Skip to content

Refactor to hosted HTTP + OAuth 2.1 transport#42

Merged
ChiragAgg5k merged 15 commits into
mainfrom
refactor/hosted-http-oauth
Jun 24, 2026
Merged

Refactor to hosted HTTP + OAuth 2.1 transport#42
ChiragAgg5k merged 15 commits into
mainfrom
refactor/hosted-http-oauth

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Jun 22, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the stdio + API-key MCP with a hosted OAuth 2.1 resource server over the MCP Streamable HTTP transport. A single deployment authenticates users against the Appwrite Cloud console project's OAuth server; the MCP validates the bearer token and forwards it to the Appwrite REST API, which accepts the OAuth2 access token directly (AccessToken::findIdentity). No API keys are distributed to clients.

Follows the MCP authorization spec: OAuth 2.1 + PKCE, RFC 9728 protected-resource metadata, RFC 8414/OIDC AS discovery, RFC 7591 Dynamic Client Registration, RFC 8707 resource indicators.

Connection URL is a single fixed endpoint:

https://mcp.appwrite.io/mcp

How it works

  1. Client calls /mcp with no token → 401 + WWW-Authenticate pointing at the protected-resource metadata.
  2. Client fetches /.well-known/oauth-protected-resource/mcp (RFC 9728) → names the console project's authorization server (<endpoint>/oauth2/console) and its scopes_supported (sourced live from AS discovery, so it never drifts).
  3. Client discovers the AS, self-registers (RFC 7591 DCR), and runs OAuth 2.1 + PKCE with the RFC 8707 resource parameter.
  4. Client calls /mcp with Authorization: Bearer <token>. The MCP verifies it (RS256/JWKS, issuer pinned to the served project, audience bound to <host>/mcp) and forwards it downstream.

Single-tenant console model + project targeting

The deployment is single-tenant: it serves one Appwrite project — the Cloud console by default (APPWRITE_PROJECT_ID, default console). The console token natively covers account/organization/project-management operations. To act on the user's own projects' data, appwrite_call_tool takes two optional args:

  • project_id → sent as X-Appwrite-Project + X-Appwrite-Mode: admin, which lets the console-issued token be recognized on that project as the owner (without admin mode it falls back to the guest role). Required for project-scoped tools (databases, tables, users, storage, functions, messaging, sites).
  • organization_id → sent as X-Appwrite-Organization for org-scoped console operations (e.g. creating a project). Admin mode is gated to project_id only, since the API rejects admin mode on the console project itself.

Server instructions tell the client to discover a project first (console-level), then pass its id.

Changes

  • Transport (http_app.py) — Starlette ASGI app serving Streamable HTTP at /mcp, RFC 9728 metadata at /.well-known/oauth-protected-resource/mcp, and /healthz (stateless SSE). stdio transport and --transport/MCP_TRANSPORT removed.
  • Auth (auth.py) — RS256/JWKS TokenVerifier pinned to the served (console) project; rejects tokens issued by any other project; enforces RFC 8707 audience binding. RFC 9728 metadata + WWW-Authenticate challenge. Reuses the SDK's BearerAuthBackend/AuthContextMiddleware so the token reaches handlers via get_access_token().
  • Execution (server.py) — tool schema built once via SDK introspection; each call re-binds the SDK method to a per-request client built from the request's OAuth token (resolve_client/build_client_for_request), adding the X-Appwrite-Project/X-Appwrite-Organization/X-Appwrite-Mode headers when targeting a project/org.
  • Operator surface (operator.py) — unchanged 2-tool surface (appwrite_search_tools, appwrite_call_tool) with the write-confirmation guard; all SDK services are auto-discovered into the hidden catalog; appwrite_call_tool gains project_id/organization_id. Large results stored as MCP resources.
  • Packaging/docsDockerfile, streamable-http remote (/mcp) in server.json, README rewritten for the hosted console-only flow (self-hosting / per-project-setup sections removed), .env.example. Version → 0.6.0.
  • Tooling/CI — Ruff + Black lint, unit, Docker-build, and (same-repo) integration jobs; GitHub Actions pinned to commit SHAs; uv (0.11.22) and build/twine pinned.

