From b9b85a6d487cbb72281d582fe707f18171f56a56 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sun, 10 May 2026 17:31:30 -0400 Subject: [PATCH] Support Cloudflare Access and local no-auth MCP modes (#166) --- docs/SELF_HOSTING_CLOUDFLARE.md | 23 +++- knip.jsonc | 5 + src/server.ts | 18 +++- src/server/mcp/transport.test.ts | 176 +++++++++++++++++++++++++++++++ src/server/mcp/transport.ts | 56 +++++++++- 5 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 src/server/mcp/transport.test.ts diff --git a/docs/SELF_HOSTING_CLOUDFLARE.md b/docs/SELF_HOSTING_CLOUDFLARE.md index 6661c7a..e4f8001 100644 --- a/docs/SELF_HOSTING_CLOUDFLARE.md +++ b/docs/SELF_HOSTING_CLOUDFLARE.md @@ -5,6 +5,7 @@ This guide covers: 1. Initial setup after clicking Deploy to Cloudflare 2. How to update to the latest OpenSEO version 3. How to add teammates +4. How to connect the OpenSEO MCP server through Cloudflare Access ## Initial setup @@ -52,6 +53,24 @@ Without a lifecycle rule, cached objects under `dataforseo-cache/` will accumula If login fails, re-check the three secrets and Access toggle. +## Connect the MCP server through Cloudflare Access + +Use the same Cloudflare Access application that protects your OpenSEO Worker. +Managed OAuth is required for MCP clients and is not enabled by default. + +1. Open Cloudflare Zero Trust. +2. Go to `Access controls` -> `Applications`. +3. Find your OpenSEO application, then select `Edit`. +4. Go to `Additional settings` -> `OAuth`. +5. Turn on `Managed OAuth`. +6. Save. + +MCP clients should connect to: + +```text +https://YOUR_WORKER_HOSTNAME/mcp +``` + ## How to update to the latest OpenSEO version If your repo was created from the Cloudflare Deploy button, use this flow. @@ -85,10 +104,6 @@ Why this is needed: ## Give teammates access to OpenSEO -Cloudflare Access makes small-team self-hosting simple: your teammates do not -need Cloudflare accounts. Add their email addresses to the Access `Allow` policy, -and Cloudflare will handle the login flow before they reach OpenSEO. - 1. Open Cloudflare Zero Trust. 2. Go to Access -> Applications. 3. Open your OpenSEO application. diff --git a/knip.jsonc b/knip.jsonc index 20646d0..995c515 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -18,6 +18,11 @@ "src/db/index.ts", "src/db/app.schema.ts", "src/db/better-auth-schema.ts", + // Package scripts and one-off maintenance utilities + "scripts/backlinks-cost-profile.ts", + "scripts/brand-lookup-cost-profile.ts", + "scripts/release-notes.mjs", + "scripts/seed-rank-tracking.ts", ], "project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"], "ignore": ["drizzle-prod.config.ts"], diff --git a/src/server.ts b/src/server.ts index 5bef2b8..621b23a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,12 +6,14 @@ import { RankTrackingRepository } from "@/server/features/rank-tracking/reposito import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards"; import { customerHasPaidPlan } from "@/server/billing/subscription"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; -import { isHostedAuthMode } from "@/lib/auth-mode"; +import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode"; import { createOpenSeoOAuthProvider, type OpenSeoOAuthEnv, } from "@/server/mcp/oauth-provider"; import { requestWithPublicOrigin } from "@/server/mcp/public-origin"; +import { MCP_ROUTE } from "@/server/mcp/context"; +import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { computeNextCheckAt } from "@/shared/rank-tracking"; const appFetch = createStartHandler(defaultStreamHandler); @@ -24,14 +26,24 @@ function fetch( env: Env, ctx: ExecutionContext, ): Response | Promise { - if (isHostedAuthMode(env.AUTH_MODE)) { + const authMode = getAuthMode(env.AUTH_MODE); + const publicRequest = requestWithPublicOrigin(request); + + if (isHostedAuthMode(authMode)) { return openSeoOAuthProvider.fetch( - requestWithPublicOrigin(request), + publicRequest, env as OpenSeoOAuthEnv, ctx, ); } + if ( + (authMode === "cloudflare_access" || authMode === "local_noauth") && + new URL(publicRequest.url).pathname === MCP_ROUTE + ) { + return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx); + } + return handleAppFetch(request); } diff --git a/src/server/mcp/transport.test.ts b/src/server/mcp/transport.test.ts new file mode 100644 index 0000000..4266b5f --- /dev/null +++ b/src/server/mcp/transport.test.ts @@ -0,0 +1,176 @@ +import type { CreateMcpHandlerOptions } from "agents/mcp"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; + +const selfHostedAuthMocks = vi.hoisted(() => ({ + resolveCloudflareAccessContext: vi.fn(), + resolveLocalNoAuthContext: vi.fn(), +})); + +const serverMocks = vi.hoisted(() => ({ + nextServerId: 0, + serverIds: new WeakMap(), +})); + +vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({ + resolveCloudflareAccessContext: + selfHostedAuthMocks.resolveCloudflareAccessContext, +})); + +vi.mock("@/middleware/ensure-user/delegated", () => ({ + resolveLocalNoAuthContext: selfHostedAuthMocks.resolveLocalNoAuthContext, +})); + +vi.mock("@/server/mcp/server", () => ({ + registerOpenSeoMcpTools: vi.fn(), +})); + +vi.mock("agents/mcp", () => ({ + createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => { + serverMocks.nextServerId += 1; + serverMocks.serverIds.set(_server, serverMocks.nextServerId); + + return async () => + new Response( + JSON.stringify({ + serverId: serverMocks.serverIds.get(_server), + options, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, +})); + +const ctx: ExecutionContext = { + waitUntil() {}, + passThroughOnException() {}, + props: {}, +}; + +const transportOptionsSchema = z.object({ + serverId: z.number().optional(), + options: z.object({ + route: z.string().optional(), + enableJsonResponse: z.boolean().optional(), + authContext: z + .object({ + props: z.record(z.string(), z.unknown()), + }) + .optional(), + }), +}); + +function createMcpRequest() { + return new Request("https://open-seo.test/mcp", { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + }), + }); +} + +describe("handleSelfHostedOpenSeoMcpRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + serverMocks.nextServerId = 0; + serverMocks.serverIds = new WeakMap(); + selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({ + userId: "local-admin", + userEmail: "admin@localhost", + organizationId: "delegated-local-admin", + }); + selfHostedAuthMocks.resolveCloudflareAccessContext.mockResolvedValue({ + userId: "cloudflare-user", + userEmail: "person@example.com", + organizationId: "delegated-cloudflare-user", + }); + }); + + it("accepts local no-auth MCP requests with the local admin context", async () => { + const { handleSelfHostedOpenSeoMcpRequest } = + await import("@/server/mcp/transport"); + + const response = await handleSelfHostedOpenSeoMcpRequest( + createMcpRequest(), + "local_noauth", + {}, + ctx, + ); + const body = transportOptionsSchema.parse(await response.json()); + + expect(response.status).toBe(200); + expect(selfHostedAuthMocks.resolveLocalNoAuthContext).toHaveBeenCalled(); + expect( + body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP], + ).toMatchObject({ + userId: "local-admin", + userEmail: "admin@localhost", + organizationId: "delegated-local-admin", + clientId: null, + scopes: [], + audience: "https://open-seo.test/mcp", + subject: "local-admin", + baseUrl: "https://open-seo.test", + }); + }); + + it("accepts Cloudflare Access MCP requests through the existing Access resolver", async () => { + const { handleSelfHostedOpenSeoMcpRequest } = + await import("@/server/mcp/transport"); + + const response = await handleSelfHostedOpenSeoMcpRequest( + createMcpRequest(), + "cloudflare_access", + {}, + ctx, + ); + const body = transportOptionsSchema.parse(await response.json()); + + expect(response.status).toBe(200); + expect( + selfHostedAuthMocks.resolveCloudflareAccessContext, + ).toHaveBeenCalledWith(expect.any(Headers)); + expect( + body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP], + ).toMatchObject({ + userId: "cloudflare-user", + userEmail: "person@example.com", + organizationId: "delegated-cloudflare-user", + clientId: null, + scopes: [], + audience: "https://open-seo.test/mcp", + subject: "cloudflare-user", + baseUrl: "https://open-seo.test", + }); + }); + + it("lets the MCP transport handle OPTIONS without auth context", async () => { + const { handleSelfHostedOpenSeoMcpRequest } = + await import("@/server/mcp/transport"); + + const response = await handleSelfHostedOpenSeoMcpRequest( + new Request("https://open-seo.test/mcp", { method: "OPTIONS" }), + "cloudflare_access", + {}, + ctx, + ); + const body = transportOptionsSchema.parse(await response.json()); + + expect(response.status).toBe(200); + expect( + selfHostedAuthMocks.resolveCloudflareAccessContext, + ).not.toHaveBeenCalled(); + expect(body.options.authContext).toBeUndefined(); + }); +}); diff --git a/src/server/mcp/transport.ts b/src/server/mcp/transport.ts index 88cfccc..ba86826 100644 --- a/src/server/mcp/transport.ts +++ b/src/server/mcp/transport.ts @@ -1,12 +1,16 @@ import { createMcpHandler } from "agents/mcp"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { MCP_SCOPE } from "@/lib/oauth-resource"; +import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource"; +import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; +import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated"; import { + createWorkersOAuthMcpProps, MCP_AUTH_CONTEXT_PROP, MCP_ROUTE, runWithMcpToolAuthContext, workersOAuthMcpPropsSchema, } from "@/server/mcp/context"; +import { getPublicOrigin } from "@/server/mcp/public-origin"; import { registerOpenSeoMcpTools } from "@/server/mcp/server"; function createOpenSeoMcpServer() { @@ -22,7 +26,7 @@ function createOpenSeoMcpServer() { export async function handleAuthenticatedOpenSeoMcpRequest( request: Request, props: unknown, - env: Env, + env: unknown, ctx: ExecutionContext, ): Promise { const result = workersOAuthMcpPropsSchema.safeParse(props); @@ -34,11 +38,53 @@ export async function handleAuthenticatedOpenSeoMcpRequest( return new Response("MCP auth context required", { status: 403 }); } + return handleOpenSeoMcpRequest(request, result.data, env, ctx); +} + +export async function handleSelfHostedOpenSeoMcpRequest( + request: Request, + authMode: "cloudflare_access" | "local_noauth", + env: unknown, + ctx: ExecutionContext, +): Promise { + // Self-hosted auth mirrors the app: local_noauth uses the local admin + // workspace, while cloudflare_access trusts Cloudflare's Access JWT. + // CORS/preflight still needs to reach the MCP transport before auth context + // exists, so OPTIONS intentionally bypasses context creation. + if (request.method === "OPTIONS") { + return handleOpenSeoMcpRequest(request, undefined, env, ctx); + } + + const baseUrl = getPublicOrigin(request); + const context = + authMode === "local_noauth" + ? await resolveLocalNoAuthContext() + : await resolveCloudflareAccessContext(request.headers); + const props = createWorkersOAuthMcpProps({ + userId: context.userId, + userEmail: context.userEmail, + organizationId: context.organizationId, + clientId: null, + scopes: [], + audience: getMcpResource(baseUrl), + subject: context.userId, + baseUrl, + }); + + return handleOpenSeoMcpRequest(request, props, env, ctx); +} + +function handleOpenSeoMcpRequest( + request: Request, + props: ReturnType | undefined, + env: unknown, + ctx: ExecutionContext, +): Promise { const server = createOpenSeoMcpServer(); const handler = createMcpHandler(server, { route: MCP_ROUTE, enableJsonResponse: true, - authContext: { props: result.data }, + authContext: props ? { props } : undefined, corsOptions: { headers: "Authorization, Content-Type, Last-Event-ID, mcp-protocol-version, mcp-session-id", @@ -46,7 +92,9 @@ export async function handleAuthenticatedOpenSeoMcpRequest( }, }); - return runWithMcpToolAuthContext(result.data[MCP_AUTH_CONTEXT_PROP], () => + if (!props) return handler(request, env, ctx); + + return runWithMcpToolAuthContext(props[MCP_AUTH_CONTEXT_PROP], () => handler(request, env, ctx), ); }