@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,220 @@
|
|
|
1
|
+
import userEvent from '@testing-library/user-event';
|
|
2
|
+
import { waitFor } from '@testing-library/vue';
|
|
3
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
4
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
5
|
+
import McpAccessTokenTab from './McpAccessTokenTab.vue';
|
|
6
|
+
|
|
7
|
+
const mockCopy = vi.fn();
|
|
8
|
+
const mockCopied = { value: false };
|
|
9
|
+
|
|
10
|
+
vi.mock('@n8n/composables/useClipboard', () => ({
|
|
11
|
+
useClipboard: () => ({
|
|
12
|
+
copy: mockCopy,
|
|
13
|
+
copied: mockCopied,
|
|
14
|
+
isSupported: { value: true },
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const mockGetOrCreateApiKey = vi.fn();
|
|
19
|
+
const mockGenerateNewApiKey = vi.fn();
|
|
20
|
+
const mockResetCurrentUserMCPKey = vi.fn();
|
|
21
|
+
let mockCurrentUserMCPKey: { apiKey: string } | null = { apiKey: 'test-api-key-12345' };
|
|
22
|
+
|
|
23
|
+
vi.mock('../mcp.store', () => ({
|
|
24
|
+
useMCPStore: () => ({
|
|
25
|
+
get currentUserMCPKey() {
|
|
26
|
+
return mockCurrentUserMCPKey;
|
|
27
|
+
},
|
|
28
|
+
getOrCreateApiKey: mockGetOrCreateApiKey,
|
|
29
|
+
generateNewApiKey: mockGenerateNewApiKey,
|
|
30
|
+
resetCurrentUserMCPKey: mockResetCurrentUserMCPKey,
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('./ConnectionParameter.vue', () => ({
|
|
35
|
+
default: {
|
|
36
|
+
name: 'ConnectionParameter',
|
|
37
|
+
template: `
|
|
38
|
+
<div :data-test-id="'connection-parameter-' + id">
|
|
39
|
+
<span data-test-id="connection-parameter-label">{{ label }}</span>
|
|
40
|
+
<span data-test-id="connection-parameter-value">{{ value }}</span>
|
|
41
|
+
<button data-test-id="connection-parameter-copy-button" @click="$emit('copy', value)">Copy</button>
|
|
42
|
+
</div>
|
|
43
|
+
`,
|
|
44
|
+
props: ['id', 'label', 'value', 'infoTip', 'maxWidth', 'allowCopy'],
|
|
45
|
+
emits: ['copy'],
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const renderComponent = createComponentRenderer(McpAccessTokenTab, {
|
|
50
|
+
pinia: createTestingPinia(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('McpAccessTokenTab', () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
mockCurrentUserMCPKey = { apiKey: 'test-api-key-12345' };
|
|
57
|
+
mockGetOrCreateApiKey.mockResolvedValue({ apiKey: 'test-api-key-12345' });
|
|
58
|
+
mockGenerateNewApiKey.mockResolvedValue({ apiKey: 'new-api-key-67890' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
vi.clearAllMocks();
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('Rendering', () => {
|
|
67
|
+
it('should render server URL connection parameter', () => {
|
|
68
|
+
const serverUrl = 'http://localhost:5678/mcp';
|
|
69
|
+
const { getByTestId } = renderComponent({
|
|
70
|
+
props: {
|
|
71
|
+
serverUrl,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(getByTestId('mcp-access-token-tab')).toBeVisible();
|
|
76
|
+
const serverUrlParam = getByTestId('connection-parameter-oauth-server-url');
|
|
77
|
+
expect(serverUrlParam).toBeVisible();
|
|
78
|
+
expect(
|
|
79
|
+
serverUrlParam.querySelector('[data-test-id="connection-parameter-value"]'),
|
|
80
|
+
).toHaveTextContent(serverUrl);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should render access token connection parameter when API key is loaded', async () => {
|
|
84
|
+
const { getByTestId } = renderComponent({
|
|
85
|
+
props: {
|
|
86
|
+
serverUrl: 'http://localhost:5678/mcp',
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Advance timers to complete loading timeout
|
|
91
|
+
await vi.runAllTimersAsync();
|
|
92
|
+
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
const accessTokenParam = getByTestId('connection-parameter-access-token');
|
|
95
|
+
expect(accessTokenParam).toBeVisible();
|
|
96
|
+
expect(
|
|
97
|
+
accessTokenParam.querySelector('[data-test-id="connection-parameter-value"]'),
|
|
98
|
+
).toHaveTextContent('test-api-key-12345');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should render JSON configuration block when loaded', async () => {
|
|
103
|
+
const { getByTestId } = renderComponent({
|
|
104
|
+
props: {
|
|
105
|
+
serverUrl: 'http://localhost:5678/mcp',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await vi.runAllTimersAsync();
|
|
110
|
+
|
|
111
|
+
await waitFor(() => {
|
|
112
|
+
expect(getByTestId('mcp-access-token-json')).toBeVisible();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('Events', () => {
|
|
118
|
+
it('should emit copied event with serverUrl type when server URL is copied', async () => {
|
|
119
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
120
|
+
const serverUrl = 'http://localhost:5678/mcp';
|
|
121
|
+
const { getByTestId, emitted } = renderComponent({
|
|
122
|
+
props: {
|
|
123
|
+
serverUrl,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const serverUrlParam = getByTestId('connection-parameter-oauth-server-url');
|
|
128
|
+
const copyButton = serverUrlParam.querySelector(
|
|
129
|
+
'[data-test-id="connection-parameter-copy-button"]',
|
|
130
|
+
) as HTMLButtonElement;
|
|
131
|
+
|
|
132
|
+
await user.click(copyButton);
|
|
133
|
+
|
|
134
|
+
expect(emitted().copied).toBeTruthy();
|
|
135
|
+
expect(emitted().copied[0]).toEqual(['serverUrl', serverUrl]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should emit copied event with accessToken type when access token is copied', async () => {
|
|
139
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
140
|
+
const { getByTestId, emitted } = renderComponent({
|
|
141
|
+
props: {
|
|
142
|
+
serverUrl: 'http://localhost:5678/mcp',
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await vi.runAllTimersAsync();
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(getByTestId('connection-parameter-access-token')).toBeVisible();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const accessTokenParam = getByTestId('connection-parameter-access-token');
|
|
153
|
+
const copyButton = accessTokenParam.querySelector(
|
|
154
|
+
'[data-test-id="connection-parameter-copy-button"]',
|
|
155
|
+
) as HTMLButtonElement;
|
|
156
|
+
|
|
157
|
+
await user.click(copyButton);
|
|
158
|
+
|
|
159
|
+
expect(emitted().copied).toBeTruthy();
|
|
160
|
+
expect(emitted().copied[0]).toEqual(['accessToken', 'test-api-key-12345']);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should emit copied event with mcpJson type when JSON config is copied', async () => {
|
|
164
|
+
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
|
165
|
+
const { getByTestId, emitted } = renderComponent({
|
|
166
|
+
props: {
|
|
167
|
+
serverUrl: 'http://localhost:5678/mcp',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await vi.runAllTimersAsync();
|
|
172
|
+
|
|
173
|
+
await waitFor(() => {
|
|
174
|
+
expect(getByTestId('mcp-config-snippet-copy')).toBeVisible();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const copyButton = getByTestId('mcp-config-snippet-copy');
|
|
178
|
+
|
|
179
|
+
await user.click(copyButton);
|
|
180
|
+
|
|
181
|
+
expect(emitted().copied).toBeTruthy();
|
|
182
|
+
const copyEvent = emitted().copied[0];
|
|
183
|
+
expect(Array.isArray(copyEvent)).toBe(true);
|
|
184
|
+
if (Array.isArray(copyEvent)) {
|
|
185
|
+
expect(copyEvent[0]).toEqual('mcpJson');
|
|
186
|
+
expect(copyEvent[1]).toContain('mcpServers');
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('API Key handling', () => {
|
|
192
|
+
it('should call getOrCreateApiKey on mount when no API key exists', async () => {
|
|
193
|
+
mockCurrentUserMCPKey = null;
|
|
194
|
+
|
|
195
|
+
renderComponent({
|
|
196
|
+
props: {
|
|
197
|
+
serverUrl: 'http://localhost:5678/mcp',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await vi.runAllTimersAsync();
|
|
202
|
+
|
|
203
|
+
expect(mockGetOrCreateApiKey).toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should not call getOrCreateApiKey on mount when API key already exists', async () => {
|
|
207
|
+
mockCurrentUserMCPKey = { apiKey: 'existing-key' };
|
|
208
|
+
|
|
209
|
+
renderComponent({
|
|
210
|
+
props: {
|
|
211
|
+
serverUrl: 'http://localhost:5678/mcp',
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await vi.runAllTimersAsync();
|
|
216
|
+
|
|
217
|
+
expect(mockGetOrCreateApiKey).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue';
|
|
3
|
+
import { useI18n } from '@n8n/i18n';
|
|
4
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
5
|
+
import { useMCPStore } from '../mcp.store';
|
|
6
|
+
import { LOADING_INDICATOR_TIMEOUT, MCP_TOOLTIP_DELAY } from '../mcp.constants';
|
|
7
|
+
import { N8nLoading, N8nTooltip, N8nButton, N8nNotice } from '@n8n/design-system';
|
|
8
|
+
import ConnectionParameter from './ConnectionParameter.vue';
|
|
9
|
+
import McpConfigSnippet from './McpConfigSnippet.vue';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
serverUrl: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const props = defineProps<Props>();
|
|
16
|
+
|
|
17
|
+
const emit = defineEmits<{
|
|
18
|
+
copied: [type: 'serverUrl' | 'accessToken' | 'mcpJson', value: string];
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const i18n = useI18n();
|
|
22
|
+
const toast = useToast();
|
|
23
|
+
const mcpStore = useMCPStore();
|
|
24
|
+
|
|
25
|
+
const loadingApiKey = ref(true);
|
|
26
|
+
const keyRotating = ref(false);
|
|
27
|
+
const apiKey = computed(() => mcpStore.currentUserMCPKey);
|
|
28
|
+
|
|
29
|
+
// mcp.json value that's to be copied / rendered as the config snippet
|
|
30
|
+
const connectionString = computed(() => {
|
|
31
|
+
return `{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"n8n-mcp": {
|
|
34
|
+
"type": "http",
|
|
35
|
+
"url": "${props.serverUrl}",
|
|
36
|
+
"headers": {
|
|
37
|
+
"Authorization": "Bearer ${apiKeyText.value}"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}`;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const isKeyRedacted = computed(() => {
|
|
45
|
+
return apiKey.value?.apiKey?.includes('******') ?? false;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const apiKeyText = computed(() => {
|
|
49
|
+
if (keyRotating.value) {
|
|
50
|
+
return `<${i18n.baseText('generic.loading')}...>`;
|
|
51
|
+
}
|
|
52
|
+
return isKeyRedacted.value ? '<YOUR_ACCESS_TOKEN_HERE>' : apiKey.value?.apiKey;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const fetchApiKey = async () => {
|
|
56
|
+
try {
|
|
57
|
+
loadingApiKey.value = true;
|
|
58
|
+
await mcpStore.getOrCreateApiKey();
|
|
59
|
+
} catch (error) {
|
|
60
|
+
toast.showError(error, i18n.baseText('settings.mcp.error.fetching.apiKey'));
|
|
61
|
+
} finally {
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
loadingApiKey.value = false;
|
|
64
|
+
}, LOADING_INDICATOR_TIMEOUT);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const rotateKey = async () => {
|
|
69
|
+
try {
|
|
70
|
+
keyRotating.value = true;
|
|
71
|
+
await mcpStore.generateNewApiKey();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
toast.showError(error, i18n.baseText('settings.mcp.error.rotating.apiKey'));
|
|
74
|
+
} finally {
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
keyRotating.value = false;
|
|
77
|
+
}, LOADING_INDICATOR_TIMEOUT);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handleConnectionStringCopy = (value: string) => {
|
|
82
|
+
emit('copied', 'mcpJson', value);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleUrlCopy = (url: string) => {
|
|
86
|
+
emit('copied', 'serverUrl', url);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleAccessTokenCopy = () => {
|
|
90
|
+
if (apiKey.value?.apiKey) {
|
|
91
|
+
emit('copied', 'accessToken', apiKey.value.apiKey);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
onMounted(async () => {
|
|
96
|
+
if (!apiKey.value) {
|
|
97
|
+
await fetchApiKey();
|
|
98
|
+
} else {
|
|
99
|
+
loadingApiKey.value = false;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<template>
|
|
105
|
+
<div :class="$style.container" data-test-id="mcp-access-token-tab">
|
|
106
|
+
<ConnectionParameter
|
|
107
|
+
id="oauth-server-url"
|
|
108
|
+
:label="i18n.baseText('settings.mcp.connectDialog.serverUrl')"
|
|
109
|
+
:value="props.serverUrl"
|
|
110
|
+
@copy="handleUrlCopy"
|
|
111
|
+
/>
|
|
112
|
+
<div v-if="loadingApiKey" :class="$style['loading-container']">
|
|
113
|
+
<N8nLoading :loading="loadingApiKey" variant="h1" :class="$style['url-skeleton']" />
|
|
114
|
+
<N8nLoading :loading="loadingApiKey" variant="button" :class="$style['code-skeleton']" />
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div v-else-if="apiKey?.apiKey" :class="$style['parameters-container']">
|
|
118
|
+
<ConnectionParameter
|
|
119
|
+
id="access-token"
|
|
120
|
+
:value="apiKey.apiKey"
|
|
121
|
+
:value-loading="keyRotating"
|
|
122
|
+
:label="i18n.baseText('settings.mcp.connectDialog.tab.accessToken')"
|
|
123
|
+
:info-tip="i18n.baseText('settings.mcp.instructions.apiKey.tip')"
|
|
124
|
+
:allow-copy="!isKeyRedacted"
|
|
125
|
+
@copy="handleAccessTokenCopy"
|
|
126
|
+
>
|
|
127
|
+
<template #customActions>
|
|
128
|
+
<N8nTooltip
|
|
129
|
+
:content="i18n.baseText('settings.mcp.instructions.rotateKey.tooltip')"
|
|
130
|
+
:show-after="MCP_TOOLTIP_DELAY"
|
|
131
|
+
>
|
|
132
|
+
<N8nButton
|
|
133
|
+
variant="subtle"
|
|
134
|
+
icon-only
|
|
135
|
+
icon="refresh-cw"
|
|
136
|
+
:disabled="keyRotating"
|
|
137
|
+
@click="rotateKey"
|
|
138
|
+
/>
|
|
139
|
+
</N8nTooltip>
|
|
140
|
+
</template>
|
|
141
|
+
</ConnectionParameter>
|
|
142
|
+
<N8nNotice v-if="!isKeyRedacted">
|
|
143
|
+
{{ i18n.baseText('settings.mcp.access.token.notice') }}
|
|
144
|
+
</N8nNotice>
|
|
145
|
+
<div :class="$style['json-container']" data-test-id="mcp-access-token-json">
|
|
146
|
+
<label :class="$style.label">
|
|
147
|
+
{{ i18n.baseText('settings.mcp.connectDialog.jsonConfig') }}
|
|
148
|
+
</label>
|
|
149
|
+
<McpConfigSnippet
|
|
150
|
+
:value="connectionString"
|
|
151
|
+
:disabled="keyRotating"
|
|
152
|
+
@copy="handleConnectionStringCopy"
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</template>
|
|
158
|
+
|
|
159
|
+
<style lang="scss" module>
|
|
160
|
+
.container {
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-direction: column;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.loading-container {
|
|
166
|
+
display: flex;
|
|
167
|
+
flex-direction: column;
|
|
168
|
+
gap: var(--spacing--xs);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Make skeletons same height as the actual content
|
|
172
|
+
// so nothing moves when loading finishes
|
|
173
|
+
.url-skeleton div {
|
|
174
|
+
width: 100%;
|
|
175
|
+
min-height: 40px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.code-skeleton div {
|
|
179
|
+
width: 100%;
|
|
180
|
+
min-height: 350px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.parameters-container {
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
|
|
187
|
+
padding-top: var(--spacing--sm);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.json-container {
|
|
191
|
+
flex-grow: 1;
|
|
192
|
+
margin-top: var(--spacing--sm);
|
|
193
|
+
|
|
194
|
+
.label {
|
|
195
|
+
display: inline-flex;
|
|
196
|
+
font-size: var(--font-size--sm);
|
|
197
|
+
color: var(--color--text--shade-1);
|
|
198
|
+
margin-bottom: var(--spacing--4xs);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
</style>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { within, waitFor } from '@testing-library/vue';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
4
|
+
import McpAllowedCallbackUrlsDialog from './McpAllowedCallbackUrlsDialog.vue';
|
|
5
|
+
|
|
6
|
+
const renderComponent = createComponentRenderer(McpAllowedCallbackUrlsDialog);
|
|
7
|
+
|
|
8
|
+
// The dialog teleports to document.body; its root is renderless, so wait for content.
|
|
9
|
+
const body = () => within(document.body);
|
|
10
|
+
|
|
11
|
+
const saveButton = () => body().getByTestId('mcp-callback-urls-save');
|
|
12
|
+
|
|
13
|
+
const renderDialog = async (uris: string[]) => {
|
|
14
|
+
const rendered = renderComponent({ props: { open: true, uris } });
|
|
15
|
+
await waitFor(() => {
|
|
16
|
+
expect(body().getByTestId('mcp-callback-urls-save')).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
return rendered;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('McpAllowedCallbackUrlsDialog', () => {
|
|
22
|
+
it('should preselect "all" mode with no URL list when nothing is configured', async () => {
|
|
23
|
+
await renderDialog([]);
|
|
24
|
+
|
|
25
|
+
expect(body().queryByTestId('mcp-callback-url-input')).not.toBeInTheDocument();
|
|
26
|
+
// Saving the default "all" selection is allowed (idempotent), no tweak needed.
|
|
27
|
+
expect(saveButton()).toBeEnabled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should preselect "trusted" mode with the persisted URLs', async () => {
|
|
31
|
+
await renderDialog(['https://a.example.com/cb', 'https://b.example.com/cb']);
|
|
32
|
+
|
|
33
|
+
expect(body().getAllByTestId('mcp-callback-url-input')).toHaveLength(2);
|
|
34
|
+
// A valid, unchanged selection can still be re-saved.
|
|
35
|
+
expect(saveButton()).toBeEnabled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should emit the trimmed URL list when saving trusted URLs', async () => {
|
|
39
|
+
const { emitted } = await renderDialog([]);
|
|
40
|
+
|
|
41
|
+
await userEvent.click(body().getByTestId('mcp-callback-urls-mode-trusted'));
|
|
42
|
+
await userEvent.type(
|
|
43
|
+
body().getByTestId('mcp-callback-url-input'),
|
|
44
|
+
' https://client.example.com/callback ',
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(saveButton()).toBeEnabled();
|
|
48
|
+
await userEvent.click(saveButton());
|
|
49
|
+
|
|
50
|
+
expect(emitted('save')).toEqual([[['https://client.example.com/callback']]]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should show a validation error and block saving for an invalid URL', async () => {
|
|
54
|
+
await renderDialog([]);
|
|
55
|
+
|
|
56
|
+
await userEvent.click(body().getByTestId('mcp-callback-urls-mode-trusted'));
|
|
57
|
+
await userEvent.type(body().getByTestId('mcp-callback-url-input'), 'not-a-url');
|
|
58
|
+
|
|
59
|
+
expect(body().getByTestId('mcp-callback-urls-error')).toBeVisible();
|
|
60
|
+
expect(saveButton()).toBeDisabled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should require https for non-localhost URLs', async () => {
|
|
64
|
+
await renderDialog([]);
|
|
65
|
+
|
|
66
|
+
await userEvent.click(body().getByTestId('mcp-callback-urls-mode-trusted'));
|
|
67
|
+
await userEvent.type(
|
|
68
|
+
body().getByTestId('mcp-callback-url-input'),
|
|
69
|
+
'http://client.example.com/callback',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(body().getByTestId('mcp-callback-urls-error')).toBeVisible();
|
|
73
|
+
expect(saveButton()).toBeDisabled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should allow http for localhost URLs', async () => {
|
|
77
|
+
await renderDialog([]);
|
|
78
|
+
|
|
79
|
+
await userEvent.click(body().getByTestId('mcp-callback-urls-mode-trusted'));
|
|
80
|
+
await userEvent.type(body().getByTestId('mcp-callback-url-input'), 'http://localhost/callback');
|
|
81
|
+
|
|
82
|
+
expect(body().queryByTestId('mcp-callback-urls-error')).not.toBeInTheDocument();
|
|
83
|
+
expect(saveButton()).toBeEnabled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should emit an empty list when switching back to allowing all URLs', async () => {
|
|
87
|
+
const { emitted } = await renderDialog(['https://a.example.com/cb']);
|
|
88
|
+
|
|
89
|
+
await userEvent.click(body().getByTestId('mcp-callback-urls-mode-all'));
|
|
90
|
+
|
|
91
|
+
expect(saveButton()).toBeEnabled();
|
|
92
|
+
await userEvent.click(saveButton());
|
|
93
|
+
|
|
94
|
+
expect(emitted('save')).toEqual([[[]]]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should sync to the persisted URLs when they load after the dialog is already open', async () => {
|
|
98
|
+
// Dialog opened before the async allow-list resolved: it starts on "all".
|
|
99
|
+
const { rerender } = await renderDialog([]);
|
|
100
|
+
expect(body().queryByTestId('mcp-callback-url-input')).not.toBeInTheDocument();
|
|
101
|
+
|
|
102
|
+
// The load resolves and the prop updates; the form must adopt the trusted list
|
|
103
|
+
// rather than keep the default "all" (which would erase it on save).
|
|
104
|
+
await rerender({ open: true, uris: ['https://a.example.com/cb'] });
|
|
105
|
+
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(body().getAllByTestId('mcp-callback-url-input')).toHaveLength(1);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should add and remove URL rows', async () => {
|
|
112
|
+
await renderDialog(['https://a.example.com/cb']);
|
|
113
|
+
|
|
114
|
+
await userEvent.click(body().getByTestId('mcp-callback-url-add'));
|
|
115
|
+
expect(body().getAllByTestId('mcp-callback-url-input')).toHaveLength(2);
|
|
116
|
+
|
|
117
|
+
await userEvent.click(body().getAllByTestId('mcp-callback-url-remove')[0]);
|
|
118
|
+
await waitFor(() => {
|
|
119
|
+
expect(body().getAllByTestId('mcp-callback-url-input')).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|