diff --git a/src/mcp_server_appwrite/constants.py b/src/mcp_server_appwrite/constants.py index b2309cc..4f33b7d 100644 --- a/src/mcp_server_appwrite/constants.py +++ b/src/mcp_server_appwrite/constants.py @@ -24,6 +24,16 @@ def _resolve_server_version() -> str: SERVER_VERSION = _resolve_server_version() SERVER_WEBSITE_URL = "https://github.com/appwrite/mcp" SERVER_ICON_URL = "https://mcp.appwrite.io/favicon.svg" +# MCP Registry name; the server card and server.template.json share it. +SERVER_REGISTRY_NAME = "io.github.appwrite/mcp" +# Server card description; the card schema caps it at 100 characters. +SERVER_SUMMARY = ( + "Build and deploy backends: auth, Postgres/MySQL/vector databases, storage, " + "functions, hosting, CDN" +) +SERVER_CARD_SCHEMA = ( + "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json" +) DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1" # Region reported by single-region deployments; carries no region subdomain. diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index 056726b..eae08b0 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -12,6 +12,8 @@ authorization-server metadata at the MCP server's own origin instead of following ``resource_metadata`` from the 401 challenge. Mirrors the Appwrite authorization server's discovery document verbatim. +* ``/.well-known/mcp/server-card.json`` and ``/server-card`` — SEP-2127 server + card (name, title, description, icon, remote) for pre-connection discovery. * ``/healthz`` — liveness probe. * ``/docs/search`` — public documentation search for the deprecated docs MCP server, whose clients have no user to sign in. Results and limits are fixed, so @@ -49,7 +51,10 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, BearerAuthBackend from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.types import CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY -from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS +from mcp_types.version import ( + HANDSHAKE_PROTOCOL_VERSIONS, + SUPPORTED_PROTOCOL_VERSIONS, +) from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware @@ -80,7 +85,11 @@ DOCS_DEFAULT_LIMIT, DOCS_MAX_BODY_BYTES, DOCS_MAX_QUERY_LENGTH, + SERVER_CARD_SCHEMA, + SERVER_REGISTRY_NAME, + SERVER_SUMMARY, SERVER_VERSION, + SERVER_WEBSITE_URL, ) from .docs_search import DocsSearch from .events import protocol as events_protocol @@ -505,6 +514,30 @@ async def favicon_svg_endpoint(request: Request) -> Response: ) +async def server_card_endpoint(request: Request) -> JSONResponse: + """Server card (SEP-2127, experimental-ext-server-card v1), so registries + and agents can describe this server before connecting.""" + card = { + "$schema": SERVER_CARD_SCHEMA, + "name": SERVER_REGISTRY_NAME, + "title": "Appwrite", + "description": SERVER_SUMMARY, + "version": SERVER_VERSION, + "websiteUrl": SERVER_WEBSITE_URL, + "repository": {"url": SERVER_WEBSITE_URL, "source": "github"}, + "icons": [{"src": _icon_url(), "mimeType": "image/svg+xml", "sizes": ["any"]}], + "remotes": [ + { + "type": "streamable-http", + "url": canonical_resource(), + "supportedProtocolVersions": list(SUPPORTED_PROTOCOL_VERSIONS), + } + ], + } + headers = {**CORS_HEADERS, "Cache-Control": "public, max-age=3600"} + return JSONResponse(card, headers=headers) + + async def favicon_ico_endpoint(request: Request) -> RedirectResponse: return RedirectResponse("/favicon.svg", status_code=307) @@ -595,6 +628,14 @@ async def lifespan(app: Starlette): endpoint=oauth_authorize_proxy_endpoint, methods=["GET"], ), + Route( + "/.well-known/mcp/server-card.json", + endpoint=server_card_endpoint, + methods=["GET", "OPTIONS"], + ), + Route( + "/server-card", endpoint=server_card_endpoint, methods=["GET", "OPTIONS"] + ), Route("/favicon.svg", endpoint=favicon_svg_endpoint, methods=["GET"]), Route("/favicon.ico", endpoint=favicon_ico_endpoint, methods=["GET"]), Route("/healthz", endpoint=health_endpoint, methods=["GET"]), diff --git a/tests/e2e/test_tools.py b/tests/e2e/test_tools.py index 4468658..9f2b7f1 100644 --- a/tests/e2e/test_tools.py +++ b/tests/e2e/test_tools.py @@ -23,7 +23,17 @@ from urllib.parse import unquote import httpx -from support import PNG, SCOPES, TOKEN, Client, Cloud, Project, Server, collector +from support import ( + PNG, + PUBLIC_URL, + SCOPES, + TOKEN, + Client, + Cloud, + Project, + Server, + collector, +) from mcp_server_appwrite import docs_search @@ -146,6 +156,19 @@ def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: def text(self, result: dict[str, Any]) -> str: return result["content"][0]["text"] + def test_server_card_describes_the_server_without_auth(self): + for path in ("/.well-known/mcp/server-card.json", "/server-card"): + response = httpx.get(f"{self.server.url}{path}") + self.assertEqual(response.status_code, 200, path) + self.assertEqual(response.headers["access-control-allow-origin"], "*") + card = response.json() + self.assertEqual(card["name"], "io.github.appwrite/mcp") + self.assertEqual(card["title"], "Appwrite") + self.assertLessEqual(len(card["description"]), 100) + self.assertEqual(card["icons"][0]["src"], f"{PUBLIC_URL}/favicon.svg") + self.assertEqual(card["remotes"][0]["url"], f"{PUBLIC_URL}/") + self.assertIn("2025-11-25", card["remotes"][0]["supportedProtocolVersions"]) + def test_every_tool_has_a_title_and_explicit_hints(self): _, response = self.client.call("tools/list") tools = {tool["name"]: tool for tool in response["result"]["tools"]}