From 84e8d0be99ee3ff27263d6aa3c882125cdbef3e6 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:15:48 -0400 Subject: [PATCH] Serve legacy MCP JSON requests statelessly to stop per-request server retention (#478) --- src/server/mcp/context.test.ts | 39 +++--- src/server/mcp/context.ts | 10 +- src/server/mcp/server.ts | 101 +++++++++------- src/server/mcp/transport.test.ts | 202 ++++++++++++++++++------------- src/server/mcp/transport.ts | 125 +++++++++++++++++-- 5 files changed, 314 insertions(+), 163 deletions(-) diff --git a/src/server/mcp/context.test.ts b/src/server/mcp/context.test.ts index f29004d..2ee2d23 100644 --- a/src/server/mcp/context.test.ts +++ b/src/server/mcp/context.test.ts @@ -1,12 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - props: {} as Record, -})); - -vi.mock("agents/mcp/server", () => ({ - getMcpAuthContext: () => ({ props: mocks.props }), -})); +import { describe, expect, it } from "vitest"; import { createMcpToolContext, @@ -36,22 +28,25 @@ 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({ - http: { - authInfo: { - token: "access-token", - clientId: "client-1", - scopes: ["mcp"], + createMcpToolContext( + { + http: { + authInfo: { + token: "access-token", + clientId: "client-1", + 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"], }); }); diff --git a/src/server/mcp/context.ts b/src/server/mcp/context.ts index 27d590d..debc192 100644 --- a/src/server/mcp/context.ts +++ b/src/server/mcp/context.ts @@ -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; + export function createWorkersOAuthMcpProps( context: ApplicationAuthContext, -): Record { +): McpProps { return { [MCP_AUTH_CONTEXT_PROP]: context, }; @@ -61,10 +62,9 @@ export function createWorkersOAuthMcpProps( export function createMcpToolContext( context: Pick, + 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}`); } diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts index e503a66..88da8ea 100644 --- a/src/server/mcp/server.ts +++ b/src/server/mcp/server.ts @@ -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 = { function registerOpenSeoTool( server: McpServer, tool: OpenSeoToolDefinition, + authProps: McpProps, ) { const outputSchema = objectSchema(tool.config.outputSchema); const handler = instrumentMcpToolHandler( @@ -99,13 +104,17 @@ function registerOpenSeoTool( inputSchema: objectSchema(tool.config.inputSchema), outputSchema, }, - (args, context) => - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- args were validated against the tool's own inputSchema just above - handler(args as ToolArgs, createMcpToolContext(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 + args as ToolArgs, + 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 = ( + tool: OpenSeoToolDefinition, + ) => 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; } diff --git a/src/server/mcp/transport.test.ts b/src/server/mcp/transport.test.ts index 69b140d..2b14a99 100644 --- a/src/server/mcp/transport.test.ts +++ b/src/server/mcp/transport.test.ts @@ -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) { + 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({ - userId: "local-admin", - userEmail: "admin@localhost", - organizationId: "delegated-local-admin", - baseUrl: "https://open-seo.test", + 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({ - userId: "cloudflare-user", - userEmail: "person@example.com", - organizationId: "delegated-cloudflare-user", - baseUrl: "https://open-seo.test", + 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(), diff --git a/src/server/mcp/transport.ts b/src/server/mcp/transport.ts index 5e9fc33..c2cacbb 100644 --- a/src/server/mcp/transport.ts +++ b/src/server/mcp/transport.ts @@ -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; +// 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; // 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 { // 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 =