Support Cloudflare Access and local no-auth MCP modes (#166)

This commit is contained in:
Ben Senescu 2026-05-10 17:31:30 -04:00 committed by GitHub
parent 5e358f1873
commit b9b85a6d48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 267 additions and 11 deletions

View File

@ -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.

View File

@ -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"],

View File

@ -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<Response> {
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);
}

View File

@ -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<McpServer, number>(),
}));
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<McpServer, number>();
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();
});
});

View File

@ -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<Response> {
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<Response> {
// 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<typeof createWorkersOAuthMcpProps> | undefined,
env: unknown,
ctx: ExecutionContext,
): Promise<Response> {
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),
);
}