From 145863c58e56bd886feee110e202091649e643c9 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Fri, 7 Aug 2026 10:25:43 -0400 Subject: [PATCH] feat(core): Support the MCP 2026-07-28 discovery handshake on the instance server (#35470) --- .../mcp-server-middleware.service.test.ts | 42 ++++++++ .../mcp/__tests__/mcp.constants.test.ts | 34 ++++++ .../mcp/__tests__/mcp.controller.test.ts | 46 ++++++++ .../modules/mcp/__tests__/mcp.utils.test.ts | 100 +++++++++++++++++- .../mcp/mcp-server-middleware.service.ts | 3 +- packages/cli/src/modules/mcp/mcp.constants.ts | 20 ++++ .../cli/src/modules/mcp/mcp.controller.ts | 18 +++- packages/cli/src/modules/mcp/mcp.types.ts | 5 + packages/cli/src/modules/mcp/mcp.utils.ts | 49 +++++++-- 9 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/modules/mcp/__tests__/mcp.constants.test.ts diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts index 561dee8c180..39c74ab942f 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-server-middleware.service.test.ts @@ -10,6 +10,7 @@ import { OAuthTokenVerifierProxy } from '@/services/oauth-token-verifier-proxy.s import { Telemetry } from '@/telemetry'; import { McpServerApiKeyService } from '../mcp-api-key.service'; +import { MCP_CLIENT_INFO_META_KEY, MCP_PROTOCOL_VERSION_META_KEY } from '../mcp.constants'; import { McpProtectedResource } from '../mcp-protected-resource'; import { McpServerMiddlewareService } from '../mcp-server-middleware.service'; @@ -416,6 +417,47 @@ describe('McpServerMiddlewareService', () => { }); }); + // An unauthenticated `server/discover` probe is treated like any other RPC: + // capability discovery is grant-scoped, so it stays behind auth and the 401 + // points the client at the OAuth protected-resource metadata. The client + // identity and protocol version it declared in `_meta` are still captured. + it('should reject an unauthenticated server/discover probe with 401 and capture _meta', async () => { + const req = mockReqWith(undefined, { + jsonrpc: '2.0', + method: 'server/discover', + params: { + _meta: { + [MCP_PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [MCP_CLIENT_INFO_META_KEY]: { name: 'Claude', version: '3.0.0' }, + }, + }, + }); + const res = mockDeep(); + res.status.mockReturnThis(); + res.send.mockReturnThis(); + res.header.mockReturnThis(); + const next = vi.fn() as NextFunction; + + await service.getAuthMiddleware()(req, res, next); + + expect(res.header).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'Bearer realm="n8n MCP Server", resource_metadata="https://n8n.example.com/.well-known/oauth-protected-resource/mcp-server/http"', + ); + expect(res.status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', { + mcp_connection_status: 'error', + error: 'Unauthorized', + client_name: 'Claude', + client_version: '3.0.0', + protocol_version: '2026-07-28', + auth_type: 'unknown', + error_details: 'Authorization header not sent', + reason: 'missing_authorization_header', + }); + }); + it('should handle invalid token format gracefully with WWW-Authenticate header', async () => { const req = mockReqWith('Bearer invalid-token-format'); const res = mockDeep(); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.constants.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.constants.test.ts new file mode 100644 index 00000000000..03693dc440a --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp.constants.test.ts @@ -0,0 +1,34 @@ +import { + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + UnsupportedProtocolVersionError, +} from '@modelcontextprotocol/server'; + +import { MCP_CLIENT_INFO_META_KEY, MCP_PROTOCOL_VERSION_META_KEY } from '../mcp.constants'; + +// The `_meta` envelope keys are mirrored locally so reading them off a request +// never eagerly loads the v2 SDK at boot (see mcp.constants.ts). These assertions +// pin the mirror to the SDK's exported constants so the two can't silently drift. +describe('MCP _meta key constants', () => { + it('mirrors the SDK client-info key', () => { + expect(MCP_CLIENT_INFO_META_KEY).toBe(CLIENT_INFO_META_KEY); + }); + + it('mirrors the SDK protocol-version key', () => { + expect(MCP_PROTOCOL_VERSION_META_KEY).toBe(PROTOCOL_VERSION_META_KEY); + }); +}); + +// Version negotiation (server/discover, per-request version checks) is handled +// by the SDK handler. The 2026-07-28 revision renumbered the mismatch error to +// -32022; this pins the code the server returns so an SDK change can't silently +// break that compliance contract. +describe('protocol version mismatch error', () => { + it('reports the renumbered -32022 code', () => { + const error = new UnsupportedProtocolVersionError({ + supported: ['2026-07-28'], + requested: '2020-01-01', + }); + expect(error.code).toBe(-32022); + }); +}); 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 9f6db758c8b..ddec2f6894c 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.controller.test.ts @@ -22,6 +22,7 @@ mcpServerMiddlewareService.getAuthMiddleware.mockReturnValue(mockAuthMiddleware) 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'; @@ -191,6 +192,51 @@ describe('McpController', () => { }); }); + test('tracks a server/discover handshake with client info and protocol version from _meta', async () => { + (mcpSettingsService.getEnabled as Mock).mockResolvedValue(true); + (mcpService.getServer as unknown as Mock).mockReturnValue({ + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }); + (mcpService.resolveFeatureFlags as Mock).mockResolvedValue({ + mcpApps: { enabled: false, variant: 'unassigned' }, + canvasGroupsEnabled: false, + }); + const res = createRes(); + + // The 2026-07-28 revision has no `initialize`; a modern client opens with + // `server/discover` and carries its identity in the per-request _meta + // envelope, so the connection event must fire off that method. + await controller.build( + createReq({ + mcpAuthType: 'oauth', + body: { + jsonrpc: '2.0', + method: 'server/discover', + params: { + _meta: { + [MCP_PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [MCP_CLIENT_INFO_META_KEY]: { name: 'Claude', version: '3.0.0' }, + }, + }, + }, + }), + res, + ); + + expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', { + user_id: 'user-1', + client_name: 'Claude', + client_version: '3.0.0', + protocol_version: '2026-07-28', + auth_type: 'oauth', + mcp_connection_status: 'success', + mcp_apps_enabled: false, + mcp_apps_variant: 'unassigned', + mcp_canvas_groups_enabled: false, + }); + }); + test('reports the env_override variant when the flag is forced on by an operator', async () => { (mcpSettingsService.getEnabled as Mock).mockResolvedValue(true); (mcpService.getServer as unknown as Mock).mockReturnValue({ diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts index 68f52f93c45..8dd65403ab1 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts @@ -2,7 +2,8 @@ import type { AuthenticatedRequest } from '@n8n/db'; import type { Request } from 'express'; import { mock } from 'vitest-mock-extended'; -import { getClientInfo, getToolName, getToolArguments } from '../mcp.utils'; +import { MCP_CLIENT_INFO_META_KEY, MCP_PROTOCOL_VERSION_META_KEY } from '../mcp.constants'; +import { getClientInfo, getProtocolVersion, getToolName, getToolArguments } from '../mcp.utils'; describe('mcp.utils', () => { describe('getClientInfo', () => { @@ -122,6 +123,103 @@ describe('mcp.utils', () => { version: '2.0.0', }); }); + + it('should read clientInfo from the 2026-07-28 _meta envelope', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'server/discover', + params: { + _meta: { + [MCP_CLIENT_INFO_META_KEY]: { name: 'Claude', version: '3.0.0' }, + }, + }, + id: 1, + }, + } as Request; + + expect(getClientInfo(req)).toEqual({ name: 'Claude', version: '3.0.0' }); + }); + + it('should prefer the _meta envelope over the legacy params.clientInfo', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'tools/call', + params: { + clientInfo: { name: 'legacy', version: '1.0.0' }, + _meta: { + [MCP_CLIENT_INFO_META_KEY]: { name: 'modern', version: '2.0.0' }, + }, + }, + id: 1, + }, + } as Request; + + expect(getClientInfo(req)).toEqual({ name: 'modern', version: '2.0.0' }); + }); + + it('should handle a partial _meta clientInfo (name only)', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'server/discover', + params: { + _meta: { [MCP_CLIENT_INFO_META_KEY]: { name: 'Claude' } }, + }, + id: 1, + }, + } as Request; + + expect(getClientInfo(req)).toEqual({ name: 'Claude', version: undefined }); + }); + }); + + describe('getProtocolVersion', () => { + it('should read the protocol version from the _meta envelope', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'server/discover', + params: { + _meta: { [MCP_PROTOCOL_VERSION_META_KEY]: '2026-07-28' }, + }, + id: 1, + }, + } as Request; + + expect(getProtocolVersion(req)).toBe('2026-07-28'); + }); + + it('should return undefined when the _meta envelope is absent (legacy client)', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'legacy', version: '1.0.0' } }, + id: 1, + }, + } as Request; + + expect(getProtocolVersion(req)).toBeUndefined(); + }); + + it('should return undefined when the version is not a string', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'server/discover', + params: { _meta: { [MCP_PROTOCOL_VERSION_META_KEY]: 20260728 } }, + id: 1, + }, + } as Request; + + expect(getProtocolVersion(req)).toBeUndefined(); + }); + + it('should return undefined when body is not a valid JSON-RPC request', () => { + expect(getProtocolVersion({ body: 'invalid' } as Request)).toBeUndefined(); + }); }); describe('getToolName', () => { diff --git a/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts index c9ae9513a68..843ce92506d 100644 --- a/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts +++ b/packages/cli/src/modules/mcp/mcp-server-middleware.service.ts @@ -15,7 +15,7 @@ import { Telemetry } from '@/telemetry'; import { McpServerApiKeyService } from './mcp-api-key.service'; import { McpProtectedResource } from './mcp-protected-resource'; import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants'; -import { getClientInfo } from './mcp.utils'; +import { getClientInfo, getProtocolVersion } from './mcp.utils'; /** * MCP Server Middleware Service @@ -142,6 +142,7 @@ export class McpServerMiddlewareService { error: UNAUTHORIZED_ERROR_MESSAGE, client_name: clientInfo?.name, client_version: clientInfo?.version, + protocol_version: getProtocolVersion(req), ...context, }; this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, payload); diff --git a/packages/cli/src/modules/mcp/mcp.constants.ts b/packages/cli/src/modules/mcp/mcp.constants.ts index 0666c916adc..97a7be2ecf7 100644 --- a/packages/cli/src/modules/mcp/mcp.constants.ts +++ b/packages/cli/src/modules/mcp/mcp.constants.ts @@ -15,6 +15,26 @@ export const USER_CALLED_MCP_TOOL_EVENT = 'User called mcp tool'; export const MCP_PREVIEW_RENDER_REQUESTED_EVENT = 'MCP App preview render requested'; export const MCP_CREDENTIALS_AUTOASSIGN_EVENT = 'MCP credentials autoassign'; +/** + * Per-request `_meta` envelope keys (2026-07-28 revision, SEP-2575). The + * `initialize` handshake is gone; clients now stamp their protocol version and + * identity into `params._meta` on every request, keyed by these spec-defined + * URIs. Mirrored locally rather than imported so reading them off a request + * never eagerly loads the v2 SDK at boot — every value import of + * `@modelcontextprotocol/server` is lazy (see mcp.controller.ts / + * mcp.service.ts). A unit test pins these to the SDK's exported constants so + * they can't silently drift. + */ +export const MCP_PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; +export const MCP_CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo'; + +/** + * The modern capability-discovery RPC (SEP-2575). Replaces `initialize` as the + * point where a client probes the server's protocol version and capabilities, + * so it's the connection-handshake analog for telemetry. + */ +export const MCP_DISCOVER_METHOD = 'server/discover'; + /** * Message constants */ diff --git a/packages/cli/src/modules/mcp/mcp.controller.ts b/packages/cli/src/modules/mcp/mcp.controller.ts index c7132276276..a4858287b0d 100644 --- a/packages/cli/src/modules/mcp/mcp.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.controller.ts @@ -15,12 +15,13 @@ import { USER_CONNECTED_TO_MCP_EVENT, MCP_ACCESS_DISABLED_ERROR_MESSAGE, INTERNAL_SERVER_ERROR_MESSAGE, + MCP_DISCOVER_METHOD, } from './mcp.constants'; import { McpService, type McpFeatureFlags } from './mcp.service'; import { McpSettingsService } from './mcp.settings.service'; import { isJSONRPCRequest } from './mcp.typeguards'; import type { UserConnectedToMCPEventPayload } from './mcp.types'; -import { getClientInfo } from './mcp.utils'; +import { getClientInfo, getProtocolVersion } from './mcp.utils'; export type FlushableResponse = Response & { flush: () => void }; @@ -120,7 +121,13 @@ export class McpController { const body = req.body; this.logger.debug('MCP Request', { body }); - const isInitializationRequest = isJSONRPCRequest(body) ? body.method === 'initialize' : false; + // The 2026-07-28 revision drops `initialize`; a modern client's first + // request is `server/discover`, so both mark the connection handshake for + // telemetry. Legacy clients on the stateless fallback still send + // `initialize`. + const isConnectionHandshake = isJSONRPCRequest(body) + ? body.method === 'initialize' || body.method === MCP_DISCOVER_METHOD + : false; const isToolCallRequest = isJSONRPCRequest(body) ? body.method === 'tools/call' : false; const clientInfo = getClientInfo(req); @@ -128,6 +135,7 @@ export class McpController { user_id: req.user.id, client_name: clientInfo?.name, client_version: clientInfo?.version, + protocol_version: getProtocolVersion(req), auth_type: ( req as AuthenticatedRequest & { mcpAuthType?: UserConnectedToMCPEventPayload['auth_type'] } ).mcpAuthType, @@ -136,7 +144,7 @@ export class McpController { const enabled = await this.mcpSettingsService.getEnabled(); if (!enabled) { - if (isInitializationRequest) { + if (isConnectionHandshake) { this.trackConnectionEvent({ ...baseTelemetryPayload, mcp_connection_status: 'error', @@ -162,7 +170,7 @@ export class McpController { // when multiple clients connect concurrently. try { await this.handleTransportRequest(req, res, featureFlags, req.body); - if (isInitializationRequest) { + if (isConnectionHandshake) { this.trackConnectionEvent({ ...telemetryPayload, mcp_connection_status: 'success', @@ -172,7 +180,7 @@ export class McpController { } } catch (error) { this.errorReporter.error(error); - if (isInitializationRequest) { + if (isConnectionHandshake) { this.trackConnectionEvent({ ...telemetryPayload, mcp_connection_status: 'error', diff --git a/packages/cli/src/modules/mcp/mcp.types.ts b/packages/cli/src/modules/mcp/mcp.types.ts index dbccc55520f..2194f9d88ad 100644 --- a/packages/cli/src/modules/mcp/mcp.types.ts +++ b/packages/cli/src/modules/mcp/mcp.types.ts @@ -120,7 +120,10 @@ export type JSONRPCRequest = { jsonrpc?: string; method?: string; params?: { + /** 2025-era location; superseded by the `_meta` envelope in 2026-07-28. */ clientInfo?: McpClientInfo; + /** Per-request envelope carrying protocol version and client identity. */ + _meta?: Record; [key: string]: unknown; }; id?: string | number | null; @@ -138,6 +141,8 @@ export type UserConnectedToMCPEventPayload = { user_id?: string; client_name?: string; client_version?: string; + /** Protocol revision the client declared in its `_meta` envelope (2026-07-28+). */ + protocol_version?: string; auth_type?: Mcpauth_type; mcp_connection_status: 'success' | 'error'; mcp_apps_enabled?: boolean; diff --git a/packages/cli/src/modules/mcp/mcp.utils.ts b/packages/cli/src/modules/mcp/mcp.utils.ts index 247b9c796c9..a4978472381 100644 --- a/packages/cli/src/modules/mcp/mcp.utils.ts +++ b/packages/cli/src/modules/mcp/mcp.utils.ts @@ -3,18 +3,53 @@ import { isRecord } from '@n8n/utils/is-record'; import type { Request } from 'express'; import type { INode } from 'n8n-workflow'; -import { SUPPORTED_MCP_TRIGGERS, SUPPORTED_PRODUCTION_MCP_TRIGGERS } from './mcp.constants'; +import { + MCP_CLIENT_INFO_META_KEY, + MCP_PROTOCOL_VERSION_META_KEY, + SUPPORTED_MCP_TRIGGERS, + SUPPORTED_PRODUCTION_MCP_TRIGGERS, +} from './mcp.constants'; import { isJSONRPCRequest } from './mcp.typeguards'; -import type { McpClientInfo } from './mcp.types'; +import type { JSONRPCRequest, McpClientInfo } from './mcp.types'; type McpExecutionMode = 'manual' | 'production'; -export const getClientInfo = (req: Request | AuthenticatedRequest) => { - let clientInfo: McpClientInfo | undefined; - if (isJSONRPCRequest(req.body) && req.body.params?.clientInfo) { - clientInfo = req.body.params.clientInfo; +/** Reads a single key off a request's `params._meta` envelope, if present. */ +const getRequestMetaValue = (params: JSONRPCRequest['params'], key: string): unknown => { + const meta = params?._meta; + return isRecord(meta) ? meta[key] : undefined; +}; + +/** + * Extracts the client's self-identification from a request. The 2026-07-28 + * revision moves this into the per-request `_meta` envelope (sent on every + * request), so read that first; fall back to the 2025-era `initialize` params + * location so legacy clients on the stateless fallback are still identified. + */ +export const getClientInfo = (req: Request | AuthenticatedRequest): McpClientInfo | undefined => { + if (!isJSONRPCRequest(req.body)) return undefined; + const params = req.body.params; + + const metaClientInfo = getRequestMetaValue(params, MCP_CLIENT_INFO_META_KEY); + if (isRecord(metaClientInfo)) { + return { + name: typeof metaClientInfo.name === 'string' ? metaClientInfo.name : undefined, + version: typeof metaClientInfo.version === 'string' ? metaClientInfo.version : undefined, + }; } - return clientInfo; + + return params?.clientInfo; +}; + +/** + * Reads the protocol version a client declares in its per-request `_meta` + * envelope (2026-07-28). Absent for 2025-era clients, which carried it in the + * `initialize` handshake the new revision removed. + */ +export const getProtocolVersion = (req: Request | AuthenticatedRequest): string | undefined => { + if (!isJSONRPCRequest(req.body)) return undefined; + const version = getRequestMetaValue(req.body.params, MCP_PROTOCOL_VERSION_META_KEY); + return typeof version === 'string' ? version : undefined; }; /**