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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Source lives in `src/mcp_server_appwrite/`:
| `service.py` | `Service` base class: introspects an Appwrite SDK service and turns its methods into MCP tool definitions. |
| `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). |
| `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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "mcp-server-appwrite"
dynamic = ["version"]
description = "MCP server for Appwrite: manage projects and their databases, auth, storage, functions, messaging, and sites, and search the official Appwrite documentation"
description = "Build and deploy application backends. Add authentication, PostgreSQL/MySQL databases, vector databases, storage, serverless functions, realtime, messaging, hosting, domains, CDN and firewall."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion server.template.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
"name": "io.github.appwrite/mcp",
"description": "Manage Appwrite projects, databases, auth, storage, functions, and messaging; search Appwrite docs",
"description": "Build and deploy backends: auth, Postgres/MySQL/vector databases, storage, functions, hosting, CDN",
"version": "__VERSION__",
"repository": {
"url": "https://github.com/appwrite/mcp",
Expand Down
111 changes: 111 additions & 0 deletions src/mcp_server_appwrite/assets/instructions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
{
"description": "Build and deploy application backends. Add authentication, PostgreSQL/MySQL databases, vector databases, storage, serverless functions, realtime, messaging, hosting, domains, CDN and firewall.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid advertising unavailable APIs in stdio server metadata

This description is passed to Server for both transports, but the stdio API-key catalog excludes the PostgreSQL/MySQL services and domain registration. MCP clients use serverInfo.description for discovery, so stdio connections are still advertised as offering capabilities they cannot invoke; make this description transport-aware or phrase it as Appwrite platform capabilities rather than what this connection can do.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcp_server_appwrite/assets/instructions.json
Line: 2

Comment:
**Avoid advertising unavailable APIs in stdio server metadata**

This description is passed to `Server` for both transports, but the stdio API-key catalog excludes the PostgreSQL/MySQL services and domain registration. MCP clients use `serverInfo.description` for discovery, so stdio connections are still advertised as offering capabilities they cannot invoke; make this description transport-aware or phrase it as Appwrite platform capabilities rather than what this connection can do.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · bug · Reply if this doesn't apply.

"sections": [
{
"lines": [
"Appwrite is a cloud backend for web, mobile and AI apps. Use these tools when the user is building an app that needs a backend, a managed database or hosting, or wants to add any of these to an existing one."
]
},
{
"lines": [
"Auth",
"- Sign up, login and sessions: email/password, 50 OAuth providers (Google, GitHub, Apple, Microsoft…), native ID-token sign-in, phone/SMS OTP, email OTP, magic links, anonymous users, JWTs and custom tokens",
"- MFA (authenticator app, email, SMS, recovery codes) and passkeys",
"- Teams, roles, memberships, labels and per-resource permissions; server-side user management, impersonation, and user import with Argon2, bcrypt, scrypt, PHPass, MD5 or SHA hashes"
]
},
{
"lines": [
"Databases: five engines in one project, on serverless or dedicated compute",
"- PostgreSQL: managed PostgreSQL 18 (17 supported) with full SQL, extensions such as pgvector, PostGIS and pg_trgm, a connection pooler, an in-console SQL editor, and connection strings for Prisma, Drizzle, TypeORM, Sequelize, SQLAlchemy, Django, Rails and psql",
"- MySQL: managed MySQL 8.4 (8.0 supported) with a connection pooler; move an existing Laravel, Django, Rails or Spring Boot app by changing its connection string",
"- TablesDB: relational tables through the Appwrite SDKs, with typed columns, relationships, geospatial queries, indexes, transactions, bulk operations and row-level permissions tied to Auth",
"- DocumentsDB: schemaless JSON documents with filters, full-text search and document-level permissions",
"- VectorsDB: vector collections with HNSW indexes, similarity search and built-in text embeddings for semantic search, recommendations and RAG",
"- Encrypted scheduled backups; dedicated compute adds read replicas, high availability with automatic failover, point-in-time recovery, database branches for previews and CI, online resizing and IP allowlists"
]
},
{
"lines": [
"Storage",
"- File uploads with encryption, antivirus scanning and compression; image resizing, cropping and format conversion; delivery over the CDN; shareable file tokens; S3-compatible API"
]
},
{
"lines": [
"Compute and hosting",
"- Functions: serverless code in Node.js, Python, Go, Rust, PHP, Ruby, Dart, Java, Kotlin, Swift, .NET, C++, Bun and Deno, triggered by HTTP, cron schedules or platform events, running in your region or at the edge",
"- Sites: static, SSR and CSR hosting for Next.js, Nuxt, SvelteKit, Astro, Remix, TanStack Start, Angular, Vue, React, Vite, Flutter web and more",
"- Git deploys from GitHub, GitLab or Bitbucket, with preview deployments per branch and commit"
]
},
{
"lines": [
"Domains and network",
"- Search, buy and transfer domains; connect custom domains with automatic SSL, redirects and domain rules",
"- Global CDN, edge locations, caching, compression and always-on DDoS protection",
"- Firewall rules to deny, rate limit, redirect or bypass API, Functions and Sites traffic; WAF on Enterprise",
"- Six regions for data residency: Frankfurt, New York, San Francisco, Singapore, Sydney and Toronto"
]
},
{
"lines": [
"Realtime and messaging",
"- Realtime: WebSocket subscriptions to database, storage and auth changes, plus presence",
"- Messaging: email, SMS and push notifications through SendGrid, Mailgun, Resend, Amazon SES, SMTP, Twilio, Vonage, MSG91, Telesign, Textmagic, FCM and APNs, with topics, subscribers and scheduled sends",
"- Webhooks for any platform event"
]
},
{
"lines": [
"More",
"- REST and GraphQL APIs, 15 official SDKs, CLI, Terraform provider and this MCP server",
"- Avatars: QR codes, initials, favicons, country flags and remote image transforms",
"- Locale: user location by IP, countries, currencies, languages and phone codes",
"- Migrations from Firebase, Supabase, Nhost, another Appwrite project, or CSV and JSON files",
"- SOC 2 Type II certified"
]
},
{
"lines": [
"One project and one subscription include every service, with a Free plan to start. Each service works on its own: use Appwrite just for a Postgres database, just for auth, or for the whole backend."
]
},
{
"lines": [
"Don't replace a backend the project already uses unless the user asks to migrate."
]
},
{
"lines": [
"Using these tools",
"- Use appwrite_get_context to understand the current connection and available project resources, then use appwrite_search_tools and appwrite_call_tool for specific operations.",
"- Mutating hidden tools require confirm_write=true."
]
},
{
"docs": true,
"lines": [
"- 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"],
"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."
]
},
{
"transports": ["http"],
"lines": [
"- You authenticate against the Appwrite console, which can list your organizations and projects but stores no project data itself.",
"- Project-scoped tools (every database type, users, teams, storage, functions, sites, messaging, firewall, advisor) need a target project: use appwrite_get_context first, then pass the selected project id as project_id to appwrite_call_tool.",
"- Organization-scoped console tools (e.g. creating a project or managing domains) need organization_id.",
"- File/image uploads: pass a public URL as the file argument (e.g. {\"url\": \"https://...\"}) so the server downloads it directly; for very small files you may pass inline base64 ({\"filename\": ..., \"content\": ..., \"encoding\": \"base64\"}).",
"- Hosted HTTP returns tool results inline, including images and other binary payloads."
]
}
]
}
86 changes: 86 additions & 0 deletions src/mcp_server_appwrite/instructions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Server metadata and instructions sent to MCP clients on initialize.

