feat(core): Support the MCP 2026-07-28 discovery handshake on the instance server (#35470)

This commit is contained in:
Ricardo Espinoza 2026-08-07 10:25:43 -04:00 committed by GitHub
parent abe2959de8
commit 145863c58e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 303 additions and 14 deletions

View File

@ -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<Response>();
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<Response>();

View File

@ -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);
});
});

View File

@ -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({

View File

@ -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', () => {

View File

@ -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);

View File

@ -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
*/

View File

@ -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',

View File

@ -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<string, unknown>;
[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;

View File

@ -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;
};
/**