From eb13284dd0f5004cefd6b4e3e1b5cdae6c55cee6 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 15 Oct 2026 15:15:54 +0100 Subject: [PATCH 1/2] feat: Appwrite Zero tools for credential-less stdio An agent with no Appwrite account can now get a working project from one tool call. When stdio starts without a project ID or API key, the server skips startup validation and exposes appwrite_create_zero_project, which calls POST /zero and re-points the shared SDK client at the returned endpoint, project and key, so the existing catalog operates on the new project for the rest of the session. appwrite_get_zero_project reports status, expiresAt and claimBy. The tool description and instructions require the agent to hand the user the claimUrl. --- AGENTS.md | 1 + docs/self-hosted.md | 12 ++ docs/tool-surface.md | 7 + .../assets/instructions.json | 17 +- src/mcp_server_appwrite/constants.py | 7 + src/mcp_server_appwrite/instructions.py | 19 +- src/mcp_server_appwrite/operator.py | 24 +++ src/mcp_server_appwrite/server.py | 36 +++- src/mcp_server_appwrite/zero.py | 192 ++++++++++++++++++ 9 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 src/mcp_server_appwrite/zero.py diff --git a/AGENTS.md b/AGENTS.md index a33c9a0..6ef7e52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ Source lives in `src/mcp_server_appwrite/`: | `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. | | `instructions.py` | `serverInfo.description` and the server instructions sent on initialize, rendered from `assets/instructions.json` (a description plus ordered, transport/docs-conditional Markdown sections). Edit the JSON to change the wording; keep the `pyproject.toml` description in sync (a test enforces it). | +| `zero.py` | Appwrite Zero (stdio without credentials): `appwrite_create_zero_project` creates a temporary project with no account and points the shared client at it; `appwrite_get_zero_project` reports its status. | | `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. | | `docs_source.py` | Fetches the published docs (`/docs/llms.txt` + per-page `.md` exports on appwrite.io) and chunks them for the index build. | diff --git a/docs/self-hosted.md b/docs/self-hosted.md index 0f5ada6..9757471 100644 --- a/docs/self-hosted.md +++ b/docs/self-hosted.md @@ -43,6 +43,18 @@ For a local Docker Appwrite instance the endpoint is typically `http://localhost:9501/v1` (HTTP, not HTTPS). The value must be the API base (`…/v1`), not the Console URL. +## No account: Appwrite Zero + +Start the server with neither `APPWRITE_PROJECT_ID` nor `APPWRITE_API_KEY` and +it boots in Zero mode. The agent calls `appwrite_create_zero_project` to get a +temporary project (endpoint, project ID and an API key valid for 60 minutes), +and the other tools then operate on it. Open the returned `claimUrl` and sign in +to keep the project; an unclaimed one is deleted. + +```bash +claude mcp add appwrite -- uvx mcp-server-appwrite +``` + ## Connect your client
diff --git a/docs/tool-surface.md b/docs/tool-surface.md index 8459853..dcc12ab 100644 --- a/docs/tool-surface.md +++ b/docs/tool-surface.md @@ -37,6 +37,13 @@ flowchart LR | `appwrite_search_tools` | Searches the internal catalog at runtime. | | `appwrite_call_tool` | Invokes a catalog tool. Mutating calls require `confirm_write=true`. | | `appwrite_search_docs` | Semantic search over Appwrite docs (conditional — see above). | +| `appwrite_create_zero_project` | Zero mode only: creates a temporary project with no account and points the other tools at it. | +| `appwrite_get_zero_project` | Zero mode only: status, `expiresAt` and `claimBy` of a Zero project. | + +**Zero mode**: stdio started with neither `APPWRITE_PROJECT_ID` nor +`APPWRITE_API_KEY` skips startup validation and adds the two Zero tools. +`appwrite_get_context` and `appwrite_call_tool` refuse until a Zero project +exists; after that they operate on it for the rest of the session. ## Behavior diff --git a/src/mcp_server_appwrite/assets/instructions.json b/src/mcp_server_appwrite/assets/instructions.json index ef6827c..4c6600b 100644 --- a/src/mcp_server_appwrite/assets/instructions.json +++ b/src/mcp_server_appwrite/assets/instructions.json @@ -89,10 +89,25 @@ "- For questions about Appwrite concepts, products, features, or guides, use appwrite_search_docs — it searches the official Appwrite documentation and needs no project or credentials." ] }, + { + "transports": ["stdio"], + "zero": false, + "lines": [ + "- This local connection uses the API key, endpoint, and project configured in the server environment. Appwrite API calls target that configured APPWRITE_PROJECT_ID by default." + ] + }, + { + "transports": ["stdio"], + "zero": true, + "lines": [ + "- No Appwrite project is configured. When the user needs a backend and has no Appwrite project, call appwrite_create_zero_project: it creates a free project with no account, valid for 60 minutes, and points the other tools at it.", + "- Always give the user the returned claimUrl and tell them to open it to keep the project; an unclaimed project is deleted.", + "- Use the returned endpoint, projectId and apiKey to configure the user's app or an Appwrite SDK." + ] + }, { "transports": ["stdio"], "lines": [ - "- This local connection uses the API key, endpoint, and project configured in the server environment. Appwrite API calls target that configured APPWRITE_PROJECT_ID by default.", "- Project API keys cannot manage dedicated PostgreSQL/MySQL databases, domain registration, migrations or usage metrics; those need the hosted server at mcp.appwrite.io.", "- Large results are stored as resources; read the URI returned by the tool." ] diff --git a/src/mcp_server_appwrite/constants.py b/src/mcp_server_appwrite/constants.py index 429542b..613cb33 100644 --- a/src/mcp_server_appwrite/constants.py +++ b/src/mcp_server_appwrite/constants.py @@ -119,6 +119,13 @@ def _resolve_server_version() -> str: INDEX_FILE = "docs_index.npz" META_MEMBER = "meta.json" +# --- zero ----------------------------------------------------------------- + +ZERO_CREATE_TOOL_NAME = "appwrite_create_zero_project" +ZERO_GET_TOOL_NAME = "appwrite_get_zero_project" +ZERO_SOURCE = "mcp" +ZERO_TIMEOUT_SECONDS = 60.0 + # --- context -------------------------------------------------------------- SERVICE_PROBES = { diff --git a/src/mcp_server_appwrite/instructions.py b/src/mcp_server_appwrite/instructions.py index bc83845..e892d1d 100644 --- a/src/mcp_server_appwrite/instructions.py +++ b/src/mcp_server_appwrite/instructions.py @@ -8,6 +8,7 @@ such as dedicated databases and domain registration, see ``catalog_policy``), and one with ``docs`` only when docs search availability matches. Lines within a section are joined by newlines and sections by blank lines, in file order. +One with ``zero`` is included only when Zero mode (see ``zero``) matches. """ from __future__ import annotations @@ -26,10 +27,13 @@ class Section: lines: tuple[str, ...] transports: frozenset[str] | None = None docs: bool | None = None + zero: bool | None = None - def applies(self, transport: str, docs_enabled: bool) -> bool: + def applies(self, transport: str, docs_enabled: bool, zero: bool = False) -> bool: if self.transports is not None and transport not in self.transports: return False + if self.zero is not None and self.zero != zero: + return False return self.docs is None or self.docs == docs_enabled def render(self) -> str: @@ -37,7 +41,7 @@ def render(self) -> str: @classmethod def parse(cls, raw: dict[str, Any]) -> Section: - unknown = raw.keys() - {"lines", "transports", "docs"} + unknown = raw.keys() - {"lines", "transports", "docs", "zero"} if unknown: raise ValueError(f"Unknown instruction section keys: {sorted(unknown)}") lines = raw["lines"] @@ -54,7 +58,10 @@ def parse(cls, raw: dict[str, Any]) -> Section: docs = raw.get("docs") if docs is not None and not isinstance(docs, bool): raise ValueError("Instruction section 'docs' must be a boolean.") - return cls(tuple(lines), transports, docs) + zero = raw.get("zero") + if zero is not None and not isinstance(zero, bool): + raise ValueError("Instruction section 'zero' must be a boolean.") + return cls(tuple(lines), transports, docs, zero) @cache @@ -78,9 +85,11 @@ def load_description() -> str: return description -def build_instructions(transport: str = "http", *, docs_enabled: bool = True) -> str: +def build_instructions( + transport: str = "http", *, docs_enabled: bool = True, zero: bool = False +) -> str: return "\n\n".join( section.render() for section in load_sections() - if section.applies(transport, docs_enabled) + if section.applies(transport, docs_enabled, zero) ) diff --git a/src/mcp_server_appwrite/operator.py b/src/mcp_server_appwrite/operator.py index eb77628..431f2e8 100644 --- a/src/mcp_server_appwrite/operator.py +++ b/src/mcp_server_appwrite/operator.py @@ -27,6 +27,7 @@ SEARCH_LIMIT, UPDATE_HINTS, VERBS, + ZERO_CREATE_TOOL_NAME, ) from .docs_search import DocsSearch from .error_classification import ( @@ -35,6 +36,7 @@ classify_tool_error, ) from .tool_manager import ToolManager +from .zero import Zero ToolContent = types.TextContent | types.ImageContent | types.EmbeddedResource # (tool_name, arguments, project_id, organization_id) -> content @@ -120,6 +122,7 @@ def __init__( require_target_context: bool = False, store_results: bool = True, search_limit: int = SEARCH_LIMIT, + zero: Zero | None = None, ): self._tools_manager = tools_manager self._execute_tool = execute_tool @@ -129,6 +132,7 @@ def __init__( self._require_target_context = require_target_context self._store_results = store_results self._search_limit = search_limit + self._zero = zero self._result_store = ResultStore() self._catalog = self._build_catalog() self._cached_catalog_json = self._catalog_json() @@ -141,6 +145,10 @@ def get_catalog_resource_uri(self) -> str: def docs_enabled(self) -> bool: return self._docs_search is not None + @property + def zero_enabled(self) -> bool: + return self._zero is not None + def get_public_tools(self) -> list[types.Tool]: tools = [ types.Tool( @@ -292,12 +300,17 @@ def get_public_tools(self) -> list[types.Tool]: if self._docs_search is not None: tools.append(self._docs_search.get_tool()) + if self._zero is not None: + tools.extend(self._zero.get_tools()) + return tools def has_public_tool(self, name: str) -> bool: 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) + if self._zero is not None and self._zero.has_tool(name): + return True return name in names def execute_public_tool( @@ -332,6 +345,15 @@ def execute_public_tool( def _dispatch_public_tool( self, name: str, arguments: dict[str, Any] | None ) -> list[ToolContent]: + if self._zero is not None: + if self._zero.has_tool(name): + return self._zero.execute(name, arguments or {}) + if not self._zero.ready and name in _PROJECT_TOOLS: + raise ValueError( + "No Appwrite project is configured. Call " + f"{ZERO_CREATE_TOOL_NAME} to create a temporary one, or set " + "APPWRITE_PROJECT_ID and APPWRITE_API_KEY." + ) if name == "appwrite_get_context": return self._get_context(arguments or {}) if name == "appwrite_search_tools": @@ -634,6 +656,8 @@ def _search_catalog( return ranked[:limit] +# Public tools that need a project; Zero mode refuses them until one exists. +_PROJECT_TOOLS = frozenset({"appwrite_get_context", "appwrite_call_tool"}) _MIN_INFLECTION_TOKEN_LENGTH = 3 _MAX_INFLECTION_SUFFIX = 2 _SUGGESTION_LIMIT = 5 diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index acd7cfc..1f8b673 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -83,6 +83,7 @@ from .operator import Operator, _parse_tool_name from .service import Service from .tool_manager import ToolManager +from .zero import Zero def _discover_service_classes() -> dict[str, type]: @@ -1324,7 +1325,9 @@ def build_mcp_server( given, serves push delivery (``events/stream``).""" _configure_uploads(transport) instructions = build_instructions( - transport, docs_enabled=bool(getattr(operator, "docs_enabled", False)) + transport, + docs_enabled=bool(getattr(operator, "docs_enabled", False)), + zero=bool(getattr(operator, "zero_enabled", False)), ) subscriptions = ListenHandler(InMemorySubscriptionBus()) @@ -1740,6 +1743,7 @@ def build_operator( client: Client | None = None, *, store_results: bool = True, + zero: Zero | None = None, ) -> Operator: """Wire the operator surface to the per-request execution path. The execution callback re-binds each call to a per-request client via `resolve_client` in @@ -1772,6 +1776,7 @@ def build_operator( docs_search=docs_search, require_target_context=client is None, store_results=store_results, + zero=zero, ) @@ -1826,17 +1831,32 @@ def build_catalog_tools_manager() -> ToolManager: async def run_stdio() -> None: - """Serve a local stdio MCP using APPWRITE_* API-key configuration.""" + """Serve a local stdio MCP using APPWRITE_* API-key configuration, or in + Zero mode (see ``zero.py``) when no project or API key is configured.""" _log_startup("Loading Appwrite configuration") - config = load_appwrite_config() - client = build_client(config) - _log_startup(f"Using Appwrite endpoint: {config.endpoint}") + load_environment() + zero = None + if os.getenv("APPWRITE_PROJECT_ID") or os.getenv("APPWRITE_API_KEY"): + config = load_appwrite_config() + client = build_client(config) + endpoint = config.endpoint + else: + endpoint = _normalize_endpoint(os.getenv("APPWRITE_ENDPOINT", DEFAULT_ENDPOINT)) + client = _configure_mcp_client_headers(Client()) + client.set_endpoint(endpoint) + zero = Zero(client, endpoint) + _log_startup(f"Using Appwrite endpoint: {endpoint}") _log_startup("Registering Appwrite services") tools_manager = register_services(client, profile=API_KEY_PROFILE) - _log_startup("Starting Appwrite service validation") - validate_services(tools_manager) + if zero is None: + _log_startup("Starting Appwrite service validation") + validate_services(tools_manager) + else: + _log_startup( + "No APPWRITE_PROJECT_ID or APPWRITE_API_KEY: starting in Zero mode" + ) _log_startup("Building Appwrite operator surface") - operator = build_operator(tools_manager, client=client) + operator = build_operator(tools_manager, client=client, zero=zero) server = build_mcp_server(operator, transport="stdio") async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): diff --git a/src/mcp_server_appwrite/zero.py b/src/mcp_server_appwrite/zero.py new file mode 100644 index 0000000..b4553ee --- /dev/null +++ b/src/mcp_server_appwrite/zero.py @@ -0,0 +1,192 @@ +"""Appwrite Zero: a temporary Appwrite project for an agent with no account. + +The stdio server boots in Zero mode when neither ``APPWRITE_PROJECT_ID`` nor +``APPWRITE_API_KEY`` is set. Instead of failing at startup it exposes +``appwrite_create_zero_project``, which asks the Appwrite console API for a +fresh project (``POST /zero``, no authentication) and points the server's shared +SDK client at the returned endpoint, project and API key. Every hidden tool +re-binds that same client on each call (see ``server.execute_registered_tool``), +so the whole catalog works against the new project for the rest of the session. + +The API key is valid until ``expiresAt``. The project is kept only if the human +opens ``claimUrl`` and signs in before ``claimBy``; otherwise it is deleted, so +the agent must hand that link to the user. +""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import mcp.types as types +from appwrite_console.client import Client + +from .constants import ( + DEFAULT_PROJECT_ID, + SERVER_VERSION, + ZERO_CREATE_TOOL_NAME, + ZERO_GET_TOOL_NAME, + ZERO_SOURCE, + ZERO_TIMEOUT_SECONDS, +) + +ToolContent = types.TextContent | types.ImageContent | types.EmbeddedResource + +_ERRORS = { + "zero_unavailable": ( + "Appwrite Zero has no capacity right now. Try again in a few minutes, or " + "ask the user to sign up for Appwrite and configure APPWRITE_PROJECT_ID " + "and APPWRITE_API_KEY." + ), + "zero_disabled": "Appwrite Zero is not enabled on this Appwrite endpoint.", +} + + +class Zero: + """Creates Zero projects and re-points ``client`` at the latest one.""" + + def __init__(self, client: Client, endpoint: str): + self._client = client + self._endpoint = endpoint + self._manifest: dict[str, Any] | None = None + + @property + def ready(self) -> bool: + return self._manifest is not None + + def get_tools(self) -> list[types.Tool]: + return [ + types.Tool( + name=ZERO_CREATE_TOOL_NAME, + annotations=types.ToolAnnotations( + read_only_hint=False, + open_world_hint=True, + destructive_hint=False, + idempotent_hint=False, + ), + description=( + "Create a free, temporary Appwrite project. No Appwrite account " + "or credentials are needed. Returns the project's endpoint, " + "projectId and an apiKey with every project scope, valid for 60 " + "minutes (until expiresAt). After this call appwrite_get_context, " + "appwrite_search_tools and appwrite_call_tool operate on the new " + "project; the same values also work with any Appwrite SDK or the " + "REST API. You MUST give the user the claimUrl: opening it and " + "signing in keeps the project and its API key. An unclaimed " + "project stops working at expiresAt and is deleted after claimBy. " + "Use this only when the user has no Appwrite project of their own." + ), + input_schema={ + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + ), + types.Tool( + name=ZERO_GET_TOOL_NAME, + annotations=types.ToolAnnotations( + read_only_hint=True, + open_world_hint=True, + destructive_hint=False, + ), + description=( + "Get the status, expiresAt and claimBy of an Appwrite Zero " + f"project. Defaults to the project {ZERO_CREATE_TOOL_NAME} " + "created in this session." + ), + input_schema={ + "type": "object", + "properties": { + "zero_id": { + "type": "string", + "description": "Zero project ID ($id). Defaults to this session's.", + }, + "secret": { + "type": "string", + "description": "Secret returned with the Zero project. Defaults to this session's.", + }, + }, + "additionalProperties": False, + }, + ), + ] + + def has_tool(self, name: str) -> bool: + return name in {ZERO_CREATE_TOOL_NAME, ZERO_GET_TOOL_NAME} + + def execute(self, name: str, arguments: dict[str, Any]) -> list[ToolContent]: + if name == ZERO_CREATE_TOOL_NAME: + return self._create() + if name == ZERO_GET_TOOL_NAME: + return self._get(arguments) + raise ValueError(f"Unknown Appwrite Zero tool {name}") + + def _create(self) -> list[ToolContent]: + manifest = self._request("post", "/zero", json={"source": ZERO_SOURCE}) + endpoint = str(manifest.get("endpoint") or self._endpoint).rstrip("/") + self._client.set_endpoint(endpoint) + self._client.set_project(manifest["projectId"]) + self._client.set_key(manifest["apiKey"]) + self._manifest = manifest + return [ + types.TextContent( + type="text", + text=( + "Created a temporary Appwrite project. The other Appwrite tools " + "now operate on it. Give the user this link to keep the project: " + f"{manifest.get('claimUrl')}\n{json.dumps(manifest, indent=2)}" + ), + ) + ] + + def _get(self, arguments: dict[str, Any]) -> list[ToolContent]: + manifest = self._manifest or {} + zero_id = arguments.get("zero_id", arguments.get("zeroId")) or manifest.get( + "$id" + ) + secret = arguments.get("secret") or manifest.get("secret") + if not zero_id or not secret: + raise ValueError( + f"zero_id and secret are required until {ZERO_CREATE_TOOL_NAME} " + "has created a project in this session." + ) + status = self._request("get", f"/zero/{zero_id}", params={"secret": secret}) + return [types.TextContent(type="text", text=json.dumps(status, indent=2))] + + def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]: + # A plain HTTP client rather than the SDK one: the SDK client carries the + # current Zero project's API key, and /zero must be called without auth. + with httpx.Client(timeout=ZERO_TIMEOUT_SECONDS) as http: + response = http.request( + method, + f"{self._endpoint}{path}", + headers={ + "x-appwrite-project": DEFAULT_PROJECT_ID, + "x-sdk-name": "mcp", + "user-agent": f"AppwriteMCP/{SERVER_VERSION}", + }, + **kwargs, + ) + try: + body = response.json() + except ValueError: + body = {} + if response.status_code < 400 and isinstance(body, dict): + return body + + error_type = body.get("type", "") if isinstance(body, dict) else "" + message = body.get("message", "") if isinstance(body, dict) else "" + if response.status_code == 429: + guidance = ( + "Too many Appwrite Zero projects were created from this network. " + "Ask the user to sign up for Appwrite and configure " + "APPWRITE_PROJECT_ID and APPWRITE_API_KEY, or try again later." + ) + else: + guidance = _ERRORS.get(error_type, "Appwrite Zero request failed.") + details = [f"code={response.status_code}"] + if error_type: + details.append(f"type={error_type}") + suffix = f": {message}" if message else "" + raise RuntimeError(f"{guidance} ({', '.join(details)}){suffix}") From a5cb1cda06c2e659cd5c0fbdb9006793dcba858d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 16 Oct 2026 22:23:56 +0100 Subject: [PATCH 2/2] feat: tell agents how to open an unclaimed Zero project's domains --- docs/self-hosted.md | 3 +++ src/mcp_server_appwrite/assets/instructions.json | 1 + src/mcp_server_appwrite/zero.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/docs/self-hosted.md b/docs/self-hosted.md index 9757471..ea0a39a 100644 --- a/docs/self-hosted.md +++ b/docs/self-hosted.md @@ -50,6 +50,9 @@ it boots in Zero mode. The agent calls `appwrite_create_zero_project` to get a temporary project (endpoint, project ID and an API key valid for 60 minutes), and the other tools then operate on it. Open the returned `claimUrl` and sign in to keep the project; an unclaimed one is deleted. +Until then its Sites and function domains open only with the returned +`accessToken`: open `https:///__appwrite/zero?token=` once +in the browser, or send it in the `X-Appwrite-Zero-Token` header. ```bash claude mcp add appwrite -- uvx mcp-server-appwrite diff --git a/src/mcp_server_appwrite/assets/instructions.json b/src/mcp_server_appwrite/assets/instructions.json index 4c6600b..49b1f2e 100644 --- a/src/mcp_server_appwrite/assets/instructions.json +++ b/src/mcp_server_appwrite/assets/instructions.json @@ -102,6 +102,7 @@ "lines": [ "- No Appwrite project is configured. When the user needs a backend and has no Appwrite project, call appwrite_create_zero_project: it creates a free project with no account, valid for 60 minutes, and points the other tools at it.", "- Always give the user the returned claimUrl and tell them to open it to keep the project; an unclaimed project is deleted.", + "- Until the project is claimed, its Sites and function domains open only with the returned accessToken: send it in the X-Appwrite-Zero-Token header when you fetch a domain, and give the user https:///__appwrite/zero?token= to open one in a browser.", "- Use the returned endpoint, projectId and apiKey to configure the user's app or an Appwrite SDK." ] }, diff --git a/src/mcp_server_appwrite/zero.py b/src/mcp_server_appwrite/zero.py index b4553ee..1d590dc 100644 --- a/src/mcp_server_appwrite/zero.py +++ b/src/mcp_server_appwrite/zero.py @@ -75,6 +75,11 @@ def get_tools(self) -> list[types.Tool]: "REST API. You MUST give the user the claimUrl: opening it and " "signing in keeps the project and its API key. An unclaimed " "project stops working at expiresAt and is deleted after claimBy. " + "Until it is claimed, its Sites and function domains open only with " + "the accessToken: send it in the X-Appwrite-Zero-Token header when " + "you fetch a domain, and give the user " + "https:///__appwrite/zero?token= to open one " + "in a browser. " "Use this only when the user has no Appwrite project of their own." ), input_schema={