diff --git a/packages/@n8n/telemetry/src/events/mcp.ts b/packages/@n8n/telemetry/src/events/mcp.ts index 8996216129c..60db35950fc 100644 --- a/packages/@n8n/telemetry/src/events/mcp.ts +++ b/packages/@n8n/telemetry/src/events/mcp.ts @@ -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: diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index bb29be9ba6f..1bbe6baacdf 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -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'] = { diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 9968bcbcc3f..598cd89618e 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -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 diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 470a1e170fa..5665d404dbe 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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 diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index 2e496024886..aedc9384fa9 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -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; let oauthSessionService: Mocked; @@ -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(); + 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)', () => { diff --git a/packages/cli/src/modules/oauth-server/oauth-server.service.ts b/packages/cli/src/modules/oauth-server/oauth-server.service.ts index 109607af1aa..66a9dda649e 100644 --- a/packages/cli/src/modules/oauth-server/oauth-server.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-server.service.ts @@ -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',