diff --git a/AGENTS.md b/AGENTS.md index b570c7f..a33c9a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. | diff --git a/pyproject.toml b/pyproject.toml index a688d0e..c8ad8f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/server.template.json b/server.template.json index e8701c3..4de4387 100644 --- a/server.template.json +++ b/server.template.json @@ -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", diff --git a/src/mcp_server_appwrite/assets/instructions.json b/src/mcp_server_appwrite/assets/instructions.json new file mode 100644 index 0000000..ef6827c --- /dev/null +++ b/src/mcp_server_appwrite/assets/instructions.json @@ -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.", + "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." + ] + } + ] +} diff --git a/src/mcp_server_appwrite/instructions.py b/src/mcp_server_appwrite/instructions.py new file mode 100644 index 0000000..bc83845 --- /dev/null +++ b/src/mcp_server_appwrite/instructions.py @@ -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) + ) diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index 6484190..acd7cfc 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -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 @@ -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, *, @@ -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")], diff --git a/tests/e2e/test_events_protocol.py b/tests/e2e/test_events_protocol.py index 4f21408..652e595 100644 --- a/tests/e2e/test_events_protocol.py +++ b/tests/e2e/test_events_protocol.py @@ -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 = [ @@ -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) diff --git a/tests/unit/test_instructions.py b/tests/unit/test_instructions.py new file mode 100644 index 0000000..8588013 --- /dev/null +++ b/tests/unit/test_instructions.py @@ -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() diff --git a/tests/unit/test_release_metadata.py b/tests/unit/test_release_metadata.py index 4a4e194..f582e0a 100644 --- a/tests/unit/test_release_metadata.py +++ b/tests/unit/test_release_metadata.py @@ -1,11 +1,13 @@ import importlib.util import json import tempfile +import tomllib import unittest from pathlib import Path from unittest.mock import patch from mcp_server_appwrite import constants +from mcp_server_appwrite.instructions import load_description def _load_render_module(): @@ -40,6 +42,11 @@ def test_description_fits_mcp_registry_limit(self): template = json.loads(template_path.read_text()) self.assertLessEqual(len(template["description"]), 100) + def test_package_description_matches_server_info(self): + pyproject_path = Path(__file__).parents[2] / "pyproject.toml" + pyproject = tomllib.loads(pyproject_path.read_text()) + self.assertEqual(pyproject["project"]["description"], load_description()) + class RenderServerMetadataTests(unittest.TestCase): def test_render_server_metadata_sets_all_release_versions(self): diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 1720ad3..129a333 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -268,13 +268,29 @@ def test_build_instructions_are_transport_specific(self): self.assertIn("Large results are stored as resources", stdio) self.assertIn("returns tool results inline", http) + def test_build_instructions_cover_every_database_type(self): + for transport in ("stdio", "http"): + instructions = build_instructions(transport) + for product in ( + "PostgreSQL", + "MySQL", + "TablesDB", + "DocumentsDB", + "VectorsDB", + "Firewall", + ): + self.assertIn(product, instructions) + + # Dedicated databases and domain registration are console-only APIs + # that the API-key catalog does not expose. + self.assertIn("need the hosted server", build_instructions("stdio")) + self.assertNotIn("need the hosted server", build_instructions("http")) + def test_build_instructions_lead_with_capabilities(self): for transport in ("stdio", "http"): instructions = build_instructions(transport) self.assertTrue( - instructions.startswith( - "This server covers the full Appwrite platform" - ), + instructions.startswith("Appwrite is a cloud backend"), instructions, ) @@ -1807,9 +1823,9 @@ def test_path_string_allowed_on_stdio(self): def test_http_instructions_mention_url_upload(self): http = build_instructions("http") stdio = build_instructions("stdio") - self.assertIn("url", http.lower()) - self.assertIn("upload", http.lower()) - self.assertNotIn("upload", stdio.lower()) + guidance = "File/image uploads: pass a public URL" + self.assertIn(guidance, http) + self.assertNotIn(guidance, stdio) def test_url_fetch_pins_validated_ip_and_preserves_host_sni(self): response = _FakeResponse(data=b"abc", headers={"content-type": "image/png"})