fix(editor): Handle connection errors and credentials better in MCP Client Tool when executing directly (#19239)

This commit is contained in:
Mutasem Aldmour
2025-09-05 16:37:10 +02:00
committed by GitHub
parent 188a013ae0
commit 97d0eddd0e
2 changed files with 171 additions and 53 deletions

View File

@@ -6,6 +6,7 @@ import { STORES } from '@n8n/stores';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore'; import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
import { useProjectsStore } from '@/stores/projects.store';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { Workflow } from 'n8n-workflow'; import type { Workflow } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow';
@@ -13,6 +14,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { createTestWorkflow } from '@/__tests__/mocks'; import { createTestWorkflow } from '@/__tests__/mocks';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
const ModalStub = { const ModalStub = {
template: ` template: `
@@ -43,7 +45,9 @@ const mockMcpNode = {
id: 'id1', id: 'id1',
name: 'Test MCP Node', name: 'Test MCP Node',
type: AI_MCP_TOOL_NODE_TYPE, type: AI_MCP_TOOL_NODE_TYPE,
typeVersion: 1,
parameters: {}, parameters: {},
credentials: undefined,
}; };
const mockParentNode = { const mockParentNode = {
@@ -95,6 +99,7 @@ let pinia: ReturnType<typeof createTestingPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>; let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let agentRequestStore: ReturnType<typeof useAgentRequestStore>; let agentRequestStore: ReturnType<typeof useAgentRequestStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>; let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let projectsStore: MockedStore<typeof useProjectsStore>;
describe('FromAiParametersModal', () => { describe('FromAiParametersModal', () => {
beforeEach(() => { beforeEach(() => {
@@ -135,6 +140,8 @@ describe('FromAiParametersModal', () => {
agentRequestStore.getAgentRequest = vi.fn(); agentRequestStore.getAgentRequest = vi.fn();
nodeTypesStore = useNodeTypesStore(); nodeTypesStore = useNodeTypesStore();
nodeTypesStore.getNodeParameterOptions = vi.fn().mockResolvedValue(mockTools); nodeTypesStore.getNodeParameterOptions = vi.fn().mockResolvedValue(mockTools);
projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProjectId = 'test-project-id';
}); });
it('renders correctly with node data', () => { 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();
});
});
}); });

View File

@@ -18,6 +18,8 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { type JSONSchema7 } from 'json-schema'; 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; type Value = string | number | boolean | null | undefined;
@@ -38,6 +40,7 @@ const nodeTypesStore = useNodeTypesStore();
const router = useRouter(); const router = useRouter();
const { runWorkflow } = useRunWorkflow({ router }); const { runWorkflow } = useRunWorkflow({ router });
const agentRequestStore = useAgentRequestStore(); const agentRequestStore = useAgentRequestStore();
const projectsStore = useProjectsStore();
const node = computed(() => const node = computed(() =>
props.data.nodeName ? workflowsStore.getNodeByName(props.data.nodeName) : undefined, props.data.nodeName ? workflowsStore.getNodeByName(props.data.nodeName) : undefined,
@@ -52,6 +55,7 @@ const parentNode = computed(() => {
const parameters = ref<IFormInput[]>([]); const parameters = ref<IFormInput[]>([]);
const selectedTool = ref<string>(''); const selectedTool = ref<string>('');
const error = ref<Error | undefined>(undefined);
const nodeRunData = computed(() => { const nodeRunData = computed(() => {
if (!node.value) return undefined; if (!node.value) return undefined;
@@ -86,9 +90,71 @@ const mapTypes: {
}, },
}; };
const getMCPTools = async (newNode: INode, newSelectedTool: string): Promise<IFormInput[]> => {
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( watch(
[node, selectedTool], [node, selectedTool],
async ([newNode, newSelectedTool]) => { async ([newNode, newSelectedTool]) => {
error.value = undefined;
if (!newNode) { if (!newNode) {
parameters.value = []; parameters.value = [];
return; return;
@@ -98,59 +164,14 @@ watch(
// Handle MCPClientTool nodes differently // Handle MCPClientTool nodes differently
if (newNode.type === AI_MCP_TOOL_NODE_TYPE) { if (newNode.type === AI_MCP_TOOL_NODE_TYPE) {
const tools = await nodeTypesStore.getNodeParameterOptions({ try {
nodeTypeAndVersion: { const mcpResult = await getMCPTools(newNode, newSelectedTool);
name: newNode.type, parameters.value = mcpResult;
version: newNode.typeVersion,
},
path: 'parmeters.includedTools',
methodName: 'getTools',
currentNodeParameters: newNode.parameters,
});
// Load available tools return;
const toolOptions = tools?.map((tool) => ({ } catch (e: unknown) {
label: tool.name, error.value = e instanceof Error ? e : new Error('Unknown error occurred');
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,
},
});
}
}
} }
parameters.value = result;
} }
// Handle regular tool nodes // Handle regular tool nodes
@@ -267,7 +288,12 @@ const onUpdate = (change: FormFieldValueUpdate) => {
:center="true" :center="true"
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<template #content> <template v-if="error" #content>
<N8nCallout v-if="error" theme="danger">
{{ error.message }}
</N8nCallout>
</template>
<template v-else #content>
<el-col> <el-col>
<el-row :class="$style.row"> <el-row :class="$style.row">
<n8n-text data-testid="from-ai-parameters-modal-description"> <n8n-text data-testid="from-ai-parameters-modal-description">
@@ -292,7 +318,7 @@ const onUpdate = (change: FormFieldValueUpdate) => {
</el-row> </el-row>
</el-col> </el-col>
</template> </template>
<template #footer> <template v-if="!error" #footer>
<el-row justify="end"> <el-row justify="end">
<el-col :span="5" :offset="19"> <el-col :span="5" :offset="19">
<n8n-button <n8n-button