Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
82 changes: 82 additions & 0 deletions docs/flags.md
Original file line number Diff line number Diff line change
@@ -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 --<name> <value>

# … or environment variable (also works with Docker/Compose and .env)
<ENV_VAR>=<value> 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 `--<name>` CLI argument and
`$<ENV>` 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 `<console-url>/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=<id>&response_type=code&redirect_uri=<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.
35 changes: 34 additions & 1 deletion src/mcp_server_appwrite/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()}/"
Expand Down Expand Up @@ -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()
)


Expand Down
76 changes: 76 additions & 0 deletions src/mcp_server_appwrite/flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""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 ``--<name>`` CLI argument and a ``<env>`` 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 ``--<name>``."""

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 ``--<name>`` argument per flag. The default is ``None`` (not the
environment variable) so an explicit ``--<name> ""`` 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=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.

A flag not provided on the CLI leaves its environment variable untouched;
an explicit empty value (``--<name> ""``) clears it."""
for flag in FLAGS:
raw = getattr(args, flag.name.replace("-", "_"), None)
if raw is not None:
os.environ[flag.env] = raw
Comment thread
greptile-apps[bot] marked this conversation as resolved.
66 changes: 66 additions & 0 deletions src/mcp_server_appwrite/http_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -324,11 +328,58 @@ 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"
if parts.query:
location = f"{location}?{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")

Expand Down Expand Up @@ -389,6 +440,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"]),
Expand Down
7 changes: 6 additions & 1 deletion src/mcp_server_appwrite/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading