From 3f673fd037a4b93fa03be69358e8f037a5f77b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Wed, 15 Oct 2025 13:43:30 +0200 Subject: [PATCH] feat(core): Add telemetry to MCP (no-changelog) (#20797) --- .../mcp-server-api-key.service.test.ts | 4 + .../modules/mcp/__tests__/mcp.utils.test.ts | 354 ++++++++++++++++++ .../src/modules/mcp/mcp-api-key.service.ts | 28 +- packages/cli/src/modules/mcp/mcp.constants.ts | 6 + .../cli/src/modules/mcp/mcp.controller.ts | 68 +++- .../cli/src/modules/mcp/mcp.typeguards.ts | 36 +- packages/cli/src/modules/mcp/mcp.types.ts | 29 ++ packages/cli/src/modules/mcp/mcp.utils.ts | 46 +++ .../editor-ui/src/components/WorkflowCard.vue | 3 + .../src/components/WorkflowSettings.vue | 6 +- .../features/mcpAccess/SettingsMCPView.vue | 3 + .../features/mcpAccess/composables/useMcp.ts | 13 + 12 files changed, 583 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts create mode 100644 packages/cli/src/modules/mcp/mcp.constants.ts create mode 100644 packages/cli/src/modules/mcp/mcp.utils.ts diff --git a/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts index 9a80dcdbff8..fa76ad3e2f1 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp-server-api-key.service.test.ts @@ -7,6 +7,7 @@ import { mock, mockDeep } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; import { JwtService } from '@/services/jwt.service'; +import { Telemetry } from '@/telemetry'; import { McpServerApiKeyService } from '../mcp-api-key.service'; @@ -24,6 +25,7 @@ const jwtService = new JwtService(instanceSettings, mock()); let userRepository: jest.Mocked; let apiKeyRepository: jest.Mocked; +let telemetry: jest.Mocked; let mcpServerApiKeyService: McpServerApiKeyService; describe('McpServerApiKeyService', () => { @@ -34,10 +36,12 @@ describe('McpServerApiKeyService', () => { beforeAll(() => { userRepository = mockInstance(UserRepository); apiKeyRepository = mockInstance(ApiKeyRepository); + telemetry = mockInstance(Telemetry); mcpServerApiKeyService = new McpServerApiKeyService( apiKeyRepository, jwtService, userRepository, + telemetry, ); }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts new file mode 100644 index 00000000000..2f14156239a --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts @@ -0,0 +1,354 @@ +import type { AuthenticatedRequest } from '@n8n/db'; +import type { Request } from 'express'; +import { mock } from 'jest-mock-extended'; + +import { getClientInfo, getToolName, getToolArguments } from '../mcp.utils'; + +describe('mcp.utils', () => { + describe('getClientInfo', () => { + it('should return clientInfo from valid JSON-RPC request', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'tools/call', + params: { + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }, + id: 1, + }, + } as Request; + + const result = getClientInfo(req); + expect(result).toEqual({ + name: 'test-client', + version: '1.0.0', + }); + }); + + it('should return undefined when clientInfo is missing', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'tools/call', + params: {}, + id: 1, + }, + } as Request; + + const result = getClientInfo(req); + expect(result).toBeUndefined(); + }); + + it('should return undefined when params is missing', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'tools/call', + id: 1, + }, + } as Request; + + const result = getClientInfo(req); + expect(result).toBeUndefined(); + }); + + it('should return undefined when body is not a valid JSON-RPC request', () => { + const req = { + body: 'invalid', + } as Request; + + const result = getClientInfo(req); + expect(result).toBeUndefined(); + }); + + it('should return undefined when body is null', () => { + const req = { + body: null, + } as Request; + + const result = getClientInfo(req); + expect(result).toBeUndefined(); + }); + + it('should return undefined when body is an array', () => { + const req = { + body: [], + } as Request; + + const result = getClientInfo(req); + expect(result).toBeUndefined(); + }); + + it('should handle clientInfo with partial information', () => { + const req = { + body: { + jsonrpc: '2.0', + method: 'tools/call', + params: { + clientInfo: { + name: 'test-client', + }, + }, + id: 1, + }, + } as Request; + + const result = getClientInfo(req); + expect(result).toEqual({ + name: 'test-client', + }); + }); + + it('should work with AuthenticatedRequest type', () => { + const req = mock(); + req.body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + clientInfo: { + name: 'authenticated-client', + version: '2.0.0', + }, + }, + id: 1, + }; + + const result = getClientInfo(req); + expect(result).toEqual({ + name: 'authenticated-client', + version: '2.0.0', + }); + }); + }); + + describe('getToolName', () => { + it('should return tool name from valid JSON-RPC request', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'search-workflows', + }, + id: 1, + }; + + const result = getToolName(body); + expect(result).toBe('search-workflows'); + }); + + it('should return "unknown" when name is missing', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: {}, + id: 1, + }; + + const result = getToolName(body); + expect(result).toBe('unknown'); + }); + + it('should return "unknown" when params is missing', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + id: 1, + }; + + const result = getToolName(body); + expect(result).toBe('unknown'); + }); + + it('should return "unknown" when body is not a valid JSON-RPC request', () => { + const result = getToolName('invalid'); + expect(result).toBe('unknown'); + }); + + it('should return "unknown" when name is not a string', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 123, + }, + id: 1, + }; + + const result = getToolName(body); + expect(result).toBe('unknown'); + }); + + it('should return "unknown" when body is null', () => { + const result = getToolName(null); + expect(result).toBe('unknown'); + }); + + it('should return "unknown" when body is undefined', () => { + const result = getToolName(undefined); + expect(result).toBe('unknown'); + }); + + it('should return "unknown" when body is an array', () => { + const result = getToolName([]); + expect(result).toBe('unknown'); + }); + + it('should return "unknown" when name is an object', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: { tool: 'test' }, + }, + id: 1, + }; + + const result = getToolName(body); + expect(result).toBe('unknown'); + }); + }); + + describe('getToolArguments', () => { + it('should return arguments from valid JSON-RPC request', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'search-workflows', + arguments: { + limit: 10, + active: true, + name: 'test', + }, + }, + id: 1, + }; + + const result = getToolArguments(body); + expect(result).toEqual({ + limit: 10, + active: true, + name: 'test', + }); + }); + + it('should return empty object when arguments is missing', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'search-workflows', + }, + id: 1, + }; + + const result = getToolArguments(body); + expect(result).toEqual({}); + }); + + it('should return empty object when params is missing', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + id: 1, + }; + + const result = getToolArguments(body); + expect(result).toEqual({}); + }); + + it('should return empty object when body is not a valid JSON-RPC request', () => { + const result = getToolArguments('invalid'); + expect(result).toEqual({}); + }); + + it('should return empty object when arguments is not an object', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'search-workflows', + arguments: 'invalid', + }, + id: 1, + }; + + const result = getToolArguments(body); + expect(result).toEqual({}); + }); + + it('should return empty object when arguments is null', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'search-workflows', + arguments: null, + }, + id: 1, + }; + + const result = getToolArguments(body); + expect(result).toEqual({}); + }); + + it('should return empty object when arguments is an array', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'search-workflows', + arguments: [1, 2, 3], + }, + id: 1, + }; + + const result = getToolArguments(body); + expect(result).toEqual({}); + }); + + it('should return empty object when body is null', () => { + const result = getToolArguments(null); + expect(result).toEqual({}); + }); + + it('should return empty object when body is undefined', () => { + const result = getToolArguments(undefined); + expect(result).toEqual({}); + }); + + it('should handle nested objects in arguments', () => { + const body = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'complex-tool', + arguments: { + config: { + nested: { + value: 'test', + }, + }, + array: [1, 2, 3], + boolean: false, + }, + }, + id: 1, + }; + + const result = getToolArguments(body); + expect(result).toEqual({ + config: { + nested: { + value: 'test', + }, + }, + array: [1, 2, 3], + boolean: false, + }); + }); + }); +}); diff --git a/packages/cli/src/modules/mcp/mcp-api-key.service.ts b/packages/cli/src/modules/mcp/mcp-api-key.service.ts index b3001b4ae69..27387c5b7fa 100644 --- a/packages/cli/src/modules/mcp/mcp-api-key.service.ts +++ b/packages/cli/src/modules/mcp/mcp-api-key.service.ts @@ -5,8 +5,12 @@ import { randomUUID } from 'crypto'; import { NextFunction, Response, Request } from 'express'; import { ApiKeyAudience } from 'n8n-workflow'; +import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants'; +import { getClientInfo } from './mcp.utils'; + import { AuthError } from '@/errors/response-errors/auth.error'; import { JwtService } from '@/services/jwt.service'; +import { Telemetry } from '@/telemetry'; const API_KEY_AUDIENCE: ApiKeyAudience = 'mcp-server-api'; const API_KEY_ISSUER = 'n8n'; @@ -24,6 +28,7 @@ export class McpServerApiKeyService { private readonly apiKeyRepository: ApiKeyRepository, private readonly jwtService: JwtService, private readonly userRepository: UserRepository, + private readonly telemetry: Telemetry, ) {} async createMcpServerApiKey(user: User, trx?: EntityManager) { @@ -114,21 +119,21 @@ export class McpServerApiKeyService { const authorizationHeader = req.header('authorization'); if (!authorizationHeader) { - this.responseWithUnauthorized(res); + this.responseWithUnauthorized(res, req); return; } const apiKey = this.extractAPIKeyFromHeader(authorizationHeader); if (!apiKey) { - this.responseWithUnauthorized(res); + this.responseWithUnauthorized(res, req); return; } const user = await this.getUserForApiKey(apiKey); if (!user) { - this.responseWithUnauthorized(res); + this.responseWithUnauthorized(res, req); return; } @@ -138,7 +143,7 @@ export class McpServerApiKeyService { audience: API_KEY_AUDIENCE, }); } catch (e) { - this.responseWithUnauthorized(res); + this.responseWithUnauthorized(res, req); return; } @@ -148,8 +153,19 @@ export class McpServerApiKeyService { }; } - private responseWithUnauthorized(res: Response) { - res.status(401).send({ message: 'Unauthorized' }); + private responseWithUnauthorized(res: Response, req: Request) { + this.trackUnauthorizedEvent(req); + res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE }); + } + + private trackUnauthorizedEvent(req: Request) { + const clientInfo = getClientInfo(req); + this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, { + mcp_connection_status: 'error', + error: UNAUTHORIZED_ERROR_MESSAGE, + client_name: clientInfo?.name, + client_version: clientInfo?.version, + }); } async getOrCreateApiKey(user: User) { diff --git a/packages/cli/src/modules/mcp/mcp.constants.ts b/packages/cli/src/modules/mcp/mcp.constants.ts new file mode 100644 index 00000000000..a1e89cbad84 --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.constants.ts @@ -0,0 +1,6 @@ +export const USER_CONNECTED_TO_MCP_EVENT = 'User connected to MCP server'; +export const USER_CALLED_MCP_TOOL_EVENT = 'User called mcp tool'; + +export const UNAUTHORIZED_ERROR_MESSAGE = 'Unauthorized'; +export const INTERNAL_SERVER_ERROR_MESSAGE = 'Internal server error'; +export const MCP_ACCESS_DISABLED_ERROR_MESSAGE = 'MCP access is disabled'; diff --git a/packages/cli/src/modules/mcp/mcp.controller.ts b/packages/cli/src/modules/mcp/mcp.controller.ts index a6e915a6853..6594f0b7d9a 100644 --- a/packages/cli/src/modules/mcp/mcp.controller.ts +++ b/packages/cli/src/modules/mcp/mcp.controller.ts @@ -6,8 +6,19 @@ import type { Response } from 'express'; import { ErrorReporter } from 'n8n-core'; import { McpServerApiKeyService } from './mcp-api-key.service'; +import { + USER_CONNECTED_TO_MCP_EVENT, + USER_CALLED_MCP_TOOL_EVENT, + MCP_ACCESS_DISABLED_ERROR_MESSAGE, + INTERNAL_SERVER_ERROR_MESSAGE, +} from './mcp.constants'; import { McpService } from './mcp.service'; import { McpSettingsService } from './mcp.settings.service'; +import { isJSONRPCRequest } from './mcp.typeguards'; +import type { UserConnectedToMCPEventPayload, UserCalledMCPToolEventPayload } from './mcp.types'; +import { getClientInfo, getToolName, getToolArguments } from './mcp.utils'; + +import { Telemetry } from '@/telemetry'; export type FlushableResponse = Response & { flush: () => void }; @@ -19,6 +30,7 @@ export class McpController { private readonly errorReporter: ErrorReporter, private readonly mcpService: McpService, private readonly mcpSettingsService: McpSettingsService, + private readonly telemetry: Telemetry, ) {} @Post('/http', { @@ -28,10 +40,29 @@ export class McpController { usesTemplates: true, }) async build(req: AuthenticatedRequest, res: FlushableResponse) { + const body = req.body; + const isInitializationRequest = isJSONRPCRequest(body) ? body.method === 'initialize' : false; + const isToolCallRequest = isJSONRPCRequest(body) ? body.method === 'tools/call' : false; + const clientInfo = getClientInfo(req); + + const telemetryPayload: Partial = { + user_id: req.user.id, + client_name: clientInfo?.name, + client_version: clientInfo?.version, + }; + // Deny if MCP access is disabled const enabled = await this.mcpSettingsService.getEnabled(); if (!enabled) { - res.status(403).json({ message: 'MCP access is disabled' }); + if (isInitializationRequest) { + this.trackMCPEvent('connected', { + ...telemetryPayload, + mcp_connection_status: 'error', + error: MCP_ACCESS_DISABLED_ERROR_MESSAGE, + }); + } + // Return 403 Forbidden + res.status(403).json({ message: MCP_ACCESS_DISABLED_ERROR_MESSAGE }); return; } // In stateless mode, create a new instance of transport and server for each request @@ -48,18 +79,51 @@ export class McpController { }); await server.connect(transport); await transport.handleRequest(req, res, req.body); + if (isInitializationRequest) { + this.trackMCPEvent('connected', { + ...telemetryPayload, + mcp_connection_status: 'success', + }); + } else if (isToolCallRequest) { + const toolName = getToolName(body); + const parameters = getToolArguments(body); + this.trackMCPEvent('tool_call', { + user_id: req.user.id, + tool_name: toolName, + parameters, + }); + } } catch (error) { this.errorReporter.error(error); + if (isInitializationRequest) { + this.trackMCPEvent('connected', { + ...telemetryPayload, + mcp_connection_status: 'error', + error: error instanceof Error ? error.message : String(error), + }); + } + // Return JSON-RPC error response if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, - message: 'Internal server error', + message: INTERNAL_SERVER_ERROR_MESSAGE, }, id: null, }); } } } + + private trackMCPEvent( + type: 'connected' | 'tool_call', + payload: UserConnectedToMCPEventPayload | UserCalledMCPToolEventPayload, + ) { + if (type === 'connected') { + this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, payload); + } else if (type === 'tool_call') { + this.telemetry.track(USER_CALLED_MCP_TOOL_EVENT, payload); + } + } } diff --git a/packages/cli/src/modules/mcp/mcp.typeguards.ts b/packages/cli/src/modules/mcp/mcp.typeguards.ts index d01e74f3f22..4677b6d0af7 100644 --- a/packages/cli/src/modules/mcp/mcp.typeguards.ts +++ b/packages/cli/src/modules/mcp/mcp.typeguards.ts @@ -1,11 +1,9 @@ // Inferred typing for CredentialsService.getOne() is a bit too broad, so we need custom type guards // to ensure that the decrypted data has the expected structure without changing the service code. -type UnknownRecord = Record; +import type { JSONRPCRequest } from './mcp.types'; -function isRecord(value: unknown): value is UnknownRecord { - return typeof value === 'object' && value !== null; -} +type UnknownRecord = Record; export type HttpHeaderAuthDecryptedData = { name: string; @@ -14,6 +12,10 @@ export type HttpHeaderAuthDecryptedData = { export type WithDecryptedData = UnknownRecord & { data: T }; +export function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + /** * Narrows down credentials to those that have decrypted data of type HttpHeaderAuthDecryptedData. * @param value - The value to check. @@ -64,3 +66,29 @@ export function hasJwtPemKeyDecryptedData( if (typeof data.keyType === 'string' && data.keyType === 'pemKey') return true; return typeof data.privateKey === 'string' || typeof data.publicKey === 'string'; } + +// JSON-RPC request type guards for MCP +/** + * Type guard to check if a value is a JSON-RPC request + * @param value - The value to check + * @returns True if the value matches the JSONRPCRequest structure + */ +export function isJSONRPCRequest(value: unknown): value is JSONRPCRequest { + if (!isRecord(value)) return false; + + if ('jsonrpc' in value && typeof value.jsonrpc !== 'string') return false; + + if ('method' in value && typeof value.method !== 'string') return false; + + if ('params' in value && value.params !== undefined && !isRecord(value.params)) return false; + + if ( + 'id' in value && + value.id !== null && + typeof value.id !== 'string' && + typeof value.id !== 'number' + ) + return false; + + return true; +} diff --git a/packages/cli/src/modules/mcp/mcp.types.ts b/packages/cli/src/modules/mcp/mcp.types.ts index 53d3af5bd37..190d8eab03b 100644 --- a/packages/cli/src/modules/mcp/mcp.types.ts +++ b/packages/cli/src/modules/mcp/mcp.types.ts @@ -39,3 +39,32 @@ export type SearchWorkflowsResult = { export type WorkflowDetailsResult = z.infer; export type WorkflowDetailsWorkflow = WorkflowDetailsResult['workflow']; export type WorkflowDetailsNode = WorkflowDetailsWorkflow['nodes'][number]; + +// JSON-RPC types for MCP protocol +export type JSONRPCRequest = { + jsonrpc?: string; + method?: string; + params?: { + clientInfo?: { + name?: string; + version?: string; + }; + [key: string]: unknown; + }; + id?: string | number | null; +}; + +// Telemetry payloads +export type UserConnectedToMCPEventPayload = { + user_id?: string; + client_name?: string; + client_version?: string; + mcp_connection_status: 'success' | 'error'; + error?: string; +}; + +export type UserCalledMCPToolEventPayload = { + user_id?: string; + tool_name: string; + parameters?: Record; +}; diff --git a/packages/cli/src/modules/mcp/mcp.utils.ts b/packages/cli/src/modules/mcp/mcp.utils.ts new file mode 100644 index 00000000000..abe0d8322be --- /dev/null +++ b/packages/cli/src/modules/mcp/mcp.utils.ts @@ -0,0 +1,46 @@ +import type { AuthenticatedRequest } from '@n8n/db'; +import type { Request } from 'express'; + +import { isRecord, isJSONRPCRequest } from './mcp.typeguards'; + +export const getClientInfo = (req: Request | AuthenticatedRequest) => { + let clientInfo: { name?: string; version?: string } | undefined; + if (isJSONRPCRequest(req.body) && req.body.params?.clientInfo) { + clientInfo = req.body.params.clientInfo; + } + return clientInfo; +}; + +/** + * Safely extracts the tool name from a JSON-RPC request + * @param body - The request body to extract tool name from + * @returns The tool name if valid, 'unknown' otherwise + */ +export const getToolName = (body: unknown): string => { + if (!isJSONRPCRequest(body)) return 'unknown'; + if (!body.params) return 'unknown'; + + const { name } = body.params; + if (typeof name === 'string') { + return name; + } + + return 'unknown'; +}; + +/** + * Safely extracts tool arguments from a JSON-RPC request + * @param body - The request body to extract arguments from + * @returns The arguments object if valid, empty object otherwise + */ +export const getToolArguments = (body: unknown): Record => { + if (!isJSONRPCRequest(body)) return {}; + if (!body.params) return {}; + + const { arguments: args } = body.params; + if (isRecord(args)) { + return args; + } + + return {}; +}; diff --git a/packages/frontend/editor-ui/src/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/components/WorkflowCard.vue index 92fd984ed35..425dc759c8c 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowCard.vue @@ -35,6 +35,7 @@ import { N8nTooltip, } from '@n8n/design-system'; import { useMCPStore } from '@/features/mcpAccess/mcp.store'; +import { useMcp } from '@/features/mcpAccess/composables/useMcp'; const WORKFLOW_LIST_ITEM_ACTIONS = { OPEN: 'open', SHARE: 'share', @@ -91,6 +92,7 @@ const locale = useI18n(); const router = useRouter(); const route = useRoute(); const telemetry = useTelemetry(); +const mcp = useMcp(); const uiStore = useUIStore(); const usersStore = useUsersStore(); @@ -332,6 +334,7 @@ async function toggleMCPAccess(enabled: boolean) { try { await mcpStore.toggleWorkflowMcpAccess(props.data.id, enabled); mcpToggleStatus.value = enabled; + mcp.trackMcpAccessEnabledForWorkflow(props.data.id); } catch (error) { toast.showError(error, locale.baseText('workflowSettings.toggleMCP.error.title')); return; diff --git a/packages/frontend/editor-ui/src/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/components/WorkflowSettings.vue index eb89b716130..54220319f03 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowSettings.vue @@ -35,7 +35,7 @@ const externalHooks = useExternalHooks(); const toast = useToast(); const modalBus = createEventBus(); const telemetry = useTelemetry(); -const { isEligibleForMcpAccess } = useMcp(); +const { isEligibleForMcpAccess, trackMcpAccessEnabledForWorkflow } = useMcp(); const rootStore = useRootStore(); const settingsStore = useSettingsStore(); @@ -392,6 +392,10 @@ const saveSettings = async () => { time_saved: workflowSettings.value.timeSavedPerExecution ?? '', error_workflow: workflowSettings.value.errorWorkflow ?? '', }); + + if (isMCPEnabled.value && workflowSettings.value.availableInMCP) { + trackMcpAccessEnabledForWorkflow(workflowId.value); + } }; const toggleTimeout = () => { diff --git a/packages/frontend/editor-ui/src/features/mcpAccess/SettingsMCPView.vue b/packages/frontend/editor-ui/src/features/mcpAccess/SettingsMCPView.vue index aa3e3a5196e..3820edf1af2 100644 --- a/packages/frontend/editor-ui/src/features/mcpAccess/SettingsMCPView.vue +++ b/packages/frontend/editor-ui/src/features/mcpAccess/SettingsMCPView.vue @@ -28,9 +28,11 @@ import { N8nText, N8nTooltip, } from '@n8n/design-system'; +import { useMcp } from './composables/useMcp'; const i18n = useI18n(); const toast = useToast(); const documentTitle = useDocumentTitle(); +const mcp = useMcp(); const workflowsStore = useWorkflowsStore(); const mcpStore = useMCPStore(); @@ -139,6 +141,7 @@ const onUpdateMCPEnabled = async (value: string | number | boolean) => { } else { workflowsLoading.value = false; } + mcp.trackUserToggledMcpAccess(boolValue); } catch (error) { toast.showError(error, i18n.baseText('settings.mcp.toggle.error')); } finally { diff --git a/packages/frontend/editor-ui/src/features/mcpAccess/composables/useMcp.ts b/packages/frontend/editor-ui/src/features/mcpAccess/composables/useMcp.ts index 0f245f86be8..ac7c3fcb7e1 100644 --- a/packages/frontend/editor-ui/src/features/mcpAccess/composables/useMcp.ts +++ b/packages/frontend/editor-ui/src/features/mcpAccess/composables/useMcp.ts @@ -1,7 +1,10 @@ import { WEBHOOK_NODE_TYPE } from '@/constants'; import type { IWorkflowDb } from '@/Interface'; +import { useTelemetry } from '@/composables/useTelemetry'; export function useMcp() { + const telemetry = useTelemetry(); + /** * Checks if MCP access can be enabled for the given workflow. * A workflow is eligible if it is active and has at least one enabled webhook trigger. @@ -14,7 +17,17 @@ export function useMcp() { return workflow.nodes.some((node) => node.type === WEBHOOK_NODE_TYPE && node.disabled !== true); }; + const trackMcpAccessEnabledForWorkflow = (workflowId: string) => { + telemetry.track('User gave MCP access to workflow', { workflow_id: workflowId }); + }; + + const trackUserToggledMcpAccess = (enabled: boolean) => { + telemetry.track('User toggled MCP access', { state: enabled }); + }; + return { isEligibleForMcpAccess, + trackMcpAccessEnabledForWorkflow, + trackUserToggledMcpAccess, }; }