Serve legacy MCP JSON requests statelessly to stop per-request server retention (#478)

This commit is contained in:
Ben Senescu 2026-08-11 20:15:48 -04:00 committed by GitHub
parent 16eb599270
commit 84e8d0be99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 314 additions and 163 deletions

View File

@ -1,12 +1,4 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
props: {} as Record<string, unknown>,
}));
vi.mock("agents/mcp/server", () => ({
getMcpAuthContext: () => ({ props: mocks.props }),
}));
import { describe, expect, it } from "vitest";
import {
createMcpToolContext,
@ -36,14 +28,15 @@ describe("OpenSEO tool auth context", () => {
});
it("prefers standard OAuth client metadata over the props fallback", () => {
mocks.props = createWorkersOAuthMcpProps({
const props = createWorkersOAuthMcpProps({
...applicationContext,
clientId: "stale-client",
scopes: ["offline_access"],
});
expect(
createMcpToolContext({
createMcpToolContext(
{
http: {
authInfo: {
token: "access-token",
@ -51,7 +44,9 @@ describe("OpenSEO tool auth context", () => {
scopes: ["mcp"],
},
},
}).auth,
},
props,
).auth,
).toMatchObject({
...applicationContext,
clientId: "client-1",
@ -59,16 +54,16 @@ describe("OpenSEO tool auth context", () => {
});
});
it("falls back to encrypted props with workers-oauth-provider 0.10", () => {
mocks.props = createWorkersOAuthMcpProps({
it("reads clientId and scopes from props when authInfo is absent", () => {
const props = createWorkersOAuthMcpProps({
...applicationContext,
clientId: "client-1",
clientId: "legacy-client",
scopes: ["mcp"],
});
expect(createMcpToolContext({}).auth).toMatchObject({
expect(createMcpToolContext({}, props).auth).toMatchObject({
...applicationContext,
clientId: "client-1",
clientId: "legacy-client",
scopes: ["mcp"],
});
});

View File

@ -1,5 +1,4 @@
import type { ServerContext } from "@modelcontextprotocol/server";
import { getMcpAuthContext } from "agents/mcp/server";
import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { buildDashboardUrl } from "@/server/mcp/urls";
@ -51,9 +50,11 @@ export const hostedWorkersOAuthMcpPropsSchema = z.object({
}),
});
export type McpProps = z.infer<typeof workersOAuthMcpPropsSchema>;
export function createWorkersOAuthMcpProps(
context: ApplicationAuthContext,
): Record<string, ApplicationAuthContext> {
): McpProps {
return {
[MCP_AUTH_CONTEXT_PROP]: context,
};
@ -61,10 +62,9 @@ export function createWorkersOAuthMcpProps(
export function createMcpToolContext(
context: Pick<ServerContext, "http">,
props: McpProps,
): ToolContext {
const result = workersOAuthMcpPropsSchema.safeParse(
getMcpAuthContext()?.props,
);
const result = workersOAuthMcpPropsSchema.safeParse(props);
if (!result.success) {
throw new Error(`MCP auth context missing: ${result.error.message}`);
}

View File

@ -4,7 +4,11 @@ import {
type ToolAnnotations,
} from "@modelcontextprotocol/server";
import type { z } from "zod";
import { createMcpToolContext, type ToolContext } from "@/server/mcp/context";
import {
createMcpToolContext,
type McpProps,
type ToolContext,
} from "@/server/mcp/context";
import { objectSchema } from "@/server/mcp/output-schemas";
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
@ -84,6 +88,7 @@ type OpenSeoToolDefinition<Input extends ToolSchema> = {
function registerOpenSeoTool<Input extends ToolSchema>(
server: McpServer,
tool: OpenSeoToolDefinition<Input>,
authProps: McpProps,
) {
const outputSchema = objectSchema(tool.config.outputSchema);
const handler = instrumentMcpToolHandler(
@ -99,13 +104,17 @@ function registerOpenSeoTool<Input extends ToolSchema>(
inputSchema: objectSchema(tool.config.inputSchema),
outputSchema,
},
(args, context) =>
(args, context) => {
return handler(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- args were validated against the tool's own inputSchema just above
handler(args as ToolArgs<Input>, createMcpToolContext(context)),
args as ToolArgs<Input>,
createMcpToolContext(context, authProps),
);
},
);
}
export function createOpenSeoMcpServer() {
export function createOpenSeoMcpServer(authProps: McpProps) {
const server = new McpServer(
{
name: "OpenSEO MCP",
@ -128,45 +137,49 @@ export function createOpenSeoMcpServer() {
},
);
registerOpenSeoTool(server, whoamiTool);
registerOpenSeoTool(server, listProjectsTool);
registerOpenSeoTool(server, createProjectTool);
registerOpenSeoTool(server, listSavedKeywordsTool);
registerOpenSeoTool(server, researchKeywordsTool);
registerOpenSeoTool(server, saveKeywordsTool);
registerOpenSeoTool(server, getDomainOverviewTool);
registerOpenSeoTool(server, getDomainKeywordSuggestionsTool);
registerOpenSeoTool(server, getBacklinksOverviewTool);
registerOpenSeoTool(server, getBacklinksProfileTool);
registerOpenSeoTool(server, getSerpResultsTool);
registerOpenSeoTool(server, createRankTrackerTool);
registerOpenSeoTool(server, getRankTrackerTool);
registerOpenSeoTool(server, addRankTrackingKeywordsTool);
registerOpenSeoTool(server, removeRankTrackingKeywordsTool);
registerOpenSeoTool(server, estimateRankTrackerCostTool);
registerOpenSeoTool(server, runRankTrackerTool);
registerOpenSeoTool(server, getRankedKeywordsTool);
registerOpenSeoTool(server, findSerpCompetitorsTool);
registerOpenSeoTool(server, searchLocalBusinessesTool);
registerOpenSeoTool(server, getLocalSerpResultsTool);
registerOpenSeoTool(server, getGoogleBusinessQuestionsTool);
registerOpenSeoTool(server, getKeywordMetricsTool);
registerOpenSeoTool(server, getSearchConsolePerformanceTool);
registerOpenSeoTool(server, inspectUrlsTool);
registerOpenSeoTool(server, getGoogleAnalyticsOrganicLandingPagesTool);
registerOpenSeoTool(server, getGoogleAnalyticsPagePerformanceTool);
registerOpenSeoTool(server, getGoogleAnalyticsKeyEventsTool);
registerOpenSeoTool(server, getSearchOpportunitiesTool);
registerOpenSeoTool(server, getGoogleAnalyticsOrganicOverviewTool);
registerOpenSeoTool(server, getGoogleAnalyticsTrafficAcquisitionTool);
registerOpenSeoTool(server, getGoogleAnalyticsMeasurementHealthTool);
registerOpenSeoTool(server, getGoogleAnalyticsEcommercePerformanceTool);
registerOpenSeoTool(server, getGoogleAnalyticsSiteSearchTool);
registerOpenSeoTool(server, getGoogleAnalyticsAudienceBreakdownTool);
registerOpenSeoTool(server, runSiteAuditTool);
registerOpenSeoTool(server, getAuditStatusTool);
registerOpenSeoTool(server, getAuditIssuesTool);
registerOpenSeoTool(server, getAuditPagesTool);
const register = <Input extends ToolSchema>(
tool: OpenSeoToolDefinition<Input>,
) => registerOpenSeoTool(server, tool, authProps);
register(whoamiTool);
register(listProjectsTool);
register(createProjectTool);
register(listSavedKeywordsTool);
register(researchKeywordsTool);
register(saveKeywordsTool);
register(getDomainOverviewTool);
register(getDomainKeywordSuggestionsTool);
register(getBacklinksOverviewTool);
register(getBacklinksProfileTool);
register(getSerpResultsTool);
register(createRankTrackerTool);
register(getRankTrackerTool);
register(addRankTrackingKeywordsTool);
register(removeRankTrackingKeywordsTool);
register(estimateRankTrackerCostTool);
register(runRankTrackerTool);
register(getRankedKeywordsTool);
register(findSerpCompetitorsTool);
register(searchLocalBusinessesTool);
register(getLocalSerpResultsTool);
register(getGoogleBusinessQuestionsTool);
register(getKeywordMetricsTool);
register(getSearchConsolePerformanceTool);
register(inspectUrlsTool);
register(getGoogleAnalyticsOrganicLandingPagesTool);
register(getGoogleAnalyticsPagePerformanceTool);
register(getGoogleAnalyticsKeyEventsTool);
register(getSearchOpportunitiesTool);
register(getGoogleAnalyticsOrganicOverviewTool);
register(getGoogleAnalyticsTrafficAcquisitionTool);
register(getGoogleAnalyticsMeasurementHealthTool);
register(getGoogleAnalyticsEcommercePerformanceTool);
register(getGoogleAnalyticsSiteSearchTool);
register(getGoogleAnalyticsAudienceBreakdownTool);
register(runSiteAuditTool);
register(getAuditStatusTool);
register(getAuditIssuesTool);
register(getAuditPagesTool);
return server;
}

View File

@ -1,15 +1,20 @@
import type { CreateMcpHandlerOptions } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import {
createWorkersOAuthMcpProps,
MCP_AUTH_CONTEXT_PROP,
} from "@/server/mcp/context";
import {
handleAuthenticatedOpenSeoMcpRequest,
handleSelfHostedOpenSeoMcpRequest,
} from "@/server/mcp/transport";
const selfHostedAuthMocks = vi.hoisted(() => ({
resolveCloudflareAccessContext: vi.fn(),
resolveLocalNoAuthContext: vi.fn(),
createOpenSeoMcpServer: vi.fn(),
createMcpHandler: vi.fn(),
}));
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
@ -26,8 +31,9 @@ vi.mock("@/lib/auth", () => ({
}));
vi.mock("@/server/mcp/server", () => ({
createOpenSeoMcpServer: () =>
new McpServer({
createOpenSeoMcpServer: (props?: unknown) => {
selfHostedAuthMocks.createOpenSeoMcpServer(props);
return new McpServer({
name: "OpenSEO MCP",
title: "OpenSEO",
version: "0.0.11",
@ -40,27 +46,17 @@ vi.mock("@/server/mcp/server", () => ({
sizes: ["512x512"],
},
],
}),
});
},
}));
vi.mock("agents/mcp/server", () => ({
createMcpHandler: (
createServer: () => McpServer,
_createServer: () => McpServer,
options: CreateMcpHandlerOptions,
) => {
return async (request: Request) => {
if (request.method !== "OPTIONS") createServer();
return new Response(
JSON.stringify({
options,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
};
selfHostedAuthMocks.createMcpHandler(options);
return async () => Response.json({ handledBy: "modern" }, { status: 202 });
},
}));
@ -70,19 +66,25 @@ const ctx: ExecutionContext = {
props: {},
};
const transportOptionsSchema = z.object({
options: z.object({
route: z.string().optional(),
allowedOriginHostnames: z.array(z.string()).optional(),
authContext: z
.object({
props: z.record(z.string(), z.unknown()),
})
.optional(),
function createMcpRequest(headers?: Record<string, string>) {
return new Request("https://open-seo.test/mcp", {
method: "POST",
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/list",
}),
});
});
}
function createMcpRequest() {
// The modern (2026-07-28) era is selected by the per-request `_meta` envelope
// claim; without it every POST classifies as legacy traffic.
function createModernMcpRequest() {
return new Request("https://open-seo.test/mcp", {
method: "POST",
headers: {
@ -93,13 +95,29 @@ function createMcpRequest() {
jsonrpc: "2.0",
id: 1,
method: "tools/list",
params: {
_meta: {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}),
});
}
function hostedProps(scopes: string[] = ["mcp"]) {
return createWorkersOAuthMcpProps({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://open-seo.test",
clientId: "client-1",
scopes,
});
}
describe("handleSelfHostedOpenSeoMcpRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({
userId: "local-admin",
userEmail: "admin@localhost",
@ -113,90 +131,77 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
});
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(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("connection")).not.toBe("keep-alive");
expect(selfHostedAuthMocks.resolveLocalNoAuthContext).toHaveBeenCalled();
expect(
body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP],
).toMatchObject({
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith({
[MCP_AUTH_CONTEXT_PROP]: {
userId: "local-admin",
userEmail: "admin@localhost",
organizationId: "delegated-local-admin",
baseUrl: "https://open-seo.test",
},
});
// Self-hosted must not pin Origins to the request's own Host — the
// handler's localhost-class default is the rebinding-safe choice.
expect(body.options.allowedOriginHostnames).toBeUndefined();
expect(selfHostedAuthMocks.createMcpHandler).toHaveBeenCalledWith(
expect.objectContaining({
allowedOriginHostnames: undefined,
legacy: "reject",
}),
);
});
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({
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith({
[MCP_AUTH_CONTEXT_PROP]: {
userId: "cloudflare-user",
userEmail: "person@example.com",
organizationId: "delegated-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");
it("answers OPTIONS preflight without resolving an auth context", async () => {
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(await response.text()).toBe("");
expect(
selfHostedAuthMocks.resolveCloudflareAccessContext,
).not.toHaveBeenCalled();
expect(body.options.authContext).toBeUndefined();
expect(selfHostedAuthMocks.createOpenSeoMcpServer).not.toHaveBeenCalled();
});
});
describe("handleAuthenticatedOpenSeoMcpRequest", () => {
it("accepts the provider's encrypted identity and MCP scope fallback", async () => {
const { handleAuthenticatedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
const props = createWorkersOAuthMcpProps({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://open-seo.test",
clientId: "client-1",
scopes: ["mcp"],
});
const props = hostedProps();
const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest(),
@ -206,13 +211,51 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
);
expect(response.status).toBe(200);
const body = transportOptionsSchema.parse(await response.json());
expect(body.options.allowedOriginHostnames).toEqual(["open-seo.test"]);
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("connection")).not.toBe("keep-alive");
expect(selfHostedAuthMocks.createMcpHandler).toHaveBeenCalledWith(
expect.objectContaining({
allowedOriginHostnames: ["open-seo.test"],
legacy: "reject",
}),
);
expect(selfHostedAuthMocks.createOpenSeoMcpServer).toHaveBeenCalledWith(
props,
);
});
it("routes modern-era requests to the SDK handler", async () => {
const props = hostedProps();
const response = await handleAuthenticatedOpenSeoMcpRequest(
createModernMcpRequest(),
props,
{},
{ ...ctx, props },
);
expect(response.status).toBe(202);
expect(await response.json()).toEqual({ handledBy: "modern" });
// The modern handler owns server construction; the legacy leg must not
// have built one.
expect(selfHostedAuthMocks.createOpenSeoMcpServer).not.toHaveBeenCalled();
});
it("rejects a legacy request from a disallowed Origin", async () => {
const props = hostedProps();
const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest({ Origin: "https://evil.com" }),
props,
{},
{ ...ctx, props },
);
expect(response.status).toBe(403);
expect(selfHostedAuthMocks.createOpenSeoMcpServer).not.toHaveBeenCalled();
});
it("rejects provider props missing the OAuth client identity", async () => {
const { handleAuthenticatedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
// Hosted tokens always carry clientId/scopes; a token without them must
// fail closed rather than skip scope enforcement.
const props = createWorkersOAuthMcpProps({
@ -233,16 +276,7 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => {
});
it("rejects an OAuth client without the MCP scope", async () => {
const { handleAuthenticatedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
const props = createWorkersOAuthMcpProps({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://open-seo.test",
clientId: "client-1",
scopes: ["offline_access"],
});
const props = hostedProps(["offline_access"]);
const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest(),

View File

@ -1,4 +1,12 @@
import { createMcpHandler } from "agents/mcp/server";
import {
hostHeaderValidationResponse,
isLegacyRequest,
localhostAllowedHostnames,
localhostAllowedOrigins,
originValidationResponse,
WebStandardStreamableHTTPServerTransport,
} from "@modelcontextprotocol/server";
import { getHostedBaseUrl } from "@/lib/auth";
import { MCP_SCOPE } from "@/lib/oauth-resource";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
@ -8,11 +16,100 @@ import {
hostedWorkersOAuthMcpPropsSchema,
MCP_AUTH_CONTEXT_PROP,
MCP_ROUTE,
type McpProps,
} from "@/server/mcp/context";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { createOpenSeoMcpServer } from "@/server/mcp/server";
type McpProps = ReturnType<typeof createWorkersOAuthMcpProps>;
// Mirrors the agents SDK's DEFAULT_CORS_OPTIONS so legacy responses carry the
// same CORS surface as the modern handler's.
const MCP_CORS_HEADERS = {
"Access-Control-Allow-Headers":
"Content-Type, Accept, Authorization, mcp-session-id, MCP-Protocol-Version, Mcp-Method, Mcp-Name",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "mcp-session-id",
"Access-Control-Max-Age": "86400",
} as const;
function withMcpCors(response: Response) {
const headers = new Headers(response.headers);
for (const [name, value] of Object.entries(MCP_CORS_HEADERS)) {
headers.set(name, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
// Port of the host/origin validation the agents SDK handler applies to the
// requests it serves; legacy requests bypass that handler, so it runs here.
function validateLegacyRequest(
request: Request,
allowedOriginHostnames?: string[],
) {
const url = new URL(request.url);
const isLocal = localhostAllowedHostnames().includes(url.hostname);
const isWorkersDev = url.hostname.endsWith(".workers.dev");
const acceptedHostnames = isLocal
? localhostAllowedHostnames()
: isWorkersDev
? [url.hostname]
: undefined;
const hostRejection = acceptedHostnames
? hostHeaderValidationResponse(request, acceptedHostnames)
: undefined;
if (hostRejection) return withMcpCors(hostRejection);
const acceptedOrigins =
allowedOriginHostnames ??
(isWorkersDev
? [...localhostAllowedOrigins(), url.hostname]
: localhostAllowedOrigins());
const originRejection = originValidationResponse(request, acceptedOrigins);
return originRejection ? withMcpCors(originRejection) : undefined;
}
async function handleLegacyJsonRequest(request: Request, props: McpProps) {
if (request.method !== "POST") {
return withMcpCors(
Response.json(
{
jsonrpc: "2.0",
error: { code: -32000, message: "Method not allowed." },
id: null,
},
{ status: 405, headers: { Allow: "POST, OPTIONS" } },
),
);
}
// The SDK's own legacy fallbacks (agents' compat lane, the MCP SDK's
// legacyStatelessFallback) construct this transport without
// enableJsonResponse, which answers with an SSE stream and retains the
// per-request server plus a keepalive for the response lifetime. JSON mode
// buffers the response and lets the finally below tear everything down
// before the request completes. JSON mode silently drops server-to-client
// requests (sampling/elicitation) and would hang the buffered response —
// no OpenSEO tool issues them.
const server = createOpenSeoMcpServer(props);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
try {
await server.connect(transport);
return withMcpCors(await transport.handleRequest(request));
} finally {
await Promise.all([
transport.close().catch(() => {}),
server.close().catch(() => {}),
]);
}
}
// Hosted pins browser Origins to the configured base URL. Self-hosted leaves
// the option unset so the handler's localhost-class default applies — an
@ -20,14 +117,29 @@ type McpProps = ReturnType<typeof createWorkersOAuthMcpProps>;
// page trivially. Non-browser MCP clients send no Origin and are unaffected
// either way.
function createRequestHandler(
props: McpProps | undefined,
props: McpProps,
allowedOriginHostnames?: string[],
) {
return createMcpHandler(createOpenSeoMcpServer, {
const modernHandler = createMcpHandler(() => createOpenSeoMcpServer(props), {
route: MCP_ROUTE,
allowedOriginHostnames,
authContext: props ? { props } : undefined,
legacy: "reject",
});
return async (request: Request, env: unknown, ctx: ExecutionContext) => {
if (request.method === "OPTIONS") {
return new Response(null, { headers: MCP_CORS_HEADERS });
}
if (new URL(request.url).pathname !== MCP_ROUTE) {
return withMcpCors(new Response("Not Found", { status: 404 }));
}
if (!(await isLegacyRequest(request))) {
return modernHandler(request, env, ctx);
}
const rejection = validateLegacyRequest(request, allowedOriginHostnames);
return rejection ?? handleLegacyJsonRequest(request, props);
};
}
export async function handleAuthenticatedOpenSeoMcpRequest(
@ -44,9 +156,6 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
return new Response("MCP scope required", { status: 403 });
}
// The handler would fall back to the provider-populated ctx.props on its
// own; passing authContext explicitly hands it the schema-validated copy and
// keeps this path symmetrical with self-hosted, which has no ctx.props.
return createRequestHandler(result.data, [
new URL(getHostedBaseUrl()).hostname,
])(request, env, ctx);
@ -60,7 +169,7 @@ export async function handleSelfHostedOpenSeoMcpRequest(
): Promise<Response> {
// Preflight does not carry an authenticated application context.
if (request.method === "OPTIONS") {
return createRequestHandler(undefined)(request, env, ctx);
return new Response(null, { headers: MCP_CORS_HEADERS });
}
const identity =