From 25ad78907f8300cadc767348e1f2fec65a0f6759 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Jul 2026 09:10:43 +0530 Subject: [PATCH 1/2] (feat): add --console-url testing flag for OAuth against alternative consoles Introduces a central testing-flag registry (flags.py): each flag declares its CLI name, env var, and help text once, and gets the -- argument and $ variable automatically, with the environment as the runtime source of truth. First flag: --console-url / MCP_CONSOLE_URL. The upstream authorization server hard-codes its consent redirect to the production console, so when the flag is set the MCP server advertises itself as the authorization server, mirrors the upstream discovery document with authorization_endpoint pointing at a local /oauth2/authorize proxy, and the proxy forwards to the real authorize endpoint and rewrites the consent redirect to the override console (e.g. https://new.appwrite.io). Token, registration, and JWKS endpoints stay upstream, so token validation is unchanged. Verified end to end against new.appwrite.io: discovery -> local proxy -> new console login/consent -> code -> PKCE token exchange -> authenticated MCP initialize/tools/call. docs/flags.md documents how to add, enable, and test flags. --- AGENTS.md | 1 + docs/flags.md | 82 ++++++++++++++++++++++ src/mcp_server_appwrite/auth.py | 35 +++++++++- src/mcp_server_appwrite/flags.py | 72 ++++++++++++++++++++ src/mcp_server_appwrite/http_app.py | 64 +++++++++++++++++ src/mcp_server_appwrite/server.py | 7 +- tests/unit/test_http_app.py | 102 ++++++++++++++++++++++++++++ 7 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 docs/flags.md create mode 100644 src/mcp_server_appwrite/flags.py diff --git a/AGENTS.md b/AGENTS.md index fb91180..9b48f63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ Source lives in `src/mcp_server_appwrite/`: | `__main__.py` / `server.py` | Entry point, CLI args, transport selection (`--transport stdio\|http`), service registration, low-level MCP server. | | `http_app.py` | Hosted Streamable-HTTP transport: `/` plus the `/mcp` alias, RFC 9728 protected-resource metadata, `/healthz`. | | `auth.py` | OAuth 2.1 resource-server layer — bearer-token validation against the project's Appwrite authorization server. | +| `flags.py` | Central registry of tester/testing flags (CLI arg + env var per flag). Enabling and testing them is documented in [docs/flags.md](docs/flags.md). | | `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. | diff --git a/docs/flags.md b/docs/flags.md new file mode 100644 index 0000000..4ca12b4 --- /dev/null +++ b/docs/flags.md @@ -0,0 +1,82 @@ +# Testing flags + +Testing flags are opt-in behavior overrides for testers — they are off by +default and are not part of the supported configuration surface. All flags are +declared in one place, [`src/mcp_server_appwrite/flags.py`](../src/mcp_server_appwrite/flags.py), +and every flag can be enabled two equivalent ways: + +```bash +# CLI argument … +uv run mcp-server-appwrite --transport http -- + +# … or environment variable (also works with Docker/Compose and .env) += uv run mcp-server-appwrite --transport http +``` + +The CLI argument simply writes through to the environment variable, which is +the single runtime source of truth. + +## Adding a flag + +1. Declare a `Flag(name=..., env=..., help=...)` in `flags.py` and add it to + `FLAGS`. That's all it takes to get the `--` CLI argument and + `$` variable. +2. Read it where needed with `flags.value(flags.MY_FLAG)` — read at request + time (not import time) so tests can toggle it via `os.environ`. +3. Add unit tests for the behavior the flag changes. +4. Document it below: what it does, how to enable it, and how to verify it. + +Flags are for testing overrides only — permanent configuration belongs in +`constants.py` or a plain environment variable. + +## Available flags + +| CLI | Env | What it does | +| --- | --- | --- | +| `--console-url` | `MCP_CONSOLE_URL` | Send OAuth login/consent to an alternative Appwrite Console (HTTP transport only). | + +### `--console-url` — test OAuth against a pre-release console + +By default the Appwrite authorization server redirects users to the production +console (`cloud.appwrite.io/console`) for login and consent. This flag lets +testers run the whole OAuth flow through a different console deployment, for +example the new console at `new.appwrite.io`. + +How it works: the MCP server advertises itself as the OAuth authorization +server and mirrors the real server's discovery document with one change — the +`authorization_endpoint` points at a local `/oauth2/authorize` proxy. The proxy +forwards each authorize request to the real Appwrite authorize endpoint +(keeping all upstream validation) and rewrites its consent redirect from the +default console to `/oauth2/consent`. Token, registration, and +JWKS endpoints stay on the real authorization server, so token validation is +unchanged. + +**Enable it:** + +```bash +MCP_PUBLIC_URL=http://localhost:8000 \ + uv run mcp-server-appwrite --transport http --console-url https://new.appwrite.io +``` + +**Quick checks (no browser):** + +```bash +# Protected resource metadata names this MCP server as the authorization server +curl -s http://localhost:8000/.well-known/oauth-protected-resource | jq .authorization_servers + +# Mirrored discovery points the authorize step at the local proxy +curl -s http://localhost:8000/.well-known/oauth-authorization-server | jq .authorization_endpoint + +# The authorize proxy rewrites the consent redirect to the override console +curl -sI "http://localhost:8000/oauth2/authorize?client_id=&response_type=code&redirect_uri=&scope=openid" | grep -i location +``` + +**Full flow with an MCP client:** + +```bash +claude mcp add --transport http appwrite-test http://localhost:8000/ +``` + +Then run `/mcp` in Claude Code and authenticate — the browser should open the +override console's sign-in page, and after consent the client completes the +token exchange against the real authorization server. diff --git a/src/mcp_server_appwrite/auth.py b/src/mcp_server_appwrite/auth.py index 451209c..2cc2b72 100644 --- a/src/mcp_server_appwrite/auth.py +++ b/src/mcp_server_appwrite/auth.py @@ -26,6 +26,7 @@ from jwt import PyJWKClient from mcp.server.auth.provider import AccessToken, TokenVerifier +from . import flags from .constants import ( CACHE_TTL_SECONDS, DEFAULT_ENDPOINT, @@ -60,6 +61,34 @@ def issuer_url() -> str: return f"{appwrite_endpoint()}/oauth2/{configured_project_id()}" +def console_url() -> str | None: + """Tester override: base URL of an alternative Appwrite Console (for example + ``https://new.appwrite.io``). When set, the MCP server proxies the OAuth + authorize step and sends users to this console's login/consent pages instead + of the console the authorization server redirects to by default. Token, + registration, and JWKS endpoints stay on the real authorization server, so + token validation is unaffected. See ``docs/flags.md``.""" + return flags.value(flags.CONSOLE_URL) + + +def local_authorize_endpoint() -> str: + """The MCP-hosted authorize proxy used when a console override is active.""" + return f"{public_base_url()}/oauth2/authorize" + + +def proxied_authorization_server_metadata(metadata: dict) -> dict: + """Rewrite upstream authorization-server metadata for the console override. + + The MCP server presents itself as the authorization server (so clients hit + the local authorize proxy, which forwards to the real authorize endpoint and + rewrites the consent redirect to the override console); every other endpoint + is served verbatim from the upstream document.""" + rewritten = dict(metadata) + rewritten["issuer"] = public_base_url() + rewritten["authorization_endpoint"] = local_authorize_endpoint() + return rewritten + + def canonical_resource() -> str: """RFC 8707 canonical resource URI for this MCP server.""" return f"{public_base_url()}/" @@ -332,8 +361,12 @@ async def protected_resource_metadata(*, resource: str | None = None) -> dict: discovery.""" metadata = await authorization_server_metadata() scopes = await _filter_deprecated_scopes(_advertised_scopes(metadata)) + # With a console override active, the MCP server is the advertised + # authorization server: clients then discover the local authorize proxy from + # the metadata mirrored at this origin. + authorization_server = public_base_url() if console_url() else metadata["issuer"] return build_resource_metadata( - scopes, [metadata["issuer"]], resource=resource or canonical_resource() + scopes, [authorization_server], resource=resource or canonical_resource() ) diff --git a/src/mcp_server_appwrite/flags.py b/src/mcp_server_appwrite/flags.py new file mode 100644 index 0000000..2c61a5b --- /dev/null +++ b/src/mcp_server_appwrite/flags.py @@ -0,0 +1,72 @@ +"""Central registry for tester/feature flags. + +A flag is an opt-in behavior override for testing (for example, pointing OAuth +login at a pre-release console). Every flag is declared once here and gets, for +free, a ``--`` CLI argument and a ```` environment variable — the CLI +argument simply writes through to the environment, which is the single runtime +source of truth (modules read flags per request via :func:`value`, so tests can +toggle them with ``mock.patch.dict(os.environ, ...)``). + +To add a flag: + +1. Add a ``Flag`` entry to ``FLAGS`` below. +2. Read it where needed with ``flags.value(flags.MY_FLAG)``. +3. Document how to enable and test it in ``docs/flags.md``. + +Flags are for testing overrides only — permanent configuration belongs in +``constants.py`` or a plain environment variable. +""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Flag: + name: str + """Kebab-case CLI name, exposed as ``--``.""" + + env: str + """Environment variable backing the flag; the CLI argument writes to it.""" + + help: str + """One-line description shown in ``--help`` and docs.""" + + +CONSOLE_URL = Flag( + name="console-url", + env="MCP_CONSOLE_URL", + help=( + "Base URL of an alternative Appwrite Console to use for OAuth " + "login/consent (e.g. https://new.appwrite.io). HTTP transport only." + ), +) + +FLAGS: tuple[Flag, ...] = (CONSOLE_URL,) + + +def value(flag: Flag) -> str | None: + """The flag's current value (normalized), or ``None`` when unset.""" + return os.getenv(flag.env, "").strip().rstrip("/") or None + + +def register_cli_args(parser: argparse.ArgumentParser) -> None: + """Add a ``--`` argument per flag, defaulting to its environment + variable so both spellings behave identically.""" + for flag in FLAGS: + parser.add_argument( + f"--{flag.name}", + default=os.getenv(flag.env, ""), + help=f"Testing flag: {flag.help} (default ${flag.env}).", + ) + + +def apply_cli_args(args: argparse.Namespace) -> None: + """Write parsed CLI flag values back to their environment variables.""" + for flag in FLAGS: + raw = getattr(args, flag.name.replace("-", "_"), "") + if raw: + os.environ[flag.env] = raw diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index d76d58f..e5b2eff 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -28,7 +28,9 @@ import re import sys from importlib.resources import files +from urllib.parse import urlsplit +import httpx from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, BearerAuthBackend from mcp.server.streamable_http_manager import StreamableHTTPSessionManager @@ -50,8 +52,10 @@ AppwriteTokenVerifier, authorization_server_metadata, canonical_resource, + console_url, mcp_path_resource, protected_resource_metadata, + proxied_authorization_server_metadata, public_base_url, resource_metadata_url, ) @@ -324,11 +328,56 @@ async def authorization_server_metadata_endpoint(request: Request) -> JSONRespon 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). + + With a console override active (``MCP_CONSOLE_URL``), this document is also + the primary discovery path: the protected resource metadata names this MCP + server as the authorization server, and the mirrored document points the + authorize step at the local proxy so login/consent happens on the override + console. """ metadata = await authorization_server_metadata() + if console_url(): + metadata = proxied_authorization_server_metadata(metadata) return JSONResponse(metadata, headers=dict(CORS_HEADERS)) +_REDIRECT_STATUSES = {301, 302, 303, 307, 308} + + +async def oauth_authorize_proxy_endpoint(request: Request) -> Response: + """Authorize proxy for the console override (``MCP_CONSOLE_URL``). + + Forwards the OAuth authorize request to the real Appwrite authorization + server (which performs client/redirect-URI validation) and rewrites its + consent-page redirect to the override console, which serves the same + login/consent flow at ``/oauth2/consent``. Error redirects back to the + client's ``redirect_uri`` and non-redirect responses pass through verbatim. + """ + if not console_url(): + return PlainTextResponse("Not Found", status_code=404) + + metadata = await authorization_server_metadata() + upstream = metadata["authorization_endpoint"] + if request.url.query: + upstream = f"{upstream}?{request.url.query}" + + async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client: + resp = await client.get(upstream) + + location = resp.headers.get("location") + if resp.status_code in _REDIRECT_STATUSES and location: + parts = urlsplit(location) + if parts.path.rstrip("/").endswith("/oauth2/consent"): + location = f"{console_url()}/oauth2/consent?{parts.query}" + return RedirectResponse(location, status_code=303) + + return Response( + resp.content, + status_code=resp.status_code, + media_type=resp.headers.get("content-type"), + ) + + async def health_endpoint(request: Request) -> PlainTextResponse: return PlainTextResponse(f"appwrite-mcp {SERVER_VERSION} ok") @@ -389,6 +438,21 @@ async def lifespan(app: Starlette): endpoint=authorization_server_metadata_endpoint, methods=["GET", "OPTIONS"], ), + # OIDC-style discovery for clients that resolve the advertised + # authorization server (this origin, when MCP_CONSOLE_URL is set) via + # openid-configuration instead of oauth-authorization-server. + Route( + "/.well-known/openid-configuration", + endpoint=authorization_server_metadata_endpoint, + methods=["GET", "OPTIONS"], + ), + # Authorize proxy for the console override; 404 unless MCP_CONSOLE_URL + # is set. + Route( + "/oauth2/authorize", + endpoint=oauth_authorize_proxy_endpoint, + methods=["GET"], + ), 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/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py index 6163f78..fb518ab 100644 --- a/src/mcp_server_appwrite/server.py +++ b/src/mcp_server_appwrite/server.py @@ -40,7 +40,7 @@ from mcp.server.models import InitializationOptions from pydantic import AnyUrl -from . import error_monitoring, telemetry +from . import error_monitoring, flags, telemetry from .constants import ( CACHE_TTL_SECONDS, CATALOG_URI, @@ -137,6 +137,7 @@ def parse_args(argv: list[str] | None = None): default=int(os.getenv("PORT", "8000")), help="Bind port for the HTTP server (default $PORT or 8000).", ) + flags.register_cli_args(parser) args = parser.parse_args(argv) try: args.transport = _transport_arg(args.transport) @@ -1398,6 +1399,10 @@ def main(): asyncio.run(run_stdio()) return 0 + # Testing flags are read from the environment at request time; fold any + # CLI-provided values back in (see flags.py and docs/flags.md). + flags.apply_cli_args(args) + from .http_app import run_http run_http(host=args.host, port=args.port) diff --git a/tests/unit/test_http_app.py b/tests/unit/test_http_app.py index 10f6189..d6348ea 100644 --- a/tests/unit/test_http_app.py +++ b/tests/unit/test_http_app.py @@ -19,6 +19,7 @@ authorization_server_metadata_endpoint, build_app, mcp_path_protected_resource_metadata_endpoint, + oauth_authorize_proxy_endpoint, protected_resource_metadata_endpoint, ) @@ -256,6 +257,7 @@ class WellKnownMetadataEndpointTests(unittest.TestCase): "APPWRITE_ENDPOINT": "https://cloud.appwrite.io/v1", "MCP_PUBLIC_URL": "https://mcp.appwrite.io", "APPWRITE_PROJECT_ID": "console", + "MCP_CONSOLE_URL": "", } DISCOVERY = { "issuer": "https://cloud.appwrite.io/v1/oauth2/console", @@ -313,6 +315,106 @@ def test_app_routes_include_legacy_discovery_paths(self): self.assertIn("/.well-known/oauth-authorization-server", paths) +class ConsoleOverrideTests(unittest.TestCase): + """The MCP_CONSOLE_URL tester flag: discovery rewrites plus the local + authorize proxy that sends login/consent to an alternative console.""" + + ENV = { + "APPWRITE_ENDPOINT": "https://cloud.appwrite.io/v1", + "MCP_PUBLIC_URL": "https://mcp.appwrite.io", + "APPWRITE_PROJECT_ID": "console", + "MCP_CONSOLE_URL": "https://new.appwrite.io", + } + DISCOVERY = dict(WellKnownMetadataEndpointTests.DISCOVERY) + + 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) + + @staticmethod + def _fake_httpx_client(response): + client = mock.AsyncMock() + client.get.return_value = response + context = mock.AsyncMock() + context.__aenter__.return_value = client + return mock.Mock(return_value=context), client + + @staticmethod + def _upstream_response(status_code, headers=None, content=b""): + response = mock.Mock() + response.status_code = status_code + response.headers = headers or {} + response.content = content + return response + + def test_discovery_rewrites_authorize_step_only(self): + response = asyncio.run(authorization_server_metadata_endpoint(mock.Mock())) + body = self._body(response) + self.assertEqual(body["issuer"], "https://mcp.appwrite.io") + self.assertEqual( + body["authorization_endpoint"], + "https://mcp.appwrite.io/oauth2/authorize", + ) + # Everything else mirrors the upstream document. + self.assertEqual(body["token_endpoint"], self.DISCOVERY["token_endpoint"]) + self.assertEqual(body["jwks_uri"], self.DISCOVERY["jwks_uri"]) + + response = asyncio.run(protected_resource_metadata_endpoint(mock.Mock())) + self.assertEqual( + self._body(response)["authorization_servers"], + ["https://mcp.appwrite.io"], + ) + + def _request(self, query: str) -> mock.Mock: + request = mock.Mock() + request.url.query = query + return request + + def test_authorize_proxy_rewrites_consent_redirect(self): + upstream = self._upstream_response( + 303, + { + "location": "https://cloud.appwrite.io/console/oauth2/consent" + "?client_id=abc&state=xyz" + }, + ) + factory, client = self._fake_httpx_client(upstream) + with mock.patch("mcp_server_appwrite.http_app.httpx.AsyncClient", factory): + response = asyncio.run( + oauth_authorize_proxy_endpoint(self._request("client_id=abc")) + ) + client.get.assert_awaited_once_with( + f"{self.DISCOVERY['authorization_endpoint']}?client_id=abc" + ) + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "https://new.appwrite.io/oauth2/consent?client_id=abc&state=xyz", + ) + + def test_authorize_proxy_passes_through_error_redirect(self): + upstream = self._upstream_response( + 303, + {"location": "http://localhost:33418/callback?error=invalid_scope"}, + ) + factory, _client = self._fake_httpx_client(upstream) + with mock.patch("mcp_server_appwrite.http_app.httpx.AsyncClient", factory): + response = asyncio.run( + oauth_authorize_proxy_endpoint(self._request("client_id=abc")) + ) + self.assertEqual(response.status_code, 303) + self.assertEqual( + response.headers["location"], + "http://localhost:33418/callback?error=invalid_scope", + ) + + class ClientFromUserAgentTests(unittest.TestCase): def test_product_token(self): self.assertEqual( From ce975e8913a6488d2360142c74e7f8bf5cb032e2 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 16 Jul 2026 09:18:31 +0530 Subject: [PATCH 2/2] =?UTF-8?q?(fix):=20address=20review=20=E2=80=94=20no?= =?UTF-8?q?=20bare=20'=3F'=20on=20query-less=20consent=20redirects,=20CLI?= =?UTF-8?q?=20can=20clear=20env-set=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The consent-redirect rewrite no longer emits a trailing bare '?' when the upstream Location has no query string. - Flag CLI arguments now default to None instead of mirroring the env var, so an explicit --console-url "" clears a flag exported in the shell while an omitted argument leaves the environment untouched. - Tests: query-less consent redirect, 404 when the flag is unset, and CLI clear-vs-omit semantics. --- src/mcp_server_appwrite/flags.py | 16 +++++++++------ src/mcp_server_appwrite/http_app.py | 4 +++- tests/unit/test_http_app.py | 31 +++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/mcp_server_appwrite/flags.py b/src/mcp_server_appwrite/flags.py index 2c61a5b..a1d7e27 100644 --- a/src/mcp_server_appwrite/flags.py +++ b/src/mcp_server_appwrite/flags.py @@ -54,19 +54,23 @@ def value(flag: Flag) -> str | None: def register_cli_args(parser: argparse.ArgumentParser) -> None: - """Add a ``--`` argument per flag, defaulting to its environment - variable so both spellings behave identically.""" + """Add a ``--`` argument per flag. The default is ``None`` (not the + environment variable) so an explicit ``-- ""`` is distinguishable + from "not provided" and can clear a flag exported in the shell.""" for flag in FLAGS: parser.add_argument( f"--{flag.name}", - default=os.getenv(flag.env, ""), + default=None, help=f"Testing flag: {flag.help} (default ${flag.env}).", ) def apply_cli_args(args: argparse.Namespace) -> None: - """Write parsed CLI flag values back to their environment variables.""" + """Write parsed CLI flag values back to their environment variables. + + A flag not provided on the CLI leaves its environment variable untouched; + an explicit empty value (``-- ""``) clears it.""" for flag in FLAGS: - raw = getattr(args, flag.name.replace("-", "_"), "") - if raw: + raw = getattr(args, flag.name.replace("-", "_"), None) + if raw is not None: os.environ[flag.env] = raw diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index e5b2eff..7d0cd50 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -368,7 +368,9 @@ async def oauth_authorize_proxy_endpoint(request: Request) -> Response: if resp.status_code in _REDIRECT_STATUSES and location: parts = urlsplit(location) if parts.path.rstrip("/").endswith("/oauth2/consent"): - location = f"{console_url()}/oauth2/consent?{parts.query}" + location = f"{console_url()}/oauth2/consent" + if parts.query: + location = f"{location}?{parts.query}" return RedirectResponse(location, status_code=303) return Response( diff --git a/tests/unit/test_http_app.py b/tests/unit/test_http_app.py index d6348ea..8c8617b 100644 --- a/tests/unit/test_http_app.py +++ b/tests/unit/test_http_app.py @@ -1,3 +1,4 @@ +import argparse import asyncio import json import logging @@ -398,6 +399,19 @@ def test_authorize_proxy_rewrites_consent_redirect(self): "https://new.appwrite.io/oauth2/consent?client_id=abc&state=xyz", ) + def test_authorize_proxy_rewrites_queryless_consent_redirect_without_bare_qmark( + self, + ): + upstream = self._upstream_response( + 303, {"location": "https://cloud.appwrite.io/console/oauth2/consent"} + ) + factory, _client = self._fake_httpx_client(upstream) + with mock.patch("mcp_server_appwrite.http_app.httpx.AsyncClient", factory): + response = asyncio.run(oauth_authorize_proxy_endpoint(self._request(""))) + self.assertEqual( + response.headers["location"], "https://new.appwrite.io/oauth2/consent" + ) + def test_authorize_proxy_passes_through_error_redirect(self): upstream = self._upstream_response( 303, @@ -414,6 +428,23 @@ def test_authorize_proxy_passes_through_error_redirect(self): "http://localhost:33418/callback?error=invalid_scope", ) + def test_authorize_proxy_404_when_flag_unset(self): + with mock.patch.dict(os.environ, {"MCP_CONSOLE_URL": ""}): + response = asyncio.run(oauth_authorize_proxy_endpoint(mock.Mock())) + self.assertEqual(response.status_code, 404) + + def test_cli_empty_value_clears_env_flag(self): + from mcp_server_appwrite import flags + + parser = argparse.ArgumentParser() + flags.register_cli_args(parser) + + flags.apply_cli_args(parser.parse_args([])) + self.assertEqual(flags.value(flags.CONSOLE_URL), "https://new.appwrite.io") + + flags.apply_cli_args(parser.parse_args(["--console-url", ""])) + self.assertIsNone(flags.value(flags.CONSOLE_URL)) + class ClientFromUserAgentTests(unittest.TestCase): def test_product_token(self):