Serve legacy MCP JSON requests statelessly to stop per-request server retention (#478)
This commit is contained in:
parent
16eb599270
commit
84e8d0be99
@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user