Skip to content

Remote (hosted) mode ​

For the steps a user follows to connect, see Connect an AI assistant.

An MCP client pointed at https://spellingcreator.org/mcp connects over Streamable HTTP with a real OAuth 2.1 flow: no token to copy, paste or store. This is the recommended way to connect any client that supports remote MCP servers (claude.ai, Claude Desktop, Cursor and others); the local stdio setup remains the path for development and for clients that only run local servers.

How it works ​

  1. The client discovers the server's OAuth metadata and registers itself automatically (RFC 7591 Dynamic Client Registration at /register), so there is nothing to set up on the user's end.
  2. It opens the user's browser at /authorize. The Worker parses the request, stores it in KV under a random, single-use state token that lives for 10 minutes, and redirects to an ordinary page of the web app, /oauth/authorize?state=…. If the user isn't signed in, that page offers the same magic-link sign-in as /login, with the link routed back to the same page and the email's code accepted in place.
  3. The consent screen shows which client is connecting and what it can do, and the user chooses Approve or Deny. Approving requires a display name; without one the Worker answers 403.
  4. The client receives its own access/refresh token pair (scope lessons) and starts calling tools. No Supabase token is ever shown to the user or passed to the client; the grant holds the user's Supabase session and the server calls the hub's normal endpoints with it.

The tool layer (src/tools.js) and API client (src/api.js) are the exact same code the stdio server uses: the same tools, the same validation, the same author attribution. That includes the live session tools: HubMcp hibernates between tool calls and rebuilds its server on the way back, so the one thing those tools must remember (which session the connection is in) is kept in the Durable Object's own storage rather than in the server.

There is no page in the web app that lists or revokes a user's grants yet. A user disconnects by removing the connector in their client.

Worker-side implementation ​

The whole thing is implemented in apps/api, not apps/mcp; the MCP package only supplies the remote-specific pieces the Worker composes:

  • src/worker.js (@spelling-creator/mcp/worker): buildMcpServer (build a connection-scoped McpServer given any auth provider), grantAuth (an auth provider seeded from an OAuth grant's props, with the same getAccessToken()/forceRefresh() shape the stdio auth provider has) and durableSessionStore (the live-session handle, kept in a Durable Object's storage so it survives hibernation).
  • src/auth.js (@spelling-creator/mcp/auth): refreshSupabaseSession, the plain Supabase refresh-token exchange shared by the stdio server, grantAuth's fallback, and the Worker's token refresh (below).

On the Worker side (apps/api):

  • src/routes/mcp.js wires up @cloudflare/workers-oauth-provider with HubMcp (an McpAgent from the Cloudflare Agents SDK, one per MCP connection) as the /mcp API handler, and the existing Hono app as the defaultHandler for everything else. src/index.js builds this OAuthProvider per request (cheap, no I/O) so its callback can close over the request's env. Supabase is the upstream identity: a grant's props carry a Supabase session captured at consent time, and a tokenExchangeCallback rotates it in step with the MCP client's own OAuth token refresh. MCP access tokens live 45 minutes (MCP_ACCESS_TOKEN_TTL), a little shorter than Supabase's roughly one-hour JWT, so a tool call's Supabase access token is normally already fresh with no extra round trip. grantAuth is the defense-in-depth fallback for a connection that outlives that cadence.
  • src/routes/oauth.js implements the consent flow's three endpoints: GET /authorize (parse the client's request, stash it, redirect to the consent page), GET /oauth/request (the consent page's data for a state token: client name, redirect URI, scope) and POST /oauth/approve (verify the user's Supabase session and display name, then complete the authorization with the session as the grant's props). They're registered on the Hono app by registerOAuthConsentRoutes. /token, /register and the discovery metadata are implemented by the OAuthProvider library.
  • The consent page is apps/web/src/pages/OAuthAuthorizePage.jsx, an ordinary route (/oauth/authorize) of the SPA. It calls the two /oauth/* endpoints through @spelling-creator/core/mcpOAuth.

Infrastructure ​

Deploying this needs a KV namespace for the OAuth provider's grant, token and client storage (also used for the consent flow's short-lived pending-request state, reached through oauthStateStore in src/platform/) and a Durable Object binding for HubMcp. Both are declared in apps/api/wrangler.jsonc:

  • kv_namespaces: OAUTH_KV, already filled in for production. For your own Cloudflare account, create one with wrangler kv namespace create OAUTH_KV and put the returned id there.
  • durable_objects: MCP_OBJECT bound to the HubMcp class (the binding name McpAgent.serve() looks for by default), with its SQLite migration (tag v2).

No new secrets are needed. Approving reuses verifySupabaseUser (which uses the existing SUPABASE_SERVICE_ROLE_KEY secret), and the server's config (mcpConfig in src/routes/mcp.js) reads the SUPABASE_URL, SUPABASE_ANON_KEY and SPELLING_CREATOR_API_URL vars already in wrangler.jsonc.

The remote endpoint ships with the Worker: the deploy workflow (.github/workflows/deploy.yml) runs on pushes to main that touch apps/mcp/** among other paths, so a change to the tools reaches spellingcreator.org/mcp on the next deploy. The .mcpb bundle, by contrast, is released by hand (see Packaging the bundle).

The Node entry point for self-hosting (apps/api/src/node/server.js) doesn't serve /mcp, since it needs Durable Objects; self-hosters use the stdio server instead.

A note on the agents dependency ​

apps/api depends on agents (the Cloudflare Agents SDK, for McpAgent) and @cloudflare/workers-oauth-provider. One of agents' own transitive dependencies, @cfworker/json-schema, probes self.location at module load to pick a default base URI and throws on Workers' location global under this Worker's compatibility settings, crashing the whole Worker before any request is handled. Because the crash happens while Cloudflare evaluates the script's top-level scope, the upload fails too, with error code 10021: the deploy never produces a broken version, it just refuses.

This is patched via pnpm patch to fall back to the library's own safe default instead of throwing; pnpm install applies it automatically.

Three patches are needed, not one, because two of agents' other dependencies inline their own copy of @cfworker/json-schema into their published bundles. A patchedDependencies entry only rewrites the package pnpm installs, so it cannot reach a copy baked into someone else's dist/:

PatchFixes
patches/@cfworker__json-schema@4.1.1.patchthe real package
patches/@modelcontextprotocol__client@2.0.0.patchits copy in dist/cfWorkerProvider-*.{mjs,cjs}
patches/@modelcontextprotocol__server@2.0.0.patchits copy in dist/cfWorkerProvider-*.{mjs,cjs}

All three are registered under patchedDependencies in pnpm-workspace.yaml.

Two traps when this resurfaces after a dependency bump. First, the stack trace points at a path like node_modules/.pnpm/@modelcontextprotocol+client@2.0.0/node_modules/node_modules/.pnpm/@cfworker+json-schema@4.1.1/… that does not exist in your tree; it is a sourcemap path from the upstream package's own build machine, and it is what gives the inlining away. Second, the patched copies live in .pnpm directories carrying a _patch_hash= suffix, so a path without that suffix is an unpatched copy.

To check the fix actually reaches the shipped bundle rather than trusting the install, bundle the Worker (the same dry run CI does, written to apps/api/dist; like CI, it needs the web app built first) and search the output; every hit should be inside a try:

bash
pnpm --filter @spelling-creator/api bundle
rg -c 'initialBaseURI[0-9]* = typeof self' apps/api/dist/index.js   # expect no matches

Copyright © 2026 Spelling Creator.