mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
feat(core): Add telemetry to MCP (no-changelog) (#20797)
This commit is contained in:
parent
dcfdc88f65
commit
3f673fd037
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
354
packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts
Normal file
354
packages/cli/src/modules/mcp/__tests__/mcp.utils.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
6
packages/cli/src/modules/mcp/mcp.constants.ts
Normal file
6
packages/cli/src/modules/mcp/mcp.constants.ts
Normal 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';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
46
packages/cli/src/modules/mcp/mcp.utils.ts
Normal file
46
packages/cli/src/modules/mcp/mcp.utils.ts
Normal 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 {};
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user