feat(core): Track disabled MCP authorization attempts

This commit is contained in:
Declan Carroll 2026-09-08 15:08:34 +00:00
parent 0e8fbb0d4e
commit 4bd75d6217
6 changed files with 131 additions and 3 deletions

View File

@ -30,6 +30,18 @@ export const MCP_TELEMETRY = defineTelemetryEvents({
state: z.boolean().describe('Resulting state of MCP access, not the prior one'),
}),
},
MCP_CLIENT_ATTEMPTED_AUTHORIZATION_WHILE_ACCESS_DISABLED: {
name: 'MCP client attempted authorization while access disabled',
description:
'An MCP client started OAuth authorization while instance-level MCP access was disabled.',
properties: z.object({
client_brand: clientBrand,
client_type: clientType,
resource_provided: z
.boolean()
.describe('Whether the authorization request included an RFC 8707 resource indicator'),
}),
},
USER_CLICKED_CONNECT_CLIENT: {
name: 'User clicked connect MCP client',
description:

View File

@ -248,6 +248,25 @@ describe('TelemetryEventRelay', () => {
});
});
describe('MCP events', () => {
it('should track a disabled MCP OAuth authorization attempt', () => {
eventService.emit('mcp-oauth-authorization-rejected', {
clientBrand: 'claude',
clientType: 'cli',
resourceProvided: true,
});
expect(telemetry.track).toHaveBeenCalledWith(
TELEMETRY_EVENT.MCP.MCP_CLIENT_ATTEMPTED_AUTHORIZATION_WHILE_ACCESS_DISABLED,
{
client_brand: 'claude',
client_type: 'cli',
resource_provided: true,
},
);
});
});
describe('project events', () => {
it('should track on `team-project-updated` event', () => {
const event: RelayEventMap['team-project-updated'] = {

View File

@ -1,4 +1,10 @@
import type { AuthenticationMethod, ProjectRelation, RedactionFloor } from '@n8n/api-types';
import type {
AuthenticationMethod,
McpClientBrandName,
McpClientType,
ProjectRelation,
RedactionFloor,
} from '@n8n/api-types';
import type { AuthProviderType, User, IWorkflowDb } from '@n8n/db';
import type {
CancellationReason,
@ -1237,6 +1243,12 @@ export type RelayEventMap = {
clientName?: string;
};
'mcp-oauth-authorization-rejected': {
clientBrand: McpClientBrandName | null;
clientType: McpClientType | null;
resourceProvided: boolean;
};
/**
* `authType` reports how the call authenticated, so an absent `clientId` is
* explicit rather than inferred. `api_key` covers every non-OAuth bearer

View File

@ -221,6 +221,7 @@ export class TelemetryEventRelay extends EventRelay {
this.instanceAiMcpRegistryConnectionCreated(event),
'instance-ai-mcp-registry-connection-deleted': (event) =>
this.instanceAiMcpRegistryConnectionDeleted(event),
'mcp-oauth-authorization-rejected': (event) => this.mcpOauthAuthorizationRejected(event),
'hitl-response-actioned': (event) => this.hitlResponseActioned(event),
'runner-disconnected': (event) => this.runnerDisconnected(event),
});
@ -266,6 +267,25 @@ export class TelemetryEventRelay extends EventRelay {
// #endregion
// #region MCP
private mcpOauthAuthorizationRejected({
clientBrand,
clientType,
resourceProvided,
}: RelayEventMap['mcp-oauth-authorization-rejected']) {
this.telemetry.track(
TELEMETRY_EVENT.MCP.MCP_CLIENT_ATTEMPTED_AUTHORIZATION_WHILE_ACCESS_DISABLED,
{
client_brand: clientBrand,
client_type: clientType,
resource_provided: resourceProvided,
},
);
}
// #endregion
// #endregion
// #region Team

View File

@ -31,6 +31,7 @@ import { OAuthTokenService } from '../oauth-token.service';
const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails'];
const TEST_RESOURCE_URL = 'https://n8n.example.com/mcp-server/http';
const NON_MCP_RESOURCE_URL = 'https://n8n.example.com/form/test';
let logger: Mocked<Logger>;
let oauthSessionService: Mocked<OAuthSessionService>;
@ -77,6 +78,14 @@ describe('OAuthServerService', () => {
isAvailable: isResourceAvailable,
authorize: async () => true,
});
resourceRegistry.register({
id: 'form-test',
getResourceUrl: () => NON_MCP_RESOURCE_URL,
getAudiences: () => [NON_MCP_RESOURCE_URL],
scopes: [],
isAvailable: async () => false,
authorize: async () => true,
});
service = new OAuthServerService(
logger,
@ -580,12 +589,16 @@ describe('OAuthServerService', () => {
resource: 'https://n8n.example.com/mcp-server/http',
});
expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
expect(eventService.emit).not.toHaveBeenCalledWith(
'mcp-oauth-authorization-rejected',
expect.anything(),
);
});
it('should reject with invalid_target when the named resource is unavailable', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
client_name: 'Claude Code',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
@ -616,6 +629,11 @@ describe('OAuthServerService', () => {
});
expect(oauthSessionService.createSession).not.toHaveBeenCalled();
expect(res.redirect).not.toHaveBeenCalled();
expect(eventService.emit).toHaveBeenCalledWith('mcp-oauth-authorization-rejected', {
clientBrand: 'claude',
clientType: 'cli',
resourceProvided: true,
});
});
it('should reject with invalid_target when no resource is named and the default resource is unavailable', async () => {
@ -650,6 +668,41 @@ describe('OAuthServerService', () => {
error_description: 'Resource is not available for authorization',
});
expect(oauthSessionService.createSession).not.toHaveBeenCalled();
expect(eventService.emit).toHaveBeenCalledWith('mcp-oauth-authorization-rejected', {
clientBrand: null,
clientType: null,
resourceProvided: false,
});
});
it('should not emit MCP telemetry when a non-MCP resource is unavailable', async () => {
const client = {
client_id: 'client-123',
client_name: 'Test Client',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'none',
response_types: ['code'],
};
const res = mock<Response>();
res.status.mockReturnThis();
res.json.mockReturnThis();
await service.authorize(
client,
{
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge-123',
resource: new URL(NON_MCP_RESOURCE_URL),
},
res,
);
expect(res.status).toHaveBeenCalledWith(400);
expect(eventService.emit).not.toHaveBeenCalledWith(
'mcp-oauth-authorization-rejected',
expect.anything(),
);
});
describe('reusing a prior consent (auto-approval)', () => {

View File

@ -14,7 +14,11 @@ import type {
OAuthTokenRevocationRequest,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import type { McpClientConnectedPeriod, McpClientTypeFilter } from '@n8n/api-types';
import { getMcpClientType, MCP_CLIENT_TYPE_FILTER_BUCKETS } from '@n8n/api-types';
import {
getMcpClientBrand,
getMcpClientType,
MCP_CLIENT_TYPE_FILTER_BUCKETS,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { INSTANCE_MCP_RESOURCE_ID } from '@n8n/constants';
@ -420,6 +424,14 @@ export class OAuthServerService implements OAuthServerProvider {
clientId: client.client_id,
resource: targetResource.getResourceUrl(),
});
if (targetResource.id === INSTANCE_MCP_RESOURCE_ID) {
const clientName = client.client_name ?? '';
this.eventService.emit('mcp-oauth-authorization-rejected', {
clientBrand: getMcpClientBrand(clientName),
clientType: getMcpClientType(clientName),
resourceProvided: params.resource !== undefined,
});
}
res.status(400).json({
error: 'invalid_target',
error_description: 'Resource is not available for authorization',