From 74b60f2f73b14c6c01b20168dd8177c5337df81d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 15 Oct 2026 12:28:00 +0100 Subject: [PATCH 1/4] feat: describe backend capabilities in server instructions Agents decide when to reach for a server from its instructions, so lead with what can be built (auth, every database type, storage, functions, hosting, messaging, network) rather than "manage Appwrite projects". The list follows the appwrite-console 0.12 catalog and current docs; console-only APIs (dedicated databases, domain registration, migrations, usage) are omitted from the API-key stdio wording. The prose moves to assets/instructions.json as ordered sections with transport/docs conditions, so wording changes no longer touch code. --- AGENTS.md | 1 + .../assets/instructions.json | 75 +++++++++++++++++++ src/mcp_server_appwrite/instructions.py | 70 +++++++++++++++++ src/mcp_server_appwrite/server.py | 55 +------------- tests/unit/test_instructions.py | 49 ++++++++++++ tests/unit/test_server.py | 19 ++++- 6 files changed, 212 insertions(+), 57 deletions(-) create mode 100644 src/mcp_server_appwrite/assets/instructions.json create mode 100644 src/mcp_server_appwrite/instructions.py create mode 100644 tests/unit/test_instructions.py diff --git a/AGENTS.md b/AGENTS.md index b570c7f..d6d5fbc 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` | Server instructions sent on initialize, rendered from the ordered, transport/docs-conditional sections in `assets/instructions.json`. Edit the JSON to change the wording. | | `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/src/mcp_server_appwrite/assets/instructions.json b/src/mcp_server_appwrite/assets/instructions.json new file mode 100644 index 0000000..2c9d014 --- /dev/null +++ b/src/mcp_server_appwrite/assets/instructions.json @@ -0,0 +1,75 @@ +{ + "sections": [ + { + "sentences": [ + "Build and deploy application backends.", + "Add authentication, PostgreSQL/MySQL databases, vector databases, storage, serverless functions, realtime, messaging, hosting, domains, CDN and firewall.", + "Databases: TablesDB (relational tables, rows, columns, indexes, relationships, transactions), DocumentsDB (schemaless JSON collections and documents), and VectorsDB (vector embeddings with similarity search, plus a text embeddings API)." + ] + }, + { + "transports": ["http"], + "sentences": [ + "Dedicated databases: managed PostgreSQL, MySQL and MongoDB with direct connections, backups, branches, replicas, connection pooling, point-in-time recovery and extensions such as pgvector." + ] + }, + { + "sentences": [ + "Auth: users, sessions, teams, labels, MFA, OAuth2 providers, tokens, presences for who is online, and an OAuth2/OIDC server so the project can act as an identity provider.", + "Storage: buckets and files with image previews and transformations.", + "Functions: serverless functions across many runtimes with Git or manual deployments, executions, schedules and environment variables.", + "Sites: host static and server-rendered web apps.", + "Messaging: email, SMS and push notifications through providers, topics and targets.", + "Network: custom domains, CDN cache invalidation and proxy rules, and Firewall rules that deny, challenge, rate limit, redirect or bypass traffic to the API, Functions and Sites." + ] + }, + { + "transports": ["http"], + "sentences": [ + "Domains: register, transfer and renew domains and manage DNS records.", + "Operations: project backups and restores, migrations from other platforms, webhooks, usage metrics, and Advisor reports with insights." + ] + }, + { + "transports": ["stdio"], + "sentences": [ + "Operations: project backups and restores, webhooks, and Advisor reports with insights.", + "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." + ] + }, + { + "transports": ["http"], + "sentences": [ + "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\"})." + ] + }, + { + "docs": true, + "sentences": [ + "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." + ] + }, + { + "sentences": [ + "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." + ] + }, + { + "transports": ["stdio"], + "sentences": [ + "Large results are stored as resources; read the URI returned by the tool." + ] + }, + { + "transports": ["http"], + "sentences": [ + "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..72dbee7 --- /dev/null +++ b/src/mcp_server_appwrite/instructions.py @@ -0,0 +1,70 @@ +"""Server instructions sent to MCP clients on initialize. + +The prose lives in ``assets/instructions.json`` so it can be edited without +touching code. The file is an ordered list of sections; each section holds +sentences 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. Sections that +apply are joined into a single paragraph 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: + sentences: 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 + + @classmethod + def parse(cls, raw: dict[str, Any]) -> Section: + unknown = raw.keys() - {"sentences", "transports", "docs"} + if unknown: + raise ValueError(f"Unknown instruction section keys: {sorted(unknown)}") + sentences = raw["sentences"] + if not sentences or not all( + isinstance(sentence, str) and sentence.strip() for sentence in sentences + ): + raise ValueError("Instruction sections need non-empty sentences.") + 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(sentences), transports, docs) + + +@cache +def load_sections() -> tuple[Section, ...]: + raw = json.loads( + files("mcp_server_appwrite.assets").joinpath("instructions.json").read_text() + ) + return tuple(Section.parse(section) for section in raw["sections"]) + + +def build_instructions(transport: str = "http", *, docs_enabled: bool = True) -> str: + return " ".join( + sentence + for section in load_sections() + if section.applies(transport, docs_enabled) + for sentence in section.sentences + ) diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index 6484190..cb04974 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 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, *, diff --git a/tests/unit/test_instructions.py b/tests/unit/test_instructions.py new file mode 100644 index 0000000..f9297ed --- /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 = [ + {"sentences": []}, + {"sentences": [""]}, + {"sentences": ["Text."], "transports": ["websocket"]}, + {"sentences": ["Text."], "transports": []}, + {"sentences": ["Text."], "docs": "yes"}, + {"sentences": ["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( + {"sentences": ["Text."], "transports": ["stdio"], "docs": False} + ) + self.assertEqual(section.sentences, ("Text.",)) + self.assertEqual(section.transports, frozenset({"stdio"})) + self.assertIs(section.docs, False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 1720ad3..a9ec54b 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -268,13 +268,26 @@ 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 ("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. + http = build_instructions("http") + stdio = build_instructions("stdio") + self.assertIn("MongoDB", http) + self.assertIn("register, transfer and renew domains", http) + self.assertNotIn("MongoDB", stdio) + self.assertNotIn("register, transfer and renew domains", stdio) + 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("Build and deploy application backends."), instructions, ) From 2700d6663209002e102e6a3131cc031f0f76a63d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 15 Oct 2026 12:37:33 +0100 Subject: [PATCH 2/4] feat: use-case instructions and serverInfo.description Replace the product list with instructions that tell agents when to reach for Appwrite (any app needing a backend, managed database or hosting) and what each service covers, rendered as Markdown so the structure survives. Keep the tool workflow guidance, and tell stdio sessions which APIs need the hosted server. Set serverInfo.description (spec 2025-11-25), which clients read when choosing servers and we never sent, from the same JSON file. Align the PyPI description with it and give the registry a 100-character variant. --- AGENTS.md | 2 +- pyproject.toml | 2 +- server.template.json | 2 +- .../assets/instructions.json | 114 ++++++++++++------ src/mcp_server_appwrite/instructions.py | 52 +++++--- src/mcp_server_appwrite/server.py | 3 +- tests/e2e/test_events_protocol.py | 12 ++ tests/unit/test_instructions.py | 16 +-- tests/unit/test_release_metadata.py | 7 ++ tests/unit/test_server.py | 25 ++-- 10 files changed, 155 insertions(+), 80 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d6d5fbc..a33c9a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +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` | Server instructions sent on initialize, rendered from the ordered, transport/docs-conditional sections in `assets/instructions.json`. Edit the JSON to change the wording. | +| `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 index 2c9d014..c948481 100644 --- a/src/mcp_server_appwrite/assets/instructions.json +++ b/src/mcp_server_appwrite/assets/instructions.json @@ -1,74 +1,110 @@ { + "description": "Build and deploy application backends. Add authentication, PostgreSQL/MySQL databases, vector databases, storage, serverless functions, realtime, messaging, hosting, domains, CDN and firewall.", "sections": [ { - "sentences": [ - "Build and deploy application backends.", - "Add authentication, PostgreSQL/MySQL databases, vector databases, storage, serverless functions, realtime, messaging, hosting, domains, CDN and firewall.", - "Databases: TablesDB (relational tables, rows, columns, indexes, relationships, transactions), DocumentsDB (schemaless JSON collections and documents), and VectorsDB (vector embeddings with similarity search, plus a text embeddings API)." + "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." ] }, { - "transports": ["http"], - "sentences": [ - "Dedicated databases: managed PostgreSQL, MySQL and MongoDB with direct connections, backups, branches, replicas, connection pooling, point-in-time recovery and extensions such as pgvector." + "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" ] }, { - "sentences": [ - "Auth: users, sessions, teams, labels, MFA, OAuth2 providers, tokens, presences for who is online, and an OAuth2/OIDC server so the project can act as an identity provider.", - "Storage: buckets and files with image previews and transformations.", - "Functions: serverless functions across many runtimes with Git or manual deployments, executions, schedules and environment variables.", - "Sites: host static and server-rendered web apps.", - "Messaging: email, SMS and push notifications through providers, topics and targets.", - "Network: custom domains, CDN cache invalidation and proxy rules, and Firewall rules that deny, challenge, rate limit, redirect or bypass traffic to the API, Functions and Sites." + "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" ] }, { - "transports": ["http"], - "sentences": [ - "Domains: register, transfer and renew domains and manage DNS records.", - "Operations: project backups and restores, migrations from other platforms, webhooks, usage metrics, and Advisor reports with insights." + "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" ] }, { - "transports": ["stdio"], - "sentences": [ - "Operations: project backups and restores, webhooks, and Advisor reports with insights.", - "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." + "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, Bitbucket or Gitea, with preview deployments per branch and commit" ] }, { - "transports": ["http"], - "sentences": [ - "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\"})." + "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" ] }, { - "docs": true, - "sentences": [ - "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." + "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." ] }, { - "sentences": [ - "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." + "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"], - "sentences": [ - "Large results are stored as resources; read the URI returned by the tool." + "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"], - "sentences": [ - "Hosted HTTP returns tool results inline, including images and other binary payloads." + "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 index 72dbee7..bc83845 100644 --- a/src/mcp_server_appwrite/instructions.py +++ b/src/mcp_server_appwrite/instructions.py @@ -1,12 +1,13 @@ -"""Server instructions sent to MCP clients on initialize. +"""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. The file is an ordered list of sections; each section holds -sentences and optional conditions. A section with ``transports`` is included +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. Sections that -apply are joined into a single paragraph in file order. +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 @@ -22,7 +23,7 @@ @dataclass(frozen=True, slots=True) class Section: - sentences: tuple[str, ...] + lines: tuple[str, ...] transports: frozenset[str] | None = None docs: bool | None = None @@ -31,16 +32,19 @@ def applies(self, transport: str, docs_enabled: bool) -> bool: 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() - {"sentences", "transports", "docs"} + unknown = raw.keys() - {"lines", "transports", "docs"} if unknown: raise ValueError(f"Unknown instruction section keys: {sorted(unknown)}") - sentences = raw["sentences"] - if not sentences or not all( - isinstance(sentence, str) and sentence.strip() for sentence in sentences + 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 sentences.") + raise ValueError("Instruction sections need non-empty lines.") transports = raw.get("transports") if transports is not None: invalid = set(transports) - TRANSPORTS @@ -50,21 +54,33 @@ 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(sentences), transports, docs) + return cls(tuple(lines), transports, docs) @cache -def load_sections() -> tuple[Section, ...]: - raw = json.loads( +def _load() -> dict[str, Any]: + return json.loads( files("mcp_server_appwrite.assets").joinpath("instructions.json").read_text() ) - return tuple(Section.parse(section) for section in raw["sections"]) + + +@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 " ".join( - sentence + return "\n\n".join( + section.render() for section in load_sections() if section.applies(transport, docs_enabled) - for sentence in section.sentences ) diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index cb04974..acd7cfc 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -79,7 +79,7 @@ from .events.ingress import Ingress from .events.streams import Streams from .events.subscriptions import Subscriptions -from .instructions import build_instructions +from .instructions import build_instructions, load_description from .operator import Operator, _parse_tool_name from .service import Service from .tool_manager import ToolManager @@ -1562,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 index f9297ed..8588013 100644 --- a/tests/unit/test_instructions.py +++ b/tests/unit/test_instructions.py @@ -25,12 +25,12 @@ def test_applies_filters_by_transport_and_docs(self): def test_parse_rejects_malformed_sections(self): invalid = [ - {"sentences": []}, - {"sentences": [""]}, - {"sentences": ["Text."], "transports": ["websocket"]}, - {"sentences": ["Text."], "transports": []}, - {"sentences": ["Text."], "docs": "yes"}, - {"sentences": ["Text."], "transport": ["http"]}, + {"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): @@ -38,9 +38,9 @@ def test_parse_rejects_malformed_sections(self): def test_parse_reads_conditions(self): section = Section.parse( - {"sentences": ["Text."], "transports": ["stdio"], "docs": False} + {"lines": ["Text."], "transports": ["stdio"], "docs": False} ) - self.assertEqual(section.sentences, ("Text.",)) + self.assertEqual(section.lines, ("Text.",)) self.assertEqual(section.transports, frozenset({"stdio"})) self.assertIs(section.docs, False) 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 a9ec54b..129a333 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -271,23 +271,26 @@ def test_build_instructions_are_transport_specific(self): def test_build_instructions_cover_every_database_type(self): for transport in ("stdio", "http"): instructions = build_instructions(transport) - for product in ("TablesDB", "DocumentsDB", "VectorsDB", "Firewall"): + 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. - http = build_instructions("http") - stdio = build_instructions("stdio") - self.assertIn("MongoDB", http) - self.assertIn("register, transfer and renew domains", http) - self.assertNotIn("MongoDB", stdio) - self.assertNotIn("register, transfer and renew domains", stdio) + 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("Build and deploy application backends."), + instructions.startswith("Appwrite is a cloud backend"), instructions, ) @@ -1820,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"}) From ad618a2856ed4a13274c760c75d9e254deae215d Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 15 Oct 2026 13:05:05 +0100 Subject: [PATCH 3/4] fix: list only GitHub for Git deploys in instructions GitLab, Bitbucket and Gitea are OAuth login providers; the deploy docs and the console SDK only support GitHub repositories. --- src/mcp_server_appwrite/assets/instructions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_server_appwrite/assets/instructions.json b/src/mcp_server_appwrite/assets/instructions.json index c948481..ba2fa45 100644 --- a/src/mcp_server_appwrite/assets/instructions.json +++ b/src/mcp_server_appwrite/assets/instructions.json @@ -36,7 +36,7 @@ "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, Bitbucket or Gitea, with preview deployments per branch and commit" + "- Git deploys from GitHub, with preview deployments per branch and commit" ] }, { From 88c2dcd05f4cae2bb5e0bdc2d19f8388480b1c97 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 15 Oct 2026 13:05:58 +0100 Subject: [PATCH 4/4] fix: drop Gitea from Git deploy providers in instructions --- src/mcp_server_appwrite/assets/instructions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp_server_appwrite/assets/instructions.json b/src/mcp_server_appwrite/assets/instructions.json index ba2fa45..ef6827c 100644 --- a/src/mcp_server_appwrite/assets/instructions.json +++ b/src/mcp_server_appwrite/assets/instructions.json @@ -36,7 +36,7 @@ "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, with preview deployments per branch and commit" + "- Git deploys from GitHub, GitLab or Bitbucket, with preview deployments per branch and commit" ] }, {