291 lines
8.5 KiB
TypeScript
291 lines
8.5 KiB
TypeScript
import type { CreateMcpHandlerOptions } from "agents/mcp/server";
|
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
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", () => ({
|
|
resolveCloudflareAccessContext:
|
|
selfHostedAuthMocks.resolveCloudflareAccessContext,
|
|
}));
|
|
|
|
vi.mock("@/middleware/ensure-user/delegated", () => ({
|
|
resolveLocalNoAuthContext: selfHostedAuthMocks.resolveLocalNoAuthContext,
|
|
}));
|
|
|
|
vi.mock("@/lib/auth", () => ({
|
|
getHostedBaseUrl: () => "https://open-seo.test",
|
|
}));
|
|
|
|
vi.mock("@/server/mcp/server", () => ({
|
|
createOpenSeoMcpServer: (props?: unknown) => {
|
|
selfHostedAuthMocks.createOpenSeoMcpServer(props);
|
|
return new McpServer({
|
|
name: "OpenSEO MCP",
|
|
title: "OpenSEO",
|
|
version: "0.0.11",
|
|
description: "SEO research tools for AI agents",
|
|
websiteUrl: "https://openseo.so",
|
|
icons: [
|
|
{
|
|
src: "https://openseo.so/android-chrome-512x512.png",
|
|
mimeType: "image/png",
|
|
sizes: ["512x512"],
|
|
},
|
|
],
|
|
});
|
|
},
|
|
}));
|
|
|
|
vi.mock("agents/mcp/server", () => ({
|
|
createMcpHandler: (
|
|
_createServer: () => McpServer,
|
|
options: CreateMcpHandlerOptions,
|
|
) => {
|
|
selfHostedAuthMocks.createMcpHandler(options);
|
|
return async () => Response.json({ handledBy: "modern" }, { status: 202 });
|
|
},
|
|
}));
|
|
|
|
const ctx: ExecutionContext = {
|
|
waitUntil() {},
|
|
passThroughOnException() {},
|
|
props: {},
|
|
};
|
|
|
|
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",
|
|
}),
|
|
});
|
|
}
|
|
|
|
// 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: {
|
|
Accept: "application/json, text/event-stream",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
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(() => {
|
|
selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({
|
|
userId: "local-admin",
|
|
userEmail: "admin@localhost",
|
|
organizationId: "delegated-local-admin",
|
|
});
|
|
selfHostedAuthMocks.resolveCloudflareAccessContext.mockResolvedValue({
|
|
userId: "cloudflare-user",
|
|
userEmail: "person@example.com",
|
|
organizationId: "delegated-cloudflare-user",
|
|
});
|
|
});
|
|
|
|
it("accepts local no-auth MCP requests with the local admin context", async () => {
|
|
const response = await handleSelfHostedOpenSeoMcpRequest(
|
|
createMcpRequest(),
|
|
"local_noauth",
|
|
{},
|
|
ctx,
|
|
);
|
|
|
|
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.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(selfHostedAuthMocks.createMcpHandler).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
allowedOriginHostnames: undefined,
|
|
legacy: "reject",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("accepts Cloudflare Access MCP requests through the existing Access resolver", async () => {
|
|
const response = await handleSelfHostedOpenSeoMcpRequest(
|
|
createMcpRequest(),
|
|
"cloudflare_access",
|
|
{},
|
|
ctx,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(
|
|
selfHostedAuthMocks.resolveCloudflareAccessContext,
|
|
).toHaveBeenCalledWith(expect.any(Headers));
|
|
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("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,
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("");
|
|
expect(
|
|
selfHostedAuthMocks.resolveCloudflareAccessContext,
|
|
).not.toHaveBeenCalled();
|
|
expect(selfHostedAuthMocks.createOpenSeoMcpServer).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("handleAuthenticatedOpenSeoMcpRequest", () => {
|
|
it("accepts the provider's encrypted identity and MCP scope fallback", async () => {
|
|
const props = hostedProps();
|
|
|
|
const response = await handleAuthenticatedOpenSeoMcpRequest(
|
|
createMcpRequest(),
|
|
props,
|
|
{},
|
|
{ ...ctx, props },
|
|
);
|
|
|
|
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.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 () => {
|
|
// Hosted tokens always carry clientId/scopes; a token without them must
|
|
// fail closed rather than skip scope enforcement.
|
|
const props = createWorkersOAuthMcpProps({
|
|
userId: "user-1",
|
|
userEmail: "user@example.com",
|
|
organizationId: "org-1",
|
|
baseUrl: "https://open-seo.test",
|
|
});
|
|
|
|
const response = await handleAuthenticatedOpenSeoMcpRequest(
|
|
createMcpRequest(),
|
|
props,
|
|
{},
|
|
{ ...ctx, props },
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it("rejects an OAuth client without the MCP scope", async () => {
|
|
const props = hostedProps(["offline_access"]);
|
|
|
|
const response = await handleAuthenticatedOpenSeoMcpRequest(
|
|
createMcpRequest(),
|
|
props,
|
|
{},
|
|
{ ...ctx, props },
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|