API-key helpers (build_client, etc.) remain only as integration-test scaffolding — the running server has no API-key path.

Testing

  • 37 unit tests pass (incl. test_auth.py); ruff check and black --check clean; Docker image builds; CI green (lint / unit / docker / integration).
  • End-to-end OAuth verified against a live Appwrite instance: DCR → authorize (with resource) → consent → token exchange → /mcp accepts the token (audience-bound), tools/list returns the operator surface, and appwrite_call_tool returns real data using project_id + admin mode.

Cloud-side dependencies (separate Appwrite Cloud PRs — now satisfied)

  1. RFC 7591 DCR endpoint on the OAuth2 module.
  2. RFC 8707 — the requested resource is written into the issued JWT aud (the verifier requires audience binding).
  3. The console project has oAuth2Server enabled with the advertised scope set, and admin-mode cross-project recognition for owner tokens.

Replace the stdio + API-key server with a hosted, multi-tenant OAuth 2.1
resource server over the MCP Streamable HTTP transport. Users authenticate
against Appwrite Cloud's per-project OAuth server; the MCP validates the
bearer token and forwards it to the Appwrite REST API (which accepts the
OAuth2 access token directly).

- Transport: custom Starlette ASGI app serving Streamable HTTP at
  /{project_id}/mcp (SSE responses, stateless). stdio transport removed.
- Auth (new auth.py): RFC 9728 protected-resource metadata, WWW-Authenticate
  401 challenge, and a per-project RS256/JWKS token verifier that derives the
  project from the JWT `iss` claim and enforces RFC 8707 audience binding.
- Execution: split tool schema (built once via SDK introspection) from
  binding; each call re-binds the SDK method to a per-request client built
  from the request's OAuth token.
- Packaging: Dockerfile, streamable-http remote in server.json, HTTP/OAuth
  README, .env.example. Bump to 0.5.0.
- Tooling: add Ruff linter (E/F/W/I) alongside black; pin GitHub Actions to
  commit SHAs, pin uv (0.11.22) and build/twine; add Docker-build CI job.

API-key helpers remain only as integration-test scaffolding.
- register_services now auto-discovers every Appwrite SDK service class
  (14 services, 376 tools) via an EXCLUDED_SERVICES knob, instead of a
  hardcoded list of 9. Adds account, databases, graphql, health, tokens.
- protected-resource metadata's scopes_supported is now sourced live from
  the project's authorization-server discovery (cached per project), so it
  never drifts from the tool surface. Removed the static scope list.
Auto-discovery picks up the new oauth2, apps, and 9 other services
(25 total, 575 tools). SDK 21.0.0 returns Pydantic models whose
to_dict() keeps result formatting intact, and stores the project in
client config rather than eagerly in headers (test assertion updated).
Bumps server version to 0.6.0.
The cloud OAuth server now advertises an open registration_endpoint
(RFC 7591), so MCP clients self-register without pre-shared credentials.
This server is the OAuth 2.1 Resource Server and needs no runtime change;
document the now-live self-service registration step and lock the
discovery contract it depends on.

- README: explicit self-service DCR step (public/PKCE, no client_id/secret
  to provision), project-setup note, and regional APPWRITE_ENDPOINT caveat
  (token iss is validated against it).
- unit: supported_scopes works against DCR-enabled discovery (no network).
- integration: assert the AS advertises registration_endpoint == issuer/register
  and exposes scopes_supported; skips when the endpoint predates DCR.
Collapse the multi-tenant /<project_id>/mcp routing to a single-tenant
/mcp endpoint for the Appwrite Cloud console project. The served project
is now a constant (APPWRITE_PROJECT_ID, default 'console'); the token
verifier rejects any token whose issuer names a different project.

Also fix the README service undercount (14 -> 25 auto-discovered
services) and remove the project-setup and self-hosting sections, since
the OAuth endpoints only exist on Cloud.
appwrite_call_tool gains optional project_id and organization_id. The OAuth
token authenticates against the console (which holds no project data), so:

