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 { 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<typeof createTestingPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let agentRequestStore: ReturnType<typeof useAgentRequestStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let projectsStore: MockedStore<typeof useProjectsStore>;
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();
});
});
});

View File

@@ -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<IFormInput[]>([]);
const selectedTool = ref<string>('');
const error = ref<Error | undefined>(undefined);
const nodeRunData = computed(() => {
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(
[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"
>
<template #content>
<template v-if="error" #content>
<N8nCallout v-if="error" theme="danger">
{{ error.message }}
</N8nCallout>
</template>
<template v-else #content>
<el-col>
<el-row :class="$style.row">
<n8n-text data-testid="from-ai-parameters-modal-description">
@@ -292,7 +318,7 @@ const onUpdate = (change: FormFieldValueUpdate) => {
</el-row>
</el-col>
</template>
<template #footer>
<template v-if="!error" #footer>
<el-row justify="end">
<el-col :span="5" :offset="19">
<n8n-button