feat(core): Add telemetry to MCP (no-changelog) (#20797)

This commit is contained in:
Milorad FIlipović 2025-10-15 13:43:30 +02:00 committed by GitHub
parent dcfdc88f65
commit 3f673fd037
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 583 additions and 13 deletions

View File

@ -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<UserRepository>;
let apiKeyRepository: jest.Mocked<ApiKeyRepository>;
let telemetry: jest.Mocked<Telemetry>;
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,
);
});

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>;
import type { JSONRPCRequest } from './mcp.types';
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null;
}
type UnknownRecord = Record<string, unknown>;
export type HttpHeaderAuthDecryptedData = {
name: string;
@ -14,6 +12,10 @@ export type HttpHeaderAuthDecryptedData = {
export type WithDecryptedData<T> = 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;
}

View File

@ -39,3 +39,32 @@ export type SearchWorkflowsResult = {
export type WorkflowDetailsResult = z.infer<WorkflowDetailsOutputSchema>;
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<string, unknown>;
};

View File

@ -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<string, unknown> => {
if (!isJSONRPCRequest(body)) return {};
if (!body.params) return {};
const { arguments: args } = body.params;
if (isRecord(args)) {
return args;
}
return {};
};

View File

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

View File

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

View File

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

View File

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