From 4c01bf30dab1f0d48b30e46fcf88343930f31eb5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 9 Jul 2026 15:52:57 +0530 Subject: [PATCH] (fix): serve legacy OAuth discovery endpoints for 2025-03-26 MCP clients Raycast (and other clients on the 2025-03-26 MCP authorization spec) ignore the resource_metadata hint in the 401 challenge and instead fetch /.well-known/oauth-authorization-server from the MCP origin, which 404'd and surfaced as "Invalid response: not found". Serve the Appwrite authorization server's discovery document there, and also serve the protected-resource metadata at its root form for clients that don't apply RFC 9728 path insertion. --- src/mcp_server_appwrite/http_app.py | 36 +++++++++++++++++- tests/unit/test_http_app.py | 59 +++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index 3656e70..a5d5f01 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -5,7 +5,14 @@ * ``/mcp`` — the MCP Streamable-HTTP endpoint, gated by a bearer-token check that returns an RFC 9728 ``WWW-Authenticate`` challenge when unauthenticated. * ``/.well-known/oauth-protected-resource/mcp`` — RFC 9728 protected resource - metadata pointing at the project's Appwrite OAuth authorization server. + metadata pointing at the project's Appwrite OAuth authorization server. Also + served at the root form (no ``/mcp`` suffix) for clients that don't apply + path insertion. +* ``/.well-known/oauth-authorization-server`` — backwards-compatibility shim for + clients on the 2025-03-26 MCP authorization spec (e.g. Raycast), which expect + 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. * ``/healthz`` — liveness probe. Auth uses the SDK primitives (``BearerAuthBackend`` + ``AuthContextMiddleware``) so the @@ -42,6 +49,7 @@ from . import error_monitoring, telemetry from .auth import ( AppwriteTokenVerifier, + authorization_server_metadata, protected_resource_metadata, public_base_url, resource_metadata_url, @@ -291,6 +299,19 @@ async def protected_resource_metadata_endpoint(request: Request) -> JSONResponse return JSONResponse(metadata, headers=headers) +async def authorization_server_metadata_endpoint(request: Request) -> JSONResponse: + """Legacy discovery shim (MCP authorization spec 2025-03-26). + + Older clients fetch authorization-server metadata from the MCP server's own + origin rather than the ``authorization_servers`` entry in the protected + resource metadata. Serve the Appwrite authorization server's discovery + document verbatim so those clients find the real authorize/token/register + endpoints (the ``issuer`` inside points at the Appwrite endpoint, not here). + """ + metadata = await authorization_server_metadata() + return JSONResponse(metadata, headers=dict(CORS_HEADERS)) + + async def health_endpoint(request: Request) -> PlainTextResponse: return PlainTextResponse(f"appwrite-mcp {SERVER_VERSION} ok") @@ -339,6 +360,19 @@ async def lifespan(app: Starlette): endpoint=protected_resource_metadata_endpoint, methods=["GET", "OPTIONS"], ), + # Root form, for clients that don't apply RFC 9728 path insertion. + Route( + "/.well-known/oauth-protected-resource", + endpoint=protected_resource_metadata_endpoint, + methods=["GET", "OPTIONS"], + ), + # Legacy 2025-03-26 MCP clients (e.g. Raycast) discover the + # authorization server at the MCP origin itself. + Route( + "/.well-known/oauth-authorization-server", + endpoint=authorization_server_metadata_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/unit/test_http_app.py b/tests/unit/test_http_app.py index 199805a..8497a95 100644 --- a/tests/unit/test_http_app.py +++ b/tests/unit/test_http_app.py @@ -15,6 +15,9 @@ _client_from_user_agent, _find_initialize_params, _send_401, + authorization_server_metadata_endpoint, + build_app, + protected_resource_metadata_endpoint, ) @@ -170,6 +173,62 @@ def test_401_omits_scope_hint_when_discovery_unavailable(self): self.assertNotIn("scope=", challenge) +class WellKnownMetadataEndpointTests(unittest.TestCase): + """Discovery endpoints, including the legacy shims for clients on the + 2025-03-26 MCP authorization spec (e.g. Raycast) that fetch + ``/.well-known/oauth-authorization-server`` from the MCP origin instead of + following ``resource_metadata`` from the 401 challenge.""" + + ENV = { + "APPWRITE_ENDPOINT": "https://cloud.appwrite.io/v1", + "MCP_PUBLIC_URL": "https://mcp.appwrite.io", + "APPWRITE_PROJECT_ID": "console", + } + DISCOVERY = { + "issuer": "https://cloud.appwrite.io/v1/oauth2/console", + "authorization_endpoint": "https://cloud.appwrite.io/v1/oauth2/console/authorize", + "token_endpoint": "https://cloud.appwrite.io/v1/oauth2/console/token", + "registration_endpoint": "https://cloud.appwrite.io/v1/oauth2/console/register", + "jwks_uri": "https://cloud.appwrite.io/v1/oauth2/console/.well-known/jwks.json", + "scopes_supported": ["openid", "all"], + } + + def setUp(self): + patcher = mock.patch.dict(os.environ, self.ENV, clear=False) + patcher.start() + self.addCleanup(patcher.stop) + auth._store_discovery("console", dict(self.DISCOVERY)) + self.addCleanup(lambda: auth._discovery_cache.pop("console", None)) + + def _body(self, response) -> dict: + return json.loads(response.body) + + def test_authorization_server_metadata_mirrors_discovery(self): + response = asyncio.run(authorization_server_metadata_endpoint(mock.Mock())) + self.assertEqual(response.status_code, 200) + self.assertEqual(self._body(response), self.DISCOVERY) + self.assertIn("access-control-allow-origin", response.headers) + + def test_protected_resource_metadata_names_canonical_resource(self): + response = asyncio.run(protected_resource_metadata_endpoint(mock.Mock())) + self.assertEqual(response.status_code, 200) + body = self._body(response) + self.assertEqual(body["resource"], "https://mcp.appwrite.io/mcp") + self.assertEqual(body["authorization_servers"], [self.DISCOVERY["issuer"]]) + + def test_app_routes_include_legacy_discovery_paths(self): + # build_mcp_server flips the module-global upload transport to "http"; + # restore it so stdio-transport tests elsewhere keep seeing the default. + from mcp_server_appwrite import server as server_module + + original_transport = server_module._UPLOAD_TRANSPORT + self.addCleanup(setattr, server_module, "_UPLOAD_TRANSPORT", original_transport) + paths = {getattr(route, "path", None) for route in build_app().routes} + self.assertIn("/.well-known/oauth-protected-resource/mcp", paths) + self.assertIn("/.well-known/oauth-protected-resource", paths) + self.assertIn("/.well-known/oauth-authorization-server", paths) + + class ClientFromUserAgentTests(unittest.TestCase): def test_product_token(self): self.assertEqual(