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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,12 @@
import { createMcpHandler } from "agents/mcp/server"; import { createMcpHandler } from "agents/mcp/server";
import {
hostHeaderValidationResponse,
isLegacyRequest,
localhostAllowedHostnames,
localhostAllowedOrigins,
originValidationResponse,
WebStandardStreamableHTTPServerTransport,
} from "@modelcontextprotocol/server";
import { getHostedBaseUrl } from "@/lib/auth"; import { getHostedBaseUrl } from "@/lib/auth";
import { MCP_SCOPE } from "@/lib/oauth-resource"; import { MCP_SCOPE } from "@/lib/oauth-resource";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
@ -8,11 +16,100 @@ import {
hostedWorkersOAuthMcpPropsSchema, hostedWorkersOAuthMcpPropsSchema,
MCP_AUTH_CONTEXT_PROP, MCP_AUTH_CONTEXT_PROP,
MCP_ROUTE, MCP_ROUTE,
type McpProps,
} from "@/server/mcp/context"; } from "@/server/mcp/context";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
import { createOpenSeoMcpServer } from "@/server/mcp/server"; 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 // Hosted pins browser Origins to the configured base URL. Self-hosted leaves
// the option unset so the handler's localhost-class default applies — an // 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 // page trivially. Non-browser MCP clients send no Origin and are unaffected
// either way. // either way.
function createRequestHandler( function createRequestHandler(
props: McpProps | undefined, props: McpProps,
allowedOriginHostnames?: string[], allowedOriginHostnames?: string[],
) { ) {
return createMcpHandler(createOpenSeoMcpServer, { const modernHandler = createMcpHandler(() => createOpenSeoMcpServer(props), {
route: MCP_ROUTE, route: MCP_ROUTE,
allowedOriginHostnames, 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( export async function handleAuthenticatedOpenSeoMcpRequest(
@ -44,9 +156,6 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
return new Response("MCP scope required", { status: 403 }); 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, [ return createRequestHandler(result.data, [
new URL(getHostedBaseUrl()).hostname, new URL(getHostedBaseUrl()).hostname,
])(request, env, ctx); ])(request, env, ctx);
@ -60,7 +169,7 @@ export async function handleSelfHostedOpenSeoMcpRequest(
): Promise<Response> { ): Promise<Response> {
// Preflight does not carry an authenticated application context. // Preflight does not carry an authenticated application context.
if (request.method === "OPTIONS") { if (request.method === "OPTIONS") {
return createRequestHandler(undefined)(request, env, ctx); return new Response(null, { headers: MCP_CORS_HEADERS });
} }
const identity = const identity =