The prose lives in ``assets/instructions.json`` so it can be edited without
touching code. Its ``description`` is the server's ``serverInfo.description``;
its ``sections`` are an ordered list in which each section holds lines of
Markdown and optional conditions. A section with ``transports`` is included
only for those transports (the API-key stdio catalog lacks console-only APIs
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.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from functools import cache
from importlib.resources import files
from typing import Any

from .constants import TRANSPORTS


@dataclass(frozen=True, slots=True)
class Section:
lines: tuple[str, ...]
transports: frozenset[str] | None = None
docs: bool | None = None

def applies(self, transport: str, docs_enabled: bool) -> bool:
if self.transports is not None and transport not in self.transports:
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"}
if unknown:
raise ValueError(f"Unknown instruction section keys: {sorted(unknown)}")
lines = raw["lines"]
if not lines or not all(
isinstance(line, str) and line.strip() for line in lines
):
raise ValueError("Instruction sections need non-empty lines.")
transports = raw.get("transports")
if transports is not None:
invalid = set(transports) - TRANSPORTS
if not transports or invalid:
raise ValueError(f"Invalid instruction transports: {transports}")
transports = frozenset(transports)
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)


@cache
def _load() -> dict[str, Any]:
return json.loads(
files("mcp_server_appwrite.assets").joinpath("instructions.json").read_text()
)


@cache
def load_sections() -> tuple[Section, ...]:
return tuple(Section.parse(section) for section in _load()["sections"])


@cache
def load_description() -> str:
"""The ``serverInfo.description`` clients see on initialize."""
description = _load()["description"]
if not isinstance(description, str) or not description.strip():
raise ValueError("Instructions 'description' must be a non-empty string.")
return description


def build_instructions(transport: str = "http", *, docs_enabled: bool = True) -> str:
return "\n\n".join(
section.render()
for section in load_sections()
if section.applies(transport, docs_enabled)
)
56 changes: 2 additions & 54 deletions src/mcp_server_appwrite/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
from .events.ingress import Ingress
from .events.streams import Streams
from .events.subscriptions import Subscriptions
from .instructions import build_instructions, load_description
from .operator import Operator, _parse_tool_name
from .service import Service
from .tool_manager import ToolManager
Expand Down Expand Up @@ -1310,60 +1311,6 @@ def _format_response_parse_error(tool_name: str | None, response: Any = None) ->
)


