diff --git a/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts b/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts index 38ea55652f..4878975589 100644 --- a/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts +++ b/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts @@ -6,6 +6,7 @@ import { STORES } from '@n8n/stores'; import userEvent from '@testing-library/user-event'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore'; +import { useProjectsStore } from '@/stores/projects.store'; import { useRouter } from 'vue-router'; import type { Workflow } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; @@ -13,6 +14,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { nextTick } from 'vue'; import { mock } from 'vitest-mock-extended'; import { createTestWorkflow } from '@/__tests__/mocks'; +import { type MockedStore, mockedStore } from '@/__tests__/utils'; const ModalStub = { template: ` @@ -43,7 +45,9 @@ const mockMcpNode = { id: 'id1', name: 'Test MCP Node', type: AI_MCP_TOOL_NODE_TYPE, + typeVersion: 1, parameters: {}, + credentials: undefined, }; const mockParentNode = { @@ -95,6 +99,7 @@ let pinia: ReturnType; let workflowsStore: ReturnType; let agentRequestStore: ReturnType; let nodeTypesStore: ReturnType; +let projectsStore: MockedStore; describe('FromAiParametersModal', () => { beforeEach(() => { @@ -135,6 +140,8 @@ describe('FromAiParametersModal', () => { agentRequestStore.getAgentRequest = vi.fn(); nodeTypesStore = useNodeTypesStore(); nodeTypesStore.getNodeParameterOptions = vi.fn().mockResolvedValue(mockTools); + projectsStore = mockedStore(useProjectsStore); + projectsStore.currentProjectId = 'test-project-id'; }); it('renders correctly with node data', () => { @@ -282,4 +289,89 @@ describe('FromAiParametersModal', () => { }, }); }); + + it('passes credentials and projectId to MCP tool loading', async () => { + renderModal({ + props: { + modalName: FROM_AI_PARAMETERS_MODAL_KEY, + data: { + nodeName: 'Test MCP Node', + }, + }, + global: { + stubs: { + Modal: ModalStub, + }, + }, + pinia, + }); + + await nextTick(); + + expect(nodeTypesStore.getNodeParameterOptions).toHaveBeenCalledWith({ + nodeTypeAndVersion: { + name: AI_MCP_TOOL_NODE_TYPE, + version: 1, + }, + path: 'parameters.includedTools', + methodName: 'getTools', + currentNodeParameters: {}, + credentials: undefined, + projectId: 'test-project-id', + }); + }); + + describe('Error handling for MCP requests', () => { + it('displays error message when MCP tool loading fails', async () => { + const errorMessage = 'Failed to load MCP tools'; + nodeTypesStore.getNodeParameterOptions = vi.fn().mockRejectedValue(new Error(errorMessage)); + + const { findByText, queryByRole, queryByTestId } = renderModal({ + props: { + modalName: FROM_AI_PARAMETERS_MODAL_KEY, + data: { + nodeName: 'Test MCP Node', + }, + }, + global: { + stubs: { + Modal: ModalStub, + }, + }, + pinia, + }); + + const errorCallout = await findByText(errorMessage); + expect(errorCallout).toBeTruthy(); + + // Should not show the form inputs when error occurs + const toolSelect = queryByRole('combobox'); + expect(toolSelect).toBeNull(); + + const executeButton = queryByTestId('execute-workflow-button'); + expect(executeButton).toBeNull(); + }); + + it('displays generic error message for unknown errors', async () => { + nodeTypesStore.getNodeParameterOptions = vi.fn().mockRejectedValue('String error'); + + const { findByText } = renderModal({ + props: { + modalName: FROM_AI_PARAMETERS_MODAL_KEY, + data: { + nodeName: 'Test MCP Node', + }, + }, + global: { + stubs: { + Modal: ModalStub, + }, + }, + pinia, + }); + + const errorCallout = await findByText('Unknown error occurred'); + expect(errorCallout).toBeTruthy(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue b/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue index 4362043df1..1fb32fe3c7 100644 --- a/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue +++ b/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue @@ -18,6 +18,8 @@ import { useTelemetry } from '@/composables/useTelemetry'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { type JSONSchema7 } from 'json-schema'; +import { useProjectsStore } from '@/stores/projects.store'; +import type { INode } from 'n8n-workflow'; type Value = string | number | boolean | null | undefined; @@ -38,6 +40,7 @@ const nodeTypesStore = useNodeTypesStore(); const router = useRouter(); const { runWorkflow } = useRunWorkflow({ router }); const agentRequestStore = useAgentRequestStore(); +const projectsStore = useProjectsStore(); const node = computed(() => props.data.nodeName ? workflowsStore.getNodeByName(props.data.nodeName) : undefined, @@ -52,6 +55,7 @@ const parentNode = computed(() => { const parameters = ref([]); const selectedTool = ref(''); +const error = ref(undefined); const nodeRunData = computed(() => { if (!node.value) return undefined; @@ -86,9 +90,71 @@ const mapTypes: { }, }; +const getMCPTools = async (newNode: INode, newSelectedTool: string): Promise => { + const result: IFormInput[] = []; + + const tools = await nodeTypesStore.getNodeParameterOptions({ + nodeTypeAndVersion: { + name: newNode.type, + version: newNode.typeVersion, + }, + path: 'parameters.includedTools', + methodName: 'getTools', + currentNodeParameters: newNode.parameters, + credentials: newNode.credentials, + projectId: projectsStore.currentProjectId, + }); + + // Load available tools + const toolOptions = tools?.map((tool) => ({ + label: tool.name, + value: String(tool.value), + disabled: false, + })); + + result.push({ + name: 'toolName', + initialValue: '', + properties: { + label: 'Tool name', + type: 'select', + options: toolOptions, + required: true, + }, + }); + + // Only show parameters for selected tool + if (newSelectedTool) { + const selectedToolData = tools?.find((tool) => String(tool.value) === newSelectedTool); + const schema = selectedToolData?.inputSchema as JSONSchema7; + if (schema.properties) { + for (const [propertyName, value] of Object.entries(schema.properties)) { + const type = + typeof value === 'object' && 'type' in value && typeof value.type === 'string' + ? value.type + : 'text'; + + result.push({ + name: 'query.' + propertyName, + initialValue: '', + properties: { + label: propertyName, + type: mapTypes[type].inputType, + required: true, + }, + }); + } + } + } + + return result; +}; + watch( [node, selectedTool], async ([newNode, newSelectedTool]) => { + error.value = undefined; + if (!newNode) { parameters.value = []; return; @@ -98,59 +164,14 @@ watch( // Handle MCPClientTool nodes differently if (newNode.type === AI_MCP_TOOL_NODE_TYPE) { - const tools = await nodeTypesStore.getNodeParameterOptions({ - nodeTypeAndVersion: { - name: newNode.type, - version: newNode.typeVersion, - }, - path: 'parmeters.includedTools', - methodName: 'getTools', - currentNodeParameters: newNode.parameters, - }); + try { + const mcpResult = await getMCPTools(newNode, newSelectedTool); + parameters.value = mcpResult; - // Load available tools - const toolOptions = tools?.map((tool) => ({ - label: tool.name, - value: String(tool.value), - disabled: false, - })); - - result.push({ - name: 'toolName', - initialValue: '', - properties: { - label: 'Tool name', - type: 'select', - options: toolOptions, - required: true, - }, - }); - - // Only show parameters for selected tool - if (newSelectedTool) { - const selectedToolData = tools?.find((tool) => String(tool.value) === newSelectedTool); - const schema = selectedToolData?.inputSchema as JSONSchema7; - if (schema.properties) { - for (const [propertyName, value] of Object.entries(schema.properties)) { - const typedValue = value as { - type: string; - description: string; - }; - - result.push({ - name: 'query.' + propertyName, - initialValue: '', - properties: { - label: propertyName, - type: mapTypes[typedValue.type ?? 'text'].inputType, - required: true, - }, - }); - } - } + return; + } catch (e: unknown) { + error.value = e instanceof Error ? e : new Error('Unknown error occurred'); } - - parameters.value = result; } // Handle regular tool nodes @@ -267,7 +288,12 @@ const onUpdate = (change: FormFieldValueUpdate) => { :center="true" :close-on-click-modal="false" > -