@n8n/frontend-module-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +88 -0
- package/README.md +51 -0
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +72 -0
- package/src/SettingsMCPAgentsView.test.ts +256 -0
- package/src/SettingsMCPAgentsView.vue +223 -0
- package/src/SettingsMCPClientsView.test.ts +280 -0
- package/src/SettingsMCPClientsView.vue +168 -0
- package/src/SettingsMCPView.test.ts +832 -0
- package/src/SettingsMCPView.vue +496 -0
- package/src/SettingsMCPWorkflowsView.test.ts +274 -0
- package/src/SettingsMCPWorkflowsView.vue +245 -0
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/assets/client-icons/claude.svg +1 -0
- package/src/assets/client-icons/codex.svg +1 -0
- package/src/assets/client-icons/cursor.svg +1 -0
- package/src/assets/client-icons/gemini.svg +1 -0
- package/src/assets/client-icons/mistral.svg +1 -0
- package/src/assets/client-icons/openai.svg +3 -0
- package/src/assets/client-icons/vscode.svg +1 -0
- package/src/clients.utils.test.ts +85 -0
- package/src/clients.utils.ts +124 -0
- package/src/components/ConnectionParameter.vue +213 -0
- package/src/components/MCPAgentsSelect.vue +176 -0
- package/src/components/MCPEmptyState.vue +75 -0
- package/src/components/MCPWorkflowsSelect.test.ts +384 -0
- package/src/components/MCPWorkflowsSelect.vue +159 -0
- package/src/components/McpAccessToggle.vue +77 -0
- package/src/components/McpAccessTokenTab.test.ts +220 -0
- package/src/components/McpAccessTokenTab.vue +201 -0
- package/src/components/McpAllowedCallbackUrlsDialog.test.ts +122 -0
- package/src/components/McpAllowedCallbackUrlsDialog.vue +359 -0
- package/src/components/McpClientLogoCards.vue +134 -0
- package/src/components/McpConfigSnippet.vue +92 -0
- package/src/components/McpConnectClientDialog.test.ts +115 -0
- package/src/components/McpConnectClientDialog.vue +432 -0
- package/src/components/McpConnectedClientRow.test.ts +86 -0
- package/src/components/McpConnectedClientRow.vue +152 -0
- package/src/components/McpEmptyStateCard.vue +83 -0
- package/src/components/McpStatusControl.test.ts +33 -0
- package/src/components/McpStatusControl.vue +96 -0
- package/src/components/OAuthClientDetailsModal.vue +182 -0
- package/src/components/RevokeOAuthClientConfirmModal.vue +60 -0
- package/src/components/WorkflowLocation.test.ts +329 -0
- package/src/components/WorkflowLocation.vue +198 -0
- package/src/components/tabs/AgentsTable.vue +312 -0
- package/src/components/tabs/OAuthClientOwnerCell.vue +71 -0
- package/src/components/tabs/OAuthClientsFilters.test.ts +86 -0
- package/src/components/tabs/OAuthClientsFilters.vue +201 -0
- package/src/components/tabs/OAuthClientsTable.test.ts +394 -0
- package/src/components/tabs/OAuthClientsTable.vue +473 -0
- package/src/components/tabs/WorkflowsTable.test.ts +661 -0
- package/src/components/tabs/WorkflowsTable.vue +398 -0
- package/src/composables/useMcp.test.ts +59 -0
- package/src/composables/useMcp.ts +61 -0
- package/src/composables/useOAuthClientRevoke.ts +90 -0
- package/src/index.ts +10 -0
- package/src/mcp.api.test.ts +81 -0
- package/src/mcp.api.ts +206 -0
- package/src/mcp.clients.catalog.test.ts +63 -0
- package/src/mcp.clients.catalog.ts +183 -0
- package/src/mcp.constants.ts +45 -0
- package/src/mcp.module.test.ts +106 -0
- package/src/mcp.module.ts +90 -0
- package/src/mcp.store.test.ts +628 -0
- package/src/mcp.store.ts +490 -0
- package/src/mcp.test.utils.ts +57 -0
- package/src/mcp.types.ts +20 -0
- package/src/modals/MCPConnectAgentsModal.test.ts +141 -0
- package/src/modals/MCPConnectAgentsModal.vue +122 -0
- package/src/modals/MCPConnectWorkflowsModal.test.ts +303 -0
- package/src/modals/MCPConnectWorkflowsModal.vue +101 -0
- package/src/redirect-uris.utils.ts +22 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useI18n } from '@n8n/i18n';
|
|
3
|
+
import MCPAgentsSelect from '../components/MCPAgentsSelect.vue';
|
|
4
|
+
import { N8nButton, N8nDialog, N8nDialogFooter, N8nNotice } from '@n8n/design-system';
|
|
5
|
+
import { computed, ref, watch } from 'vue';
|
|
6
|
+
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|
7
|
+
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
enableMcpAccess: (agentIds: string[]) => Promise<void>;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
const open = defineModel<boolean>('open', { default: false });
|
|
14
|
+
|
|
15
|
+
const i18n = useI18n();
|
|
16
|
+
const telemetry = useTelemetry();
|
|
17
|
+
|
|
18
|
+
const isSaving = ref(false);
|
|
19
|
+
const selectedAgentIds = ref<string[]>([]);
|
|
20
|
+
// typescript-eslint reads an SFC import as `any`, so the ref names the one method it calls.
|
|
21
|
+
const selectRef = ref<{ focusOnInput: () => void } | null>(null);
|
|
22
|
+
const closedByAction = ref(false);
|
|
23
|
+
|
|
24
|
+
const canSave = computed(() => selectedAgentIds.value.length > 0);
|
|
25
|
+
|
|
26
|
+
// The view keeps this component mounted, so every opening starts from an empty selection.
|
|
27
|
+
watch(open, (isOpen) => {
|
|
28
|
+
if (isOpen) {
|
|
29
|
+
selectedAgentIds.value = [];
|
|
30
|
+
closedByAction.value = false;
|
|
31
|
+
} else if (!closedByAction.value) {
|
|
32
|
+
telemetry.track(TELEMETRY_EVENT.AGENTS.USER_DISMISSED_MCP_AGENTS_DIALOG, {});
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function save() {
|
|
37
|
+
if (selectedAgentIds.value.length === 0) return;
|
|
38
|
+
|
|
39
|
+
isSaving.value = true;
|
|
40
|
+
try {
|
|
41
|
+
await props.enableMcpAccess(selectedAgentIds.value);
|
|
42
|
+
closedByAction.value = true;
|
|
43
|
+
telemetry.track(TELEMETRY_EVENT.AGENTS.USER_SELECTED_AGENTS_FOR_MCP, {
|
|
44
|
+
agentIds: selectedAgentIds.value,
|
|
45
|
+
count: selectedAgentIds.value.length,
|
|
46
|
+
});
|
|
47
|
+
open.value = false;
|
|
48
|
+
} finally {
|
|
49
|
+
isSaving.value = false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onSelectReady() {
|
|
54
|
+
selectRef.value?.focusOnInput();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function onConfirm() {
|
|
58
|
+
if (!isSaving.value) {
|
|
59
|
+
void save();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function preventOutsideClose(event: Event) {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<N8nDialog
|
|
70
|
+
v-model:open="open"
|
|
71
|
+
size="xlarge"
|
|
72
|
+
:header="i18n.baseText('settings.mcp.connectAgents.modalTitle')"
|
|
73
|
+
data-test-id="mcp-connect-agents-dialog"
|
|
74
|
+
@interact-outside="preventOutsideClose"
|
|
75
|
+
>
|
|
76
|
+
<div :class="$style.content">
|
|
77
|
+
<N8nNotice
|
|
78
|
+
data-test-id="mcp-connect-agents-info-notice"
|
|
79
|
+
theme="info"
|
|
80
|
+
:content="i18n.baseText('settings.mcp.connectAgents.notice')"
|
|
81
|
+
:class="$style.notice"
|
|
82
|
+
/>
|
|
83
|
+
<MCPAgentsSelect
|
|
84
|
+
ref="selectRef"
|
|
85
|
+
v-model="selectedAgentIds"
|
|
86
|
+
:placeholder="i18n.baseText('settings.mcp.connectAgents.input.placeholder')"
|
|
87
|
+
:disabled="isSaving"
|
|
88
|
+
@ready="onSelectReady"
|
|
89
|
+
@confirm="onConfirm"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<N8nDialogFooter>
|
|
93
|
+
<N8nButton
|
|
94
|
+
variant="subtle"
|
|
95
|
+
:label="i18n.baseText('generic.cancel')"
|
|
96
|
+
:disabled="isSaving"
|
|
97
|
+
data-test-id="mcp-connect-agents-cancel-button"
|
|
98
|
+
@click="open = false"
|
|
99
|
+
/>
|
|
100
|
+
<N8nButton
|
|
101
|
+
variant="solid"
|
|
102
|
+
:label="i18n.baseText('settings.mcp.connectAgents.confirm.label')"
|
|
103
|
+
:loading="isSaving"
|
|
104
|
+
:disabled="!canSave || isSaving"
|
|
105
|
+
data-test-id="mcp-connect-agents-save-button"
|
|
106
|
+
@click="save"
|
|
107
|
+
/>
|
|
108
|
+
</N8nDialogFooter>
|
|
109
|
+
</N8nDialog>
|
|
110
|
+
</template>
|
|
111
|
+
|
|
112
|
+
<style module lang="scss">
|
|
113
|
+
.content {
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
gap: var(--spacing--sm);
|
|
117
|
+
|
|
118
|
+
.notice {
|
|
119
|
+
margin: 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
</style>
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { nextTick } from 'vue';
|
|
2
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
3
|
+
import { waitFor } from '@testing-library/vue';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { createComponentRenderer, mockedStore, type MockedStore } from '@n8n/frontend-test-utils';
|
|
6
|
+
import MCPConnectWorkflowsModal from './MCPConnectWorkflowsModal.vue';
|
|
7
|
+
import { useMCPStore } from '../mcp.store';
|
|
8
|
+
import { createWorkflow } from '../mcp.test.utils';
|
|
9
|
+
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|
10
|
+
import { type Mock } from 'vitest';
|
|
11
|
+
|
|
12
|
+
vi.mock('@n8n/composables/useTelemetry', () => {
|
|
13
|
+
const track = vi.fn();
|
|
14
|
+
return {
|
|
15
|
+
useTelemetry: () => ({
|
|
16
|
+
track,
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock('vue-router', async (importOriginal) => ({
|
|
22
|
+
...(await importOriginal()),
|
|
23
|
+
useRouter: () => ({
|
|
24
|
+
resolve: vi.fn(({ name, params }) => {
|
|
25
|
+
if (name === 'NodeViewExisting') {
|
|
26
|
+
return { fullPath: `/workflows/${params.workflowId}` };
|
|
27
|
+
}
|
|
28
|
+
if (name === 'ProjectsWorkflows') {
|
|
29
|
+
return { fullPath: `/projects/${params.projectId}` };
|
|
30
|
+
}
|
|
31
|
+
return { fullPath: '/' };
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const renderModal = createComponentRenderer(MCPConnectWorkflowsModal);
|
|
37
|
+
|
|
38
|
+
const telemetry = useTelemetry();
|
|
39
|
+
|
|
40
|
+
let pinia: ReturnType<typeof createTestingPinia>;
|
|
41
|
+
let mcpStore: MockedStore<typeof useMCPStore>;
|
|
42
|
+
let mockOnEnableMcpAccess: Mock;
|
|
43
|
+
|
|
44
|
+
describe('MCPConnectWorkflowsModal', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
pinia = createTestingPinia();
|
|
47
|
+
mcpStore = mockedStore(useMCPStore);
|
|
48
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
49
|
+
count: 0,
|
|
50
|
+
data: [],
|
|
51
|
+
});
|
|
52
|
+
mockOnEnableMcpAccess = vi.fn().mockResolvedValue(undefined);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const createProps = () => ({
|
|
60
|
+
open: true,
|
|
61
|
+
enableMcpAccess: mockOnEnableMcpAccess,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('Initial rendering', () => {
|
|
65
|
+
it('should not load eligible workflows while closed', async () => {
|
|
66
|
+
const { queryByTestId } = renderModal({
|
|
67
|
+
pinia,
|
|
68
|
+
props: { ...createProps(), open: false },
|
|
69
|
+
});
|
|
70
|
+
await nextTick();
|
|
71
|
+
|
|
72
|
+
expect(queryByTestId('mcp-workflows-select')).not.toBeInTheDocument();
|
|
73
|
+
expect(mcpStore.getMcpEligibleWorkflows).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should render workflow select component', async () => {
|
|
77
|
+
const { getByTestId } = renderModal({
|
|
78
|
+
pinia,
|
|
79
|
+
props: createProps(),
|
|
80
|
+
});
|
|
81
|
+
await nextTick();
|
|
82
|
+
|
|
83
|
+
expect(getByTestId('mcp-workflows-select')).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should render cancel and save buttons', async () => {
|
|
87
|
+
const { getByTestId } = renderModal({
|
|
88
|
+
pinia,
|
|
89
|
+
props: createProps(),
|
|
90
|
+
});
|
|
91
|
+
await nextTick();
|
|
92
|
+
|
|
93
|
+
expect(getByTestId('mcp-connect-workflows-cancel-button')).toBeInTheDocument();
|
|
94
|
+
expect(getByTestId('mcp-connect-workflows-save-button')).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should have save button disabled when no workflow is selected', async () => {
|
|
98
|
+
const { getByTestId } = renderModal({
|
|
99
|
+
pinia,
|
|
100
|
+
props: createProps(),
|
|
101
|
+
});
|
|
102
|
+
await nextTick();
|
|
103
|
+
|
|
104
|
+
expect(getByTestId('mcp-connect-workflows-save-button')).toBeDisabled();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Workflow selection', () => {
|
|
109
|
+
it('should enable save button when workflow is selected', async () => {
|
|
110
|
+
const workflows = [createWorkflow({ id: 'wf-1', name: 'Test Workflow' })];
|
|
111
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
112
|
+
count: 1,
|
|
113
|
+
data: workflows,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const { getByTestId } = renderModal({
|
|
117
|
+
pinia,
|
|
118
|
+
props: createProps(),
|
|
119
|
+
});
|
|
120
|
+
await nextTick();
|
|
121
|
+
|
|
122
|
+
// Wait for workflows to load
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const select = getByTestId('mcp-workflows-select');
|
|
128
|
+
await userEvent.click(select);
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
const options = document.querySelectorAll('.el-select-dropdown__item');
|
|
132
|
+
expect(options.length).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const option = document.querySelector('.el-select-dropdown__item');
|
|
136
|
+
if (option) {
|
|
137
|
+
await userEvent.click(option);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(getByTestId('mcp-connect-workflows-save-button')).not.toBeDisabled();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('Save action', () => {
|
|
147
|
+
it('should call onEnableMcpAccess with selected workflow ids when save is clicked', async () => {
|
|
148
|
+
const workflows = [createWorkflow({ id: 'wf-1', name: 'Test Workflow' })];
|
|
149
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
150
|
+
count: 1,
|
|
151
|
+
data: workflows,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const { getByTestId } = renderModal({
|
|
155
|
+
pinia,
|
|
156
|
+
props: createProps(),
|
|
157
|
+
});
|
|
158
|
+
await nextTick();
|
|
159
|
+
|
|
160
|
+
await waitFor(() => {
|
|
161
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const select = getByTestId('mcp-workflows-select');
|
|
165
|
+
await userEvent.click(select);
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
const options = document.querySelectorAll('.el-select-dropdown__item');
|
|
169
|
+
expect(options.length).toBe(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const option = document.querySelector('.el-select-dropdown__item');
|
|
173
|
+
if (option) {
|
|
174
|
+
await userEvent.click(option);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(getByTestId('mcp-connect-workflows-save-button')).not.toBeDisabled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await userEvent.click(getByTestId('mcp-connect-workflows-save-button'));
|
|
182
|
+
|
|
183
|
+
await waitFor(() => {
|
|
184
|
+
expect(mockOnEnableMcpAccess).toHaveBeenCalledWith(['wf-1']);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should track telemetry event when workflow is selected and saved', async () => {
|
|
189
|
+
const workflows = [createWorkflow({ id: 'wf-1', name: 'Test Workflow' })];
|
|
190
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
191
|
+
count: 1,
|
|
192
|
+
data: workflows,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const { getByTestId } = renderModal({
|
|
196
|
+
pinia,
|
|
197
|
+
props: createProps(),
|
|
198
|
+
});
|
|
199
|
+
await nextTick();
|
|
200
|
+
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const select = getByTestId('mcp-workflows-select');
|
|
206
|
+
await userEvent.click(select);
|
|
207
|
+
|
|
208
|
+
await waitFor(() => {
|
|
209
|
+
const options = document.querySelectorAll('.el-select-dropdown__item');
|
|
210
|
+
expect(options.length).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const option = document.querySelector('.el-select-dropdown__item');
|
|
214
|
+
if (option) {
|
|
215
|
+
await userEvent.click(option);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await waitFor(() => {
|
|
219
|
+
expect(getByTestId('mcp-connect-workflows-save-button')).not.toBeDisabled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await userEvent.click(getByTestId('mcp-connect-workflows-save-button'));
|
|
223
|
+
|
|
224
|
+
await waitFor(() => {
|
|
225
|
+
expect(telemetry.track).toHaveBeenCalledWith('User selected workflow from list', {
|
|
226
|
+
workflowIds: ['wf-1'],
|
|
227
|
+
count: 1,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('Cancel action', () => {
|
|
234
|
+
it('should track telemetry event when cancel is clicked', async () => {
|
|
235
|
+
const { getByTestId } = renderModal({
|
|
236
|
+
pinia,
|
|
237
|
+
props: createProps(),
|
|
238
|
+
});
|
|
239
|
+
await nextTick();
|
|
240
|
+
|
|
241
|
+
await userEvent.click(getByTestId('mcp-connect-workflows-cancel-button'));
|
|
242
|
+
|
|
243
|
+
expect(telemetry.track).toHaveBeenCalledWith('User dismissed mcp workflows dialog');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('Loading state', () => {
|
|
248
|
+
it('should disable buttons while saving', async () => {
|
|
249
|
+
const workflows = [createWorkflow({ id: 'wf-1', name: 'Test Workflow' })];
|
|
250
|
+
mcpStore.getMcpEligibleWorkflows.mockResolvedValue({
|
|
251
|
+
count: 1,
|
|
252
|
+
data: workflows,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Create a promise that doesn't resolve immediately
|
|
256
|
+
let resolvePromise: (value?: unknown) => void;
|
|
257
|
+
mockOnEnableMcpAccess.mockImplementation(
|
|
258
|
+
async () =>
|
|
259
|
+
await new Promise((resolve) => {
|
|
260
|
+
resolvePromise = resolve;
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const { getByTestId } = renderModal({
|
|
265
|
+
pinia,
|
|
266
|
+
props: createProps(),
|
|
267
|
+
});
|
|
268
|
+
await nextTick();
|
|
269
|
+
|
|
270
|
+
await waitFor(() => {
|
|
271
|
+
expect(mcpStore.getMcpEligibleWorkflows).toHaveBeenCalled();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const select = getByTestId('mcp-workflows-select');
|
|
275
|
+
await userEvent.click(select);
|
|
276
|
+
|
|
277
|
+
await waitFor(() => {
|
|
278
|
+
const options = document.querySelectorAll('.el-select-dropdown__item');
|
|
279
|
+
expect(options.length).toBe(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const option = document.querySelector('.el-select-dropdown__item');
|
|
283
|
+
if (option) {
|
|
284
|
+
await userEvent.click(option);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
expect(getByTestId('mcp-connect-workflows-save-button')).not.toBeDisabled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await userEvent.click(getByTestId('mcp-connect-workflows-save-button'));
|
|
292
|
+
|
|
293
|
+
// While saving, buttons should be disabled
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(getByTestId('mcp-connect-workflows-cancel-button')).toBeDisabled();
|
|
296
|
+
expect(getByTestId('mcp-connect-workflows-save-button')).toBeDisabled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Resolve the promise
|
|
300
|
+
resolvePromise!();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useI18n } from '@n8n/i18n';
|
|
3
|
+
import MCPWorkflowsSelect from '../components/MCPWorkflowsSelect.vue';
|
|
4
|
+
import { N8nButton, N8nDialog, N8nDialogFooter } from '@n8n/design-system';
|
|
5
|
+
import { computed, ref, watch } from 'vue';
|
|
6
|
+
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
enableMcpAccess: (workflowIds: string[]) => Promise<void>;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const open = defineModel<boolean>('open', { default: false });
|
|
13
|
+
|
|
14
|
+
const i18n = useI18n();
|
|
15
|
+
const telemetry = useTelemetry();
|
|
16
|
+
|
|
17
|
+
const isSaving = ref(false);
|
|
18
|
+
const selectedWorkflowIds = ref<string[]>([]);
|
|
19
|
+
// typescript-eslint reads an SFC import as `any`, so the ref names the one method it calls.
|
|
20
|
+
const selectRef = ref<{ focusOnInput: () => void } | null>(null);
|
|
21
|
+
const closedByAction = ref(false);
|
|
22
|
+
|
|
23
|
+
const canSave = computed(() => selectedWorkflowIds.value.length > 0);
|
|
24
|
+
|
|
25
|
+
// The view keeps this component mounted, so every opening starts from an empty selection.
|
|
26
|
+
watch(open, (isOpen) => {
|
|
27
|
+
if (isOpen) {
|
|
28
|
+
selectedWorkflowIds.value = [];
|
|
29
|
+
closedByAction.value = false;
|
|
30
|
+
} else if (!closedByAction.value) {
|
|
31
|
+
telemetry.track('User dismissed mcp workflows dialog');
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function save() {
|
|
36
|
+
if (selectedWorkflowIds.value.length === 0) return;
|
|
37
|
+
|
|
38
|
+
isSaving.value = true;
|
|
39
|
+
try {
|
|
40
|
+
await props.enableMcpAccess(selectedWorkflowIds.value);
|
|
41
|
+
closedByAction.value = true;
|
|
42
|
+
telemetry.track('User selected workflow from list', {
|
|
43
|
+
workflowIds: selectedWorkflowIds.value,
|
|
44
|
+
count: selectedWorkflowIds.value.length,
|
|
45
|
+
});
|
|
46
|
+
open.value = false;
|
|
47
|
+
} finally {
|
|
48
|
+
isSaving.value = false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function onSelectReady() {
|
|
53
|
+
selectRef.value?.focusOnInput();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function onConfirm() {
|
|
57
|
+
if (!isSaving.value) {
|
|
58
|
+
void save();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function preventOutsideClose(event: Event) {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<N8nDialog
|
|
69
|
+
v-model:open="open"
|
|
70
|
+
size="xlarge"
|
|
71
|
+
:header="i18n.baseText('settings.mcp.connectWorkflows.modalTitle')"
|
|
72
|
+
data-test-id="mcp-connect-workflows-dialog"
|
|
73
|
+
@interact-outside="preventOutsideClose"
|
|
74
|
+
>
|
|
75
|
+
<MCPWorkflowsSelect
|
|
76
|
+
ref="selectRef"
|
|
77
|
+
v-model="selectedWorkflowIds"
|
|
78
|
+
:placeholder="i18n.baseText('settings.mcp.connectWorkflows.input.placeholder')"
|
|
79
|
+
:disabled="isSaving"
|
|
80
|
+
@ready="onSelectReady"
|
|
81
|
+
@confirm="onConfirm"
|
|
82
|
+
/>
|
|
83
|
+
<N8nDialogFooter>
|
|
84
|
+
<N8nButton
|
|
85
|
+
variant="subtle"
|
|
86
|
+
:label="i18n.baseText('generic.cancel')"
|
|
87
|
+
:disabled="isSaving"
|
|
88
|
+
data-test-id="mcp-connect-workflows-cancel-button"
|
|
89
|
+
@click="open = false"
|
|
90
|
+
/>
|
|
91
|
+
<N8nButton
|
|
92
|
+
variant="solid"
|
|
93
|
+
:label="i18n.baseText('settings.mcp.connectWorkflows.confirm.label')"
|
|
94
|
+
:loading="isSaving"
|
|
95
|
+
:disabled="!canSave || isSaving"
|
|
96
|
+
data-test-id="mcp-connect-workflows-save-button"
|
|
97
|
+
@click="save"
|
|
98
|
+
/>
|
|
99
|
+
</N8nDialogFooter>
|
|
100
|
+
</N8nDialog>
|
|
101
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type RedirectUriError = 'invalidUrl' | 'invalidProtocol' | 'httpsRequired';
|
|
2
|
+
|
|
3
|
+
/** Validates a single OAuth redirect URI; returns the error kind or null when valid. */
|
|
4
|
+
export function validateRedirectUri(uri: string): RedirectUriError | null {
|
|
5
|
+
let url: URL;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(uri);
|
|
8
|
+
} catch {
|
|
9
|
+
return 'invalidUrl';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
13
|
+
return 'invalidProtocol';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
17
|
+
if (!isLocalhost && url.protocol !== 'https:') {
|
|
18
|
+
return 'httpsRequired';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return null;
|
|
22
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import vue from '@vitejs/plugin-vue';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import icons from 'unplugin-icons/vite';
|
|
5
|
+
import svgLoader from 'vite-svg-loader';
|
|
6
|
+
import { defineConfig, mergeConfig } from 'vite';
|
|
7
|
+
import { frontendAliases } from '@n8n/frontend-vite-config';
|
|
8
|
+
import { vitestConfig } from '@n8n/vitest-config/frontend';
|
|
9
|
+
|
|
10
|
+
const packageDir = fileURLToPath(new URL('.', import.meta.url));
|
|
11
|
+
const packagesDir = resolve(packageDir, '..', '..', '..');
|
|
12
|
+
|
|
13
|
+
export default mergeConfig(
|
|
14
|
+
defineConfig({
|
|
15
|
+
// `@n8n/design-system`'s icon set reaches for two things Vite does not handle on its
|
|
16
|
+
// own: `~icons/lucide/*` virtual modules and `./custom/*.svg` single-file components.
|
|
17
|
+
// Consuming design-system from source makes both the consumer's problem — without
|
|
18
|
+
// `svgLoader` an `.svg` import returns a data-URI string, which Vue then renders as a
|
|
19
|
+
// tag name and jsdom rejects. Every module that renders a design-system component
|
|
20
|
+
// needs these two plugins. `src/__tests__/design-system-icons.test.ts` guards them.
|
|
21
|
+
plugins: [
|
|
22
|
+
vue(),
|
|
23
|
+
// Off, so a test never reaches the network for a missing collection. The collection
|
|
24
|
+
// comes from the `@iconify/json` devDependency instead.
|
|
25
|
+
icons({ compiler: 'vue3', autoInstall: false }),
|
|
26
|
+
svgLoader({
|
|
27
|
+
svgoConfig: {
|
|
28
|
+
plugins: [
|
|
29
|
+
{
|
|
30
|
+
name: 'preset-default',
|
|
31
|
+
params: {
|
|
32
|
+
overrides: {
|
|
33
|
+
// The icons rely on their ids, and on a viewBox to stay scalable.
|
|
34
|
+
cleanupIds: false,
|
|
35
|
+
removeViewBox: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
resolve: {
|
|
44
|
+
// The same platform mapping the editor-ui dev server uses, so a test resolves
|
|
45
|
+
// `@n8n/stores/...` from source rather than from a stale `dist` — the two disagreeing is
|
|
46
|
+
// what put 1,111 specifiers on the wrong side of the src/dist line. Sibling modules are
|
|
47
|
+
// deliberately absent: nothing here should make a cross-module import resolve.
|
|
48
|
+
alias: frontendAliases(packagesDir),
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
vitestConfig,
|
|
52
|
+
);
|