def build_instructions(transport: str = "http", *, docs_enabled: bool = True) -> str:
docs = (
"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. "
if docs_enabled
else ""
)
capabilities = (
"This server covers the full Appwrite platform: it operates on Appwrite "
"resources (databases, auth/users, storage, functions, messaging, sites, "
"and more) via a searchable tool catalog"
+ (" and answers Appwrite documentation questions" if docs_enabled else "")
+ ". "
)
result_handling = (
"Large results are stored as resources; read the URI returned by the tool."
if transport == "stdio"
else "Hosted HTTP returns tool results inline, including images and other binary payloads."
)
common = (
f"{docs}"
"Appwrite workflow: use appwrite_get_context to understand the current "
"connection and available project resources, then use appwrite_search_tools "
"and appwrite_call_tool for specific operations. "
"Mutating hidden tools require confirm_write=true. "
f"{result_handling}"
)

if transport == "stdio":
return (
f"{capabilities}"
"This local Appwrite MCP connection uses the API key, endpoint, and "
"project configured in the server environment. Appwrite API calls target "
"that configured APPWRITE_PROJECT_ID by default. "
f"{common}"
)

return (
f"{capabilities}"
"You authenticate against the Appwrite console, which can list your "
"organizations and projects but stores no project data itself. Project-scoped "
"tools (Advisor, TablesDB, tables, users, storage, functions, messaging, sites) need a "
"target project: use appwrite_get_context first, then pass the selected "
"project id as project_id to appwrite_call_tool. "
"Organization-scoped console tools (e.g. creating a project) need organization_id. "
"File/image uploads: pass a public URL as the file argument (e.g. "
'{"url": "https://..."}) so the server downloads it directly; for very small '
'files you may pass inline base64 ({"filename": ..., "content": ..., '
'"encoding": "base64"}). '
f"{common}"
)


def build_mcp_server(
operator: Operator,
*,
Expand Down Expand Up @@ -1615,6 +1562,7 @@ def tool_input_schema(name: str) -> dict[str, Any] | None:
server = Server(
"Appwrite MCP Server",
version=SERVER_VERSION,
description=load_description(),
instructions=instructions,
website_url=SERVER_WEBSITE_URL,
icons=[types.Icon(src=SERVER_ICON_URL, mime_type="image/svg+xml")],
Expand Down
12 changes: 12 additions & 0 deletions tests/e2e/test_events_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from jsonschema import Draft202012Validator
from support import Server

from mcp_server_appwrite.instructions import load_description

EXTENSION = "io.modelcontextprotocol/events"

CATALOG = [
Expand Down Expand Up @@ -169,6 +171,16 @@ def test_nothing_is_advertised_or_served(self):
status, message = client.initialize()
self.assertEqual(status, 200, message)
self.assertNotIn("events", message["result"]["capabilities"])
# Clients pick servers from serverInfo.description and the
# instructions, both rendered from assets/instructions.json.
self.assertEqual(
message["result"]["serverInfo"]["description"], load_description()
)
self.assertTrue(
message["result"]["instructions"].startswith(
"Appwrite is a cloud backend"
)
)

for method in ("events/list", "events/subscribe", "events/unsubscribe"):
_, message = client.call(method)
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/test_instructions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Validation of ``assets/instructions.json`` sections.

Unit tests rather than e2e: the shipped file is always valid, so no e2e flow can
reach the malformed-section errors that guard future edits to it.
"""

import unittest

from mcp_server_appwrite.instructions import Section, load_sections


class SectionTest(unittest.TestCase):
def test_shipped_file_loads_and_covers_every_transport(self):
sections = load_sections()
for transport in ("stdio", "http"):
self.assertTrue(
any(section.applies(transport, True) for section in sections)
)

def test_applies_filters_by_transport_and_docs(self):
section = Section(("Hosted only.",), frozenset({"http"}), True)
self.assertTrue(section.applies("http", True))
self.assertFalse(section.applies("stdio", True))
self.assertFalse(section.applies("http", False))

def test_parse_rejects_malformed_sections(self):
invalid = [
{"lines": []},
{"lines": [""]},
{"lines": ["Text."], "transports": ["websocket"]},
{"lines": ["Text."], "transports": []},
{"lines": ["Text."], "docs": "yes"},
{"lines": ["Text."], "transport": ["http"]},
]
for raw in invalid:
with self.subTest(raw=raw), self.assertRaises(ValueError):
Section.parse(raw)

def test_parse_reads_conditions(self):
section = Section.parse(
{"lines": ["Text."], "transports": ["stdio"], "docs": False}
)
self.assertEqual(section.lines, ("Text.",))
self.assertEqual(section.transports, frozenset({"stdio"}))
self.assertIs(section.docs, False)


if __name__ == "__main__":
unittest.main()
Loading
Loading