Support Cloudflare Access and local no-auth MCP modes (#166)
This commit is contained in:
parent
5e358f1873
commit
b9b85a6d48
@ -5,6 +5,7 @@ This guide covers:
|
|||||||
1. Initial setup after clicking Deploy to Cloudflare
|
1. Initial setup after clicking Deploy to Cloudflare
|
||||||
2. How to update to the latest OpenSEO version
|
2. How to update to the latest OpenSEO version
|
||||||
3. How to add teammates
|
3. How to add teammates
|
||||||
|
4. How to connect the OpenSEO MCP server through Cloudflare Access
|
||||||
|
|
||||||
## Initial setup
|
## 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.
|
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
|
## How to update to the latest OpenSEO version
|
||||||
|
|
||||||
If your repo was created from the Cloudflare Deploy button, use this flow.
|
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
|
## 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.
|
1. Open Cloudflare Zero Trust.
|
||||||
2. Go to Access -> Applications.
|
2. Go to Access -> Applications.
|
||||||
3. Open your OpenSEO application.
|
3. Open your OpenSEO application.
|
||||||
|
|||||||
@ -18,6 +18,11 @@
|
|||||||
"src/db/index.ts",
|
"src/db/index.ts",
|
||||||
"src/db/app.schema.ts",
|
"src/db/app.schema.ts",
|
||||||
"src/db/better-auth-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/**"],
|
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
||||||
"ignore": ["drizzle-prod.config.ts"],
|
"ignore": ["drizzle-prod.config.ts"],
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import { RankTrackingRepository } from "@/server/features/rank-tracking/reposito
|
|||||||
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
||||||
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
|
||||||
import {
|
import {
|
||||||
createOpenSeoOAuthProvider,
|
createOpenSeoOAuthProvider,
|
||||||
type OpenSeoOAuthEnv,
|
type OpenSeoOAuthEnv,
|
||||||
} from "@/server/mcp/oauth-provider";
|
} from "@/server/mcp/oauth-provider";
|
||||||
import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
|
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";
|
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||||
|
|
||||||
const appFetch = createStartHandler(defaultStreamHandler);
|
const appFetch = createStartHandler(defaultStreamHandler);
|
||||||
@ -24,14 +26,24 @@ function fetch(
|
|||||||
env: Env,
|
env: Env,
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext,
|
||||||
): Response | Promise<Response> {
|
): Response | Promise<Response> {
|
||||||
if (isHostedAuthMode(env.AUTH_MODE)) {
|
const authMode = getAuthMode(env.AUTH_MODE);
|
||||||
|
const publicRequest = requestWithPublicOrigin(request);
|
||||||
|
|
||||||
|
if (isHostedAuthMode(authMode)) {
|
||||||
return openSeoOAuthProvider.fetch(
|
return openSeoOAuthProvider.fetch(
|
||||||
requestWithPublicOrigin(request),
|
publicRequest,
|
||||||
env as OpenSeoOAuthEnv,
|
env as OpenSeoOAuthEnv,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(authMode === "cloudflare_access" || authMode === "local_noauth") &&
|
||||||
|
new URL(publicRequest.url).pathname === MCP_ROUTE
|
||||||
|
) {
|
||||||
|
return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
return handleAppFetch(request);
|
return handleAppFetch(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
src/server/mcp/transport.test.ts
Normal file
176
src/server/mcp/transport.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,12 +1,16 @@
|
|||||||
import { createMcpHandler } from "agents/mcp";
|
import { createMcpHandler } from "agents/mcp";
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
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 {
|
import {
|
||||||
|
createWorkersOAuthMcpProps,
|
||||||
MCP_AUTH_CONTEXT_PROP,
|
MCP_AUTH_CONTEXT_PROP,
|
||||||
MCP_ROUTE,
|
MCP_ROUTE,
|
||||||
runWithMcpToolAuthContext,
|
runWithMcpToolAuthContext,
|
||||||
workersOAuthMcpPropsSchema,
|
workersOAuthMcpPropsSchema,
|
||||||
} from "@/server/mcp/context";
|
} from "@/server/mcp/context";
|
||||||
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
import { registerOpenSeoMcpTools } from "@/server/mcp/server";
|
import { registerOpenSeoMcpTools } from "@/server/mcp/server";
|
||||||
|
|
||||||
function createOpenSeoMcpServer() {
|
function createOpenSeoMcpServer() {
|
||||||
@ -22,7 +26,7 @@ function createOpenSeoMcpServer() {
|
|||||||
export async function handleAuthenticatedOpenSeoMcpRequest(
|
export async function handleAuthenticatedOpenSeoMcpRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
props: unknown,
|
props: unknown,
|
||||||
env: Env,
|
env: unknown,
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const result = workersOAuthMcpPropsSchema.safeParse(props);
|
const result = workersOAuthMcpPropsSchema.safeParse(props);
|
||||||
@ -34,11 +38,53 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
|
|||||||
return new Response("MCP auth context required", { status: 403 });
|
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 server = createOpenSeoMcpServer();
|
||||||
const handler = createMcpHandler(server, {
|
const handler = createMcpHandler(server, {
|
||||||
route: MCP_ROUTE,
|
route: MCP_ROUTE,
|
||||||
enableJsonResponse: true,
|
enableJsonResponse: true,
|
||||||
authContext: { props: result.data },
|
authContext: props ? { props } : undefined,
|
||||||
corsOptions: {
|
corsOptions: {
|
||||||
headers:
|
headers:
|
||||||
"Authorization, Content-Type, Last-Event-ID, mcp-protocol-version, mcp-session-id",
|
"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),
|
handler(request, env, ctx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user