- project_id -> X-Appwrite-Project + X-Appwrite-Mode: admin, letting the
  console token be recognized on the user's own project as the owner instead
  of falling back to the guest role.
- organization_id -> X-Appwrite-Organization for org-scoped console operations
  (e.g. creating a project). Admin mode is gated to project_id only, since the
  API rejects admin mode on the console project.

Server instructions tell the client to discover a project first and pass its
id for project-scoped tools.
unittest discover -s tests/integration imports modules top-level, so the
relative 'from .support import' failed with 'attempted relative import with no
known parent package' and broke the Integration CI job. Match the other
integration tests' absolute 'from support import'.
@ChiragAgg5k ChiragAgg5k marked this pull request as ready for review June 24, 2026 04:27
@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR moves the Appwrite MCP server to a hosted OAuth HTTP transport. The main changes are:

  • Streamable HTTP endpoint at /mcp with OAuth bearer-token validation.
  • Protected-resource metadata and authorization-server discovery support.
  • Per-request Appwrite clients built from the user's OAuth token.
  • Operator tools for catalog search, hidden tool calls, and optional docs search.
  • Docker, CI, deployment workflows, and hosted-flow documentation updates.

Confidence Score: 3/5

The hosted OAuth transport is broadly covered, but the write-confirmation guard and hosted file-upload coercion need fixes before this is safe to merge.

Focused execution confirmed the reported write-confirmation bypass and server-local path acceptance in upload coercion, so the main remaining risk is concentrated in those code paths rather than the overall transport refactor.

src/mcp_server_appwrite/operator.py and src/mcp_server_appwrite/service.py

T-Rex T-Rex Logs

What T-Rex did

  • Ran a focused unittest harness against the Operator appwrite_call_tool path with a mutating mocked tables_db_create tool and confirm_write='false' to test boolean confirmation handling.
  • Ran a focused runtime script to test server-local InputFile coercion with a sentinel file path, verifying coercion returns an InputFile with source_type 'path' and that the sentinel contents are readable.
  • Validated the HTTP/OAuth surface by running the post-change endpoint checks against /mcp; observed 401 Bearer on GET/POST, endpoint metadata including resource and scopes, and a healthy healthz endpoint.
  • Ran the JWT gate test harness to exercise RS256/JWKS token cases; after changes, valid tokens are accepted and invalid cases are rejected, with all tests passing.
  • Validated request-client bearer authorization by inspecting startup and runtime paths; confirmed execute_registered_tool uses only database_id and collection_id, and that client-auth headers are present on all request clients.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (2)

  1. src/mcp_server_appwrite/operator.py, line 382-386 (link)

    P1 Require boolean confirmation

    confirm_write is declared as a boolean, but this code converts any non-empty value with bool(...). A call like { "tool_name": "users_create", "confirm_write": "false" } is treated as confirmed and executes the mutating tool. Require the literal boolean true or explicitly reject non-boolean values so mistyped confirmations do not authorize writes.

    Artifacts

    Repro: focused unittest harness for confirm_write string false bypass

    • Contains supporting evidence from the run (text/x-python; charset=utf-8).

    Repro: unittest output showing confirm_write='false' reached the mutating executor

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

  2. src/mcp_server_appwrite/service.py, line 43-56 (link)

    P1 Disable server paths

    The hosted HTTP server now accepts requests from remote OAuth users, but every InputFile schema still advertises server-local file paths. A user can pass a path such as /etc/hosts to an upload tool, and _coerce_input_file() will read that file from the container and upload it to the user's project. In hosted mode, accept inline/base64 content only, or restrict paths to a safe per-request upload directory.

    Artifacts

    Repro: focused InputFile path coercion script

    • Contains supporting evidence from the run (text/x-python; charset=utf-8).

    Repro: script output showing accepted server-local path and sentinel readback

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

Reviews (4): Last reviewed commit: "chore: pin deployment workflow actions" | Re-trigger Greptile

Comment thread src/mcp_server_appwrite/server.py
Comment thread src/mcp_server_appwrite/http_app.py
Comment thread src/mcp_server_appwrite/auth.py
Reproduce the standalone mcp-for-docs capability inside this hosted MCP as a
third public tool, so the docs MCP server can be sunset.

