Refactor to hosted HTTP + OAuth 2.1 transport#42
Conversation
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'.
Greptile SummaryThis PR moves the Appwrite MCP server to a hosted OAuth HTTP transport. The main changes are:
Confidence Score: 3/5The 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
What T-Rex did
|
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.
| # 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) |
There was a problem hiding this comment.
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)) | ||
| ) |
There was a problem hiding this comment.
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.
| 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") |
There was a problem hiding this comment.
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.
| 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.
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:
How it works
/mcpwith no token →401+WWW-Authenticatepointing at the protected-resource metadata./.well-known/oauth-protected-resource/mcp(RFC 9728) → names the console project's authorization server (<endpoint>/oauth2/console) and itsscopes_supported(sourced live from AS discovery, so it never drifts).resourceparameter./mcpwithAuthorization: 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, defaultconsole). The console token natively covers account/organization/project-management operations. To act on the user's own projects' data,appwrite_call_tooltakes two optional args:project_id→ sent asX-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 asX-Appwrite-Organizationfor org-scoped console operations (e.g. creating a project). Admin mode is gated toproject_idonly, since the API rejects admin mode on the console project itself.Server
instructionstell the client to discover a project first (console-level), then pass its id.Changes
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_TRANSPORTremoved.auth.py) — RS256/JWKSTokenVerifierpinned to the served (console) project; rejects tokens issued by any other project; enforces RFC 8707 audience binding. RFC 9728 metadata +WWW-Authenticatechallenge. Reuses the SDK'sBearerAuthBackend/AuthContextMiddlewareso the token reaches handlers viaget_access_token().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 theX-Appwrite-Project/X-Appwrite-Organization/X-Appwrite-Modeheaders when targeting a project/org.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_toolgainsproject_id/organization_id. Large results stored as MCP resources.Dockerfile,streamable-httpremote (/mcp) inserver.json, README rewritten for the hosted console-only flow (self-hosting / per-project-setup sections removed),.env.example. Version → 0.6.0.0.11.22) andbuild/twinepinned.API-key helpers (
build_client, etc.) remain only as integration-test scaffolding — the running server has no API-key path.Testing
test_auth.py);ruff checkandblack --checkclean; Docker image builds; CI green (lint / unit / docker / integration).resource) → consent → token exchange →/mcpaccepts the token (audience-bound),tools/listreturns the operator surface, andappwrite_call_toolreturns real data usingproject_id+ admin mode.Cloud-side dependencies (separate Appwrite Cloud PRs — now satisfied)
resourceis written into the issued JWTaud(the verifier requires audience binding).oAuth2Serverenabled with the advertised scope set, and admin-mode cross-project recognition for owner tokens.