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
|
||||
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.
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
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 { 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),
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user