Skip to content
Draft
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
15 changes: 15 additions & 0 deletions docs/self-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ 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.
Until then its Sites and function domains open only with the returned
`accessToken`: open `https://<domain>/__appwrite/zero?token=<accessToken>` once
in the browser, or send it in the `X-Appwrite-Zero-Token` header.

```bash
claude mcp add appwrite -- uvx mcp-server-appwrite
```

## Connect your client

<details open>
Expand Down
7 changes: 7 additions & 0 deletions docs/tool-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 17 additions & 1 deletion src/mcp_server_appwrite/assets/instructions.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,26 @@
"- 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.",
"- 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://<domain>/__appwrite/zero?token=<accessToken> to open one in a browser.",
"- 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."
]
Expand Down
7 changes: 7 additions & 0 deletions src/mcp_server_appwrite/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
19 changes: 14 additions & 5 deletions src/mcp_server_appwrite/instructions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,18 +27,21 @@ 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:
return "\n".join(self.lines)

@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"]
Expand All @@ -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
Expand All @@ -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)
)
24 changes: 24 additions & 0 deletions src/mcp_server_appwrite/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
SEARCH_LIMIT,
UPDATE_HINTS,
VERBS,
ZERO_CREATE_TOOL_NAME,
)
from .docs_search import DocsSearch
from .error_classification import (
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down
36 changes: 28 additions & 8 deletions src/mcp_server_appwrite/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1772,6 +1776,7 @@ def build_operator(
docs_search=docs_search,
require_target_context=client is None,
store_results=store_results,
zero=zero,
)


Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading