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