- docs_search.py: load a committed index and rank doc pages by cosine
  similarity over OpenAI text-embedding-3-small query embeddings; dedupe
  matching chunks to their source page (mirrors mcp-for-docs `search`).
- scripts/build_docs_index.py: build-time pipeline that downloads
  appwrite/website docs, chunks markdown, embeds, and writes the index
  artifact (committed under data/, shipped in the image/wheel).
- Wire the tool through the existing Operator public-tool chokepoint,
  reusing _preview_or_store_result for large page content. Tool is
  registered only when the index and OPENAI_API_KEY are both present.
- Load .env early in the hosted entry point so env-driven config
  (incl. OPENAI_API_KEY) is picked up locally.
- Tests for ranking/dedup/min-score/availability and operator wiring;
  integration smoke gated on docs availability. Bump version to 0.7.0.
Comment thread src/mcp_server_appwrite/http_app.py
Comment thread src/mcp_server_appwrite/docs_search.py Outdated
Comment on lines +188 to +200
# Mirror mcp-for-docs: take the top `limit` chunks, then dedupe to pages.
top_indices = np.argsort(-scores)[:limit]

results: list[dict[str, Any]] = []
seen_pages: set[int] = set()
for index in top_indices:
score = float(scores[index])
if score < self._min_score:
continue
page_index = int(self._chunk_page[index])
if page_index in seen_pages:
continue
seen_pages.add(page_index)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Deduplicate after enough candidates

The search takes only the top limit chunks and then deduplicates them to pages. When several top chunks belong to the same page, callers can receive fewer than limit pages even though later chunks from other matching pages exist. For example, with limit=5, five high-scoring chunks from one page produce one result and hide the next four relevant pages. Iterate through sorted chunks until limit unique pages have been collected.

min_score
if min_score is not None
else float(os.getenv("DOCS_SEARCH_MIN_SCORE", DEFAULT_MIN_SCORE))
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Validate default limit

DOCS_SEARCH_LIMIT is stored directly as _default_limit, but _clamp_limit() only validates values supplied in the tool arguments. If the environment sets DOCS_SEARCH_LIMIT=0, every default docs search asks for zero results and returns No documentation matched even when matches exist. Invalid or oversized defaults also make runtime behavior differ from the advertised schema. Validate and clamp the configured default during initialization.

Comment on lines +170 to +176
if target_project:
client.add_header("x-appwrite-project", target_project)
# Admin mode lets the console-issued token be recognized on another project
# (as the owner) instead of falling back to guest. It is only valid when
# targeting a real project — the API rejects admin mode on the console
# project itself — so it is gated on target_project, not organization_id.
client.add_header("x-appwrite-mode", "admin")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Skip console admin mode

This branch adds X-Appwrite-Mode: admin for any provided project_id, including the served console project. The surrounding comment says Appwrite rejects admin mode on the console project, so a caller that passes project_id="console" for console-scoped project operations turns an otherwise valid request into an API rejection. Only send admin mode when targeting a different project from the token's issuer project.

Suggested change
if target_project:
client.add_header("x-appwrite-project", target_project)
# Admin mode lets the console-issued token be recognized on another project
# (as the owner) instead of falling back to guest. It is only valid when
# targeting a real project — the API rejects admin mode on the console
# project itself — so it is gated on target_project, not organization_id.
client.add_header("x-appwrite-mode", "admin")
if target_project:
client.add_header("x-appwrite-project", target_project)
# Admin mode lets the console-issued token be recognized on another project
# (as the owner) instead of falling back to guest. It is only valid when
# targeting a real project — the API rejects admin mode on the console
# project itself — so skip it when targeting the issuer project.
if target_project != project_id:
client.add_header("x-appwrite-mode", "admin")
Artifacts

Repro: focused client builder harness for console project admin mode

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: harness output showing console project headers and HTTP 400 Bad Request rejection

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

@ChiragAgg5k ChiragAgg5k merged commit 0ee14ad into main Jun 24, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant