From a14ab0b086e9decbe03f2425709bc5fbae265e10 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 11 Aug 2026 05:28:43 -0400 Subject: [PATCH] feat(core): Advertise the MCP 2026-07-28 routing headers in CORS (#35471) --- packages/cli/src/modules/mcp/README.md | 21 +++++++++++++++++++ .../mcp/__tests__/mcp.controller.test.ts | 21 +++++++++++++++---- .../cli/src/modules/mcp/mcp.controller.ts | 8 ++++++- 3 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/modules/mcp/README.md diff --git a/packages/cli/src/modules/mcp/README.md b/packages/cli/src/modules/mcp/README.md new file mode 100644 index 00000000000..2d9770f4af0 --- /dev/null +++ b/packages/cli/src/modules/mcp/README.md @@ -0,0 +1,21 @@ +# Instance MCP server + +Serves the instance-level MCP endpoint at `POST /mcp-server/http`. It speaks the +2026-07-28 protocol revision and, through the SDK's stateless legacy fallback, +still serves 2025-era clients on the same endpoint. + +## Routing headers (2026-07-28) + +The revision requires two headers on every Streamable HTTP `POST` so that +gateways, load balancers, and WAFs can route and rate-limit without parsing the +JSON-RPC body: + +- `Mcp-Method` — the JSON-RPC method (e.g. `tools/call`, `tools/list`, + `server/discover`). +- `Mcp-Name` — for `tools/call`, the tool being called (e.g. `execute_workflow`). + +The SDK validates these against the parsed body and rejects a disagreement with +`HeaderMismatchError` (`-32020`), so a client cannot mislabel the header to route +around a rule keyed on it. Browser-based clients can only send these when they +are in the CORS allow-list, which is why `setCorsHeaders` advertises +`MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name`. diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts index ddec2f6894c..ed8e5ca7dc8 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts @@ -1,12 +1,14 @@ -import type { Mock } from 'vitest'; import { Logger } from '@n8n/backend-common'; import { ApiKeyRepository, type AuthenticatedRequest } from '@n8n/db'; import { ControllerRegistryMetadata, type Controller } from '@n8n/decorators'; import { Container } from '@n8n/di'; import type { Request } from 'express'; +import type { Mock } from 'vitest'; import { mock, mockDeep } from 'vitest-mock-extended'; -// eslint-disable-next-line import-x/order +import { Telemetry } from '@/telemetry'; + +import { McpProtectedResource } from '../mcp-protected-resource'; import { McpServerMiddlewareService } from '../mcp-server-middleware.service'; const mockAuthMiddleware = vi.fn().mockImplementation(async function (_req, _res, next) { @@ -23,11 +25,9 @@ Container.set(McpServerMiddlewareService, mcpServerMiddlewareService); import { McpConfig } from '../mcp.config'; import { MCP_CLIENT_INFO_META_KEY, MCP_PROTOCOL_VERSION_META_KEY } from '../mcp.constants'; -import { McpProtectedResource } from '../mcp-protected-resource'; import type { McpController as McpControllerType, FlushableResponse } from '../mcp.controller'; import { McpService } from '../mcp.service'; import { McpSettingsService } from '../mcp.settings.service'; -import { Telemetry } from '@/telemetry'; import type { UserConnectedToMCPEventPayload } from '../mcp.types'; const mockHandleRequest = vi.fn().mockResolvedValue(undefined); @@ -145,6 +145,19 @@ describe('McpController', () => { expect(mcpService.resolveFeatureFlags as Mock).not.toHaveBeenCalled(); }); + test('advertises the MCP routing headers in the CORS allow-list', async () => { + (mcpSettingsService.getEnabled as Mock).mockResolvedValue(false); + const res = createRes(); + res.header = vi.fn().mockReturnThis(); + + await controller.build(createReq(), res); + + expect(res.header).toHaveBeenCalledWith( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, X-Requested-With, MCP-Protocol-Version, Mcp-Method, Mcp-Name', + ); + }); + test('creates mcp server if MCP access is enabled', async () => { (mcpSettingsService.getEnabled as Mock).mockResolvedValue(true); (mcpService.getServer as unknown as Mock).mockReturnValue({ diff --git a/packages/cli/src/modules/mcp/mcp.controller.ts b/packages/cli/src/modules/mcp/mcp.controller.ts index a4858287b0d..88833db4fc8 100644 --- a/packages/cli/src/modules/mcp/mcp.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.controller.ts @@ -45,7 +45,13 @@ export class McpController { // Allow requests from Claude AI playground and other MCP clients res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); + // MCP-Protocol-Version, Mcp-Method and Mcp-Name are the 2026-07-28 routing + // headers. Without listing them here a browser-based client can't send + // them, so the server would reject its requests with a header mismatch. + res.header( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, X-Requested-With, MCP-Protocol-Version, Mcp-Method, Mcp-Name', + ); res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Max-Age', '86400'); // 24 hours }