diff --git a/src/server/mcp/transport-v2.test.ts b/src/server/mcp/transport-v2.test.ts index 3262193..80d65f8 100644 --- a/src/server/mcp/transport-v2.test.ts +++ b/src/server/mcp/transport-v2.test.ts @@ -1,7 +1,35 @@ import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server"; import { McpServer } from "@modelcontextprotocol/server"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { createWorkersOAuthMcpProps } from "@/server/mcp/context"; +import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport"; + +vi.mock("@/lib/auth", () => ({ + getHostedBaseUrl: () => "https://open-seo.test", +})); + +vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({ + resolveCloudflareAccessContext: vi.fn(), +})); + +vi.mock("@/middleware/ensure-user/delegated", () => ({ + resolveLocalNoAuthContext: vi.fn(), +})); + +vi.mock("@/server/mcp/server", async () => { + const { McpServer: ActualMcpServer } = + await import("@modelcontextprotocol/server"); + return { + createOpenSeoMcpServer: () => { + const server = new ActualMcpServer({ name: "test", version: "1.0.0" }); + server.registerTool("ping", {}, () => ({ + content: [{ type: "text" as const, text: "pong" }], + })); + return server; + }, + }; +}); const ctx: ExecutionContext = { waitUntil() {}, @@ -136,4 +164,50 @@ describe("Agents SDK v2 MCP transport", () => { expect(surfMindResponse.status).toBe(200); expect(unrelatedOriginResponse.status).toBe(403); }); + + it("enforces exact hosted origins around the real SDK handler", async () => { + const props = createWorkersOAuthMcpProps({ + userId: "user-1", + userEmail: "user@example.com", + organizationId: "org-1", + baseUrl: "https://open-seo.test", + clientId: "client-1", + scopes: ["mcp"], + }); + const modernToolsList = { + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }; + const call = (origin?: string) => + handleAuthenticatedOpenSeoMcpRequest( + request("POST", modernToolsList, { + "Mcp-Method": "tools/list", + ...(origin ? { Origin: origin } : {}), + }), + props, + {}, + { ...ctx, props }, + ); + + await expect( + call("chrome-extension://pghallcbnfabbgfijhbcldaapmgidnaa"), + ).resolves.toMatchObject({ status: 200 }); + await expect(call("https://open-seo.test")).resolves.toMatchObject({ + status: 200, + }); + await expect(call()).resolves.toMatchObject({ status: 200 }); + await expect( + call("https://pghallcbnfabbgfijhbcldaapmgidnaa"), + ).resolves.toMatchObject({ status: 403 }); + await expect( + call("chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ).resolves.toMatchObject({ status: 403 }); + }); }); diff --git a/src/server/mcp/transport.test.ts b/src/server/mcp/transport.test.ts index 961ec87..dfd535e 100644 --- a/src/server/mcp/transport.test.ts +++ b/src/server/mcp/transport.test.ts @@ -84,12 +84,13 @@ function createMcpRequest(headers?: Record) { // 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() { +function createModernMcpRequest(headers?: Record) { return new Request("https://open-seo.test/mcp", { method: "POST", headers: { Accept: "application/json, text/event-stream", "Content-Type": "application/json", + ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", @@ -244,6 +245,22 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => { expect(selfHostedAuthMocks.createOpenSeoMcpServer).not.toHaveBeenCalled(); }); + it("accepts a modern request from the exact SurfMind extension origin", async () => { + const props = hostedProps(); + + const response = await handleAuthenticatedOpenSeoMcpRequest( + createModernMcpRequest({ + Origin: "chrome-extension://pghallcbnfabbgfijhbcldaapmgidnaa", + }), + props, + {}, + { ...ctx, props }, + ); + + expect(response.status).toBe(202); + expect(await response.json()).toEqual({ handledBy: "modern" }); + }); + it("rejects a legacy request from a disallowed Origin", async () => { const props = hostedProps(); @@ -276,6 +293,28 @@ describe("handleAuthenticatedOpenSeoMcpRequest", () => { ); }); + it.each([ + [ + "the SurfMind hostname over HTTPS", + "https://pghallcbnfabbgfijhbcldaapmgidnaa", + ], + [ + "another Chrome extension", + "chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ], + ])("rejects a request from %s", async (_label, origin) => { + const props = hostedProps(); + + const response = await handleAuthenticatedOpenSeoMcpRequest( + createMcpRequest({ Origin: origin }), + props, + {}, + { ...ctx, props }, + ); + + expect(response.status).toBe(403); + }); + 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. diff --git a/src/server/mcp/transport.ts b/src/server/mcp/transport.ts index 196db45..de1cd02 100644 --- a/src/server/mcp/transport.ts +++ b/src/server/mcp/transport.ts @@ -33,6 +33,7 @@ const MCP_CORS_HEADERS = { } as const; const SURFMIND_CHROME_EXTENSION_HOSTNAME = "pghallcbnfabbgfijhbcldaapmgidnaa"; +const SURFMIND_CHROME_EXTENSION_ORIGIN = `chrome-extension://${SURFMIND_CHROME_EXTENSION_HOSTNAME}`; function withMcpCors(response: Response) { const headers = new Headers(response.headers); @@ -113,11 +114,12 @@ async function handleLegacyJsonRequest(request: Request, props: McpProps) { } } -// Hosted pins browser Origins to the configured base URL. Self-hosted leaves -// the option unset so the handler's localhost-class default applies — an -// allowlist derived from the request's own Host would accept a DNS-rebinding -// page trivially. Non-browser MCP clients send no Origin and are unaffected -// either way. +// Hosted applies exact-origin validation before passing the corresponding +// hostname allowlist to the SDK as defense in depth. Self-hosted leaves the +// option unset so the handler's localhost-class default applies — an allowlist +// derived from the request's own Host would accept a DNS-rebinding page +// trivially. Non-browser MCP clients send no Origin and are unaffected either +// way. function createRequestHandler( props: McpProps, allowedOriginHostnames?: string[], @@ -158,8 +160,18 @@ export async function handleAuthenticatedOpenSeoMcpRequest( return new Response("MCP scope required", { status: 403 }); } + const hostedUrl = new URL(getHostedBaseUrl()); + const origin = request.headers.get("Origin"); + if ( + origin && + origin !== hostedUrl.origin && + origin !== SURFMIND_CHROME_EXTENSION_ORIGIN + ) { + return withMcpCors(new Response("Invalid Origin", { status: 403 })); + } + return createRequestHandler(result.data, [ - new URL(getHostedBaseUrl()).hostname, + hostedUrl.hostname, SURFMIND_CHROME_EXTENSION_HOSTNAME, ])(request, env, ctx); }