mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Support the MCP 2026-07-28 discovery handshake on the instance server (#35470)
This commit is contained in:
parent
abe2959de8
commit
145863c58e
|
|
@ -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>();
|
||||
|
|
|
|||
34
packages/cli/src/modules/mcp/__tests__/mcp.constants.test.ts
Normal file
34
packages/cli/src/modules/mcp/__tests__/mcp.constants.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user