@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,274 @@
|
|
|
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 { capabilities, capabilityRegistry } from '@n8n/frontend-module-sdk';
|
|
6
|
+
import { createComponentRenderer, mockedStore, type MockedStore } from '@n8n/frontend-test-utils';
|
|
7
|
+
import SettingsMCPWorkflowsView from './SettingsMCPWorkflowsView.vue';
|
|
8
|
+
import { useMCPStore } from './mcp.store';
|
|
9
|
+
import { useSettingsStore } from '@n8n/stores/settings.store';
|
|
10
|
+
import type { FrontendSettings } from '@n8n/api-types';
|
|
11
|
+
import { WORKFLOW_DESCRIPTION_MODAL_KEY } from '@n8n/frontend-constants/workflows';
|
|
12
|
+
import { MCP_SETTINGS_VIEW } from './mcp.constants';
|
|
13
|
+
import { createWorkflow } from './mcp.test.utils';
|
|
14
|
+
import type { McpWorkflow } from './mcp.types';
|
|
15
|
+
|
|
16
|
+
const { routerPush, routerReplace } = vi.hoisted(() => ({
|
|
17
|
+
routerPush: vi.fn(),
|
|
18
|
+
routerReplace: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('vue-router', async (importOriginal) => ({
|
|
22
|
+
...(await importOriginal()),
|
|
23
|
+
useRouter: () => ({ push: routerPush, replace: routerReplace }),
|
|
24
|
+
useRoute: vi.fn(() => ({
|
|
25
|
+
params: {},
|
|
26
|
+
})),
|
|
27
|
+
RouterLink: {
|
|
28
|
+
template: '<a><slot /></a>',
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('@n8n/composables/useDocumentTitle', () => ({
|
|
33
|
+
useDocumentTitle: () => ({
|
|
34
|
+
set: vi.fn(),
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
let pinia: ReturnType<typeof createTestingPinia>;
|
|
39
|
+
let mcpStore: MockedStore<typeof useMCPStore>;
|
|
40
|
+
let settingsStore: MockedStore<typeof useSettingsStore>;
|
|
41
|
+
const modalOpeners = { openModal: vi.fn(), openModalWithData: vi.fn() };
|
|
42
|
+
|
|
43
|
+
const createComponent = createComponentRenderer(SettingsMCPWorkflowsView, {
|
|
44
|
+
global: {
|
|
45
|
+
stubs: {
|
|
46
|
+
WorkflowsTable: {
|
|
47
|
+
inheritAttrs: true,
|
|
48
|
+
template:
|
|
49
|
+
"<div><button data-test-id=\"workflows-table-page-2\" @click=\"$emit('update:options', { page: 1, itemsPerPage: 10, sortBy: [] })\">Page 2</button><button data-test-id=\"workflows-table-page-size-50\" @click=\"$emit('update:options', { page: 3, itemsPerPage: 50, sortBy: [] })\">Page size 50</button><button data-test-id=\"workflows-table-bulk-remove\" @click=\"$emit('bulkRemoveMcpAccess', ['wf-1', 'wf-2'])\">Bulk remove</button><button data-test-id=\"workflows-table-edit-description\" @click=\"$emit('updateDescription', { id: '1', name: 'Workflow 1', description: 'Old' })\">Edit description</button>Workflows Table</div>",
|
|
50
|
+
},
|
|
51
|
+
MCPConnectWorkflowsModal: {
|
|
52
|
+
props: ['open', 'enableMcpAccess'],
|
|
53
|
+
template:
|
|
54
|
+
'<div v-if="open" data-test-id="mcp-connect-workflows-dialog-stub"><button data-test-id="stub-enable-access" @click="enableMcpAccess([\'wf-1\', \'wf-2\'])">Enable</button></div>',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const workflowPage = (data: McpWorkflow[] = []) => ({ data, count: data.length });
|
|
61
|
+
|
|
62
|
+
describe('SettingsMCPWorkflowsView', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
pinia = createTestingPinia();
|
|
65
|
+
mcpStore = mockedStore(useMCPStore);
|
|
66
|
+
settingsStore = mockedStore(useSettingsStore);
|
|
67
|
+
capabilityRegistry.provide(capabilities.modalOpeners, modalOpeners);
|
|
68
|
+
|
|
69
|
+
settingsStore.settings = {
|
|
70
|
+
enterprise: {},
|
|
71
|
+
} as FrontendSettings;
|
|
72
|
+
|
|
73
|
+
settingsStore.moduleSettings = {
|
|
74
|
+
mcp: {
|
|
75
|
+
mcpAccessEnabled: true,
|
|
76
|
+
mcpManagedByEnv: false,
|
|
77
|
+
autoExposeNewWorkflows: false,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockImplementation(async (page: number) => ({
|
|
82
|
+
...workflowPage(),
|
|
83
|
+
page,
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
capabilityRegistry.clear();
|
|
89
|
+
vi.clearAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should redirect to the MCP settings view when MCP is disabled', async () => {
|
|
93
|
+
settingsStore.moduleSettings = {
|
|
94
|
+
mcp: {
|
|
95
|
+
mcpAccessEnabled: false,
|
|
96
|
+
mcpManagedByEnv: false,
|
|
97
|
+
autoExposeNewWorkflows: false,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
createComponent({ pinia });
|
|
102
|
+
await nextTick();
|
|
103
|
+
|
|
104
|
+
expect(routerReplace).toHaveBeenCalledWith({ name: MCP_SETTINGS_VIEW });
|
|
105
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Workflow pagination', () => {
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockImplementation(async (page: number) => ({
|
|
111
|
+
...workflowPage([createWorkflow({ id: '1', name: 'Workflow 1' })]),
|
|
112
|
+
page,
|
|
113
|
+
}));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should fetch the first workflow page on mount', async () => {
|
|
117
|
+
createComponent({ pinia });
|
|
118
|
+
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).toHaveBeenCalledWith(1, 10);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should fetch the selected workflow page when table options change', async () => {
|
|
125
|
+
const { getByTestId } = createComponent({ pinia });
|
|
126
|
+
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).toHaveBeenCalledWith(1, 10);
|
|
129
|
+
});
|
|
130
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockClear();
|
|
131
|
+
|
|
132
|
+
await userEvent.click(getByTestId('workflows-table-page-2'));
|
|
133
|
+
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).toHaveBeenCalledWith(2, 10);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should reset to first page when workflow table page size changes', async () => {
|
|
140
|
+
const { getByTestId } = createComponent({ pinia });
|
|
141
|
+
|
|
142
|
+
await waitFor(() => {
|
|
143
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).toHaveBeenCalledWith(1, 10);
|
|
144
|
+
});
|
|
145
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockClear();
|
|
146
|
+
|
|
147
|
+
await userEvent.click(getByTestId('workflows-table-page-size-50'));
|
|
148
|
+
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).toHaveBeenCalledWith(1, 50);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('Connect Workflows button', () => {
|
|
156
|
+
it('should not show the button when there are no workflows', async () => {
|
|
157
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockImplementation(async (page: number) => ({
|
|
158
|
+
...workflowPage(),
|
|
159
|
+
page,
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const { queryByTestId } = createComponent({ pinia });
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(queryByTestId('mcp-connect-workflows-header-button')).not.toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should open the Connect Workflows modal when the button is clicked', async () => {
|
|
170
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockImplementation(async (page: number) => ({
|
|
171
|
+
...workflowPage([createWorkflow({ id: '1', name: 'Workflow 1' })]),
|
|
172
|
+
page,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const { getByTestId } = createComponent({ pinia });
|
|
176
|
+
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(getByTestId('mcp-connect-workflows-header-button')).toBeVisible();
|
|
179
|
+
});
|
|
180
|
+
await userEvent.click(getByTestId('mcp-connect-workflows-header-button'));
|
|
181
|
+
|
|
182
|
+
expect(getByTestId('mcp-connect-workflows-dialog-stub')).toBeInTheDocument();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('Bulk workflow actions', () => {
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockImplementation(async (page: number) => ({
|
|
189
|
+
...workflowPage([createWorkflow({ id: '1', name: 'Workflow 1' })]),
|
|
190
|
+
page,
|
|
191
|
+
}));
|
|
192
|
+
mcpStore.toggleWorkflowsMcpAccess.mockResolvedValue({
|
|
193
|
+
updatedCount: 2,
|
|
194
|
+
unchangedCount: 0,
|
|
195
|
+
skippedCount: 0,
|
|
196
|
+
failedCount: 0,
|
|
197
|
+
updatedIds: ['wf-1', 'wf-2'],
|
|
198
|
+
unchangedIds: [],
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should bulk-enable the workflows selected in the Connect Workflows modal', async () => {
|
|
203
|
+
const { getByTestId } = createComponent({ pinia });
|
|
204
|
+
|
|
205
|
+
await waitFor(() => {
|
|
206
|
+
expect(getByTestId('mcp-connect-workflows-header-button')).toBeVisible();
|
|
207
|
+
});
|
|
208
|
+
await userEvent.click(getByTestId('mcp-connect-workflows-header-button'));
|
|
209
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockClear();
|
|
210
|
+
|
|
211
|
+
await userEvent.click(getByTestId('stub-enable-access'));
|
|
212
|
+
|
|
213
|
+
await waitFor(() => {
|
|
214
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).toHaveBeenCalledWith(1, 10);
|
|
215
|
+
});
|
|
216
|
+
expect(mcpStore.toggleWorkflowsMcpAccess).toHaveBeenCalledWith(
|
|
217
|
+
{ workflowIds: ['wf-1', 'wf-2'] },
|
|
218
|
+
true,
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should disable MCP access for bulk-selected workflows and refresh the table', async () => {
|
|
223
|
+
const { getByTestId } = createComponent({ pinia });
|
|
224
|
+
await nextTick();
|
|
225
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockClear();
|
|
226
|
+
|
|
227
|
+
await userEvent.click(getByTestId('workflows-table-bulk-remove'));
|
|
228
|
+
|
|
229
|
+
await waitFor(() => {
|
|
230
|
+
expect(mcpStore.toggleWorkflowsMcpAccess).toHaveBeenCalledWith(
|
|
231
|
+
{ workflowIds: ['wf-1', 'wf-2'] },
|
|
232
|
+
false,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
await waitFor(() => {
|
|
236
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('Edit description', () => {
|
|
242
|
+
it('should open the workflow description modal through the modal openers capability', async () => {
|
|
243
|
+
const { getByTestId } = createComponent({ pinia });
|
|
244
|
+
await nextTick();
|
|
245
|
+
|
|
246
|
+
await userEvent.click(getByTestId('workflows-table-edit-description'));
|
|
247
|
+
|
|
248
|
+
expect(modalOpeners.openModalWithData).toHaveBeenCalledWith({
|
|
249
|
+
name: WORKFLOW_DESCRIPTION_MODAL_KEY,
|
|
250
|
+
data: expect.objectContaining({
|
|
251
|
+
workflowId: '1',
|
|
252
|
+
workflowName: 'Workflow 1',
|
|
253
|
+
workflowDescription: 'Old',
|
|
254
|
+
onSave: expect.any(Function),
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('Refresh button', () => {
|
|
261
|
+
it('should refresh the workflows list', async () => {
|
|
262
|
+
const { getByTestId } = createComponent({ pinia });
|
|
263
|
+
await nextTick();
|
|
264
|
+
|
|
265
|
+
mcpStore.fetchWorkflowsAvailableForMCPPage.mockClear();
|
|
266
|
+
|
|
267
|
+
await userEvent.click(getByTestId('mcp-workflows-refresh-button'));
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(mcpStore.fetchWorkflowsAvailableForMCPPage).toHaveBeenCalled();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue';
|
|
3
|
+
import { useRouter } from 'vue-router';
|
|
4
|
+
import { useI18n } from '@n8n/i18n';
|
|
5
|
+
import { capabilities, capabilityRegistry } from '@n8n/frontend-module-sdk';
|
|
6
|
+
import {
|
|
7
|
+
N8nButton,
|
|
8
|
+
N8nSettingsLayout,
|
|
9
|
+
N8nSettingsPageHeader,
|
|
10
|
+
N8nTooltip,
|
|
11
|
+
} from '@n8n/design-system';
|
|
12
|
+
import type { TableOptions } from '@n8n/design-system';
|
|
13
|
+
|
|
14
|
+
import { useDocumentTitle } from '@n8n/composables/useDocumentTitle';
|
|
15
|
+
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|
16
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
17
|
+
import { WORKFLOW_DESCRIPTION_MODAL_KEY } from '@n8n/frontend-constants/workflows';
|
|
18
|
+
import { useSettingsStore } from '@n8n/stores/settings.store';
|
|
19
|
+
import type { McpWorkflow } from './mcp.types';
|
|
20
|
+
import { useMCPStore } from './mcp.store';
|
|
21
|
+
import { LOADING_INDICATOR_TIMEOUT, MCP_DOCS_PAGE_URL, MCP_SETTINGS_VIEW } from './mcp.constants';
|
|
22
|
+
import WorkflowsTable from './components/tabs/WorkflowsTable.vue';
|
|
23
|
+
import MCPConnectWorkflowsModal from './modals/MCPConnectWorkflowsModal.vue';
|
|
24
|
+
|
|
25
|
+
const i18n = useI18n();
|
|
26
|
+
const toast = useToast();
|
|
27
|
+
const telemetry = useTelemetry();
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
const documentTitle = useDocumentTitle({
|
|
30
|
+
releaseChannel: useSettingsStore().settings.releaseChannel,
|
|
31
|
+
});
|
|
32
|
+
const mcpStore = useMCPStore();
|
|
33
|
+
|
|
34
|
+
const workflowsLoading = ref(false);
|
|
35
|
+
const showConnectWorkflowsDialog = ref(false);
|
|
36
|
+
const availableWorkflows = ref<McpWorkflow[]>([]);
|
|
37
|
+
const availableWorkflowsTotal = ref(0);
|
|
38
|
+
const workflowsTableState = ref<TableOptions>({
|
|
39
|
+
page: 0,
|
|
40
|
+
itemsPerPage: 10,
|
|
41
|
+
sortBy: [],
|
|
42
|
+
});
|
|
43
|
+
const workflowsTableItemsPerPage = ref(workflowsTableState.value.itemsPerPage);
|
|
44
|
+
|
|
45
|
+
const showConnectWorkflowsButton = computed(() => availableWorkflowsTotal.value > 0);
|
|
46
|
+
|
|
47
|
+
const showMcpAccessUpdatedToast = (count: number, enabled: boolean) => {
|
|
48
|
+
toast.showMessage({
|
|
49
|
+
type: 'success',
|
|
50
|
+
title: i18n.baseText(
|
|
51
|
+
enabled
|
|
52
|
+
? 'settings.mcp.workflows.enableAccess.success.title'
|
|
53
|
+
: 'settings.mcp.workflows.removeAccess.success.title',
|
|
54
|
+
{
|
|
55
|
+
adjustToNumber: count,
|
|
56
|
+
interpolate: { count: String(count) },
|
|
57
|
+
},
|
|
58
|
+
),
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const fetchAvailableWorkflows = async () => {
|
|
63
|
+
workflowsLoading.value = true;
|
|
64
|
+
try {
|
|
65
|
+
const response = await mcpStore.fetchWorkflowsAvailableForMCPPage(
|
|
66
|
+
workflowsTableState.value.page + 1,
|
|
67
|
+
workflowsTableState.value.itemsPerPage,
|
|
68
|
+
);
|
|
69
|
+
if (response.page !== workflowsTableState.value.page + 1) {
|
|
70
|
+
workflowsTableState.value = { ...workflowsTableState.value, page: response.page - 1 };
|
|
71
|
+
}
|
|
72
|
+
availableWorkflows.value = response.data;
|
|
73
|
+
availableWorkflowsTotal.value = response.count;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
toast.showError(error, i18n.baseText('workflows.list.error.fetching'));
|
|
76
|
+
} finally {
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
workflowsLoading.value = false;
|
|
79
|
+
}, LOADING_INDICATOR_TIMEOUT);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const refreshWorkflowsFromFirstPage = async () => {
|
|
84
|
+
workflowsTableState.value = { ...workflowsTableState.value, page: 0 };
|
|
85
|
+
await fetchAvailableWorkflows();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const onWorkflowsTableUpdate = async (options: TableOptions) => {
|
|
89
|
+
const pageSizeChanged = options.itemsPerPage !== workflowsTableItemsPerPage.value;
|
|
90
|
+
workflowsTableState.value = { ...options, page: pageSizeChanged ? 0 : options.page };
|
|
91
|
+
workflowsTableItemsPerPage.value = options.itemsPerPage;
|
|
92
|
+
await fetchAvailableWorkflows();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const onToggleWorkflowMCPAccess = async (workflowId: string, isEnabled: boolean) => {
|
|
96
|
+
try {
|
|
97
|
+
await mcpStore.toggleWorkflowMcpAccess(workflowId, isEnabled);
|
|
98
|
+
if (isEnabled) {
|
|
99
|
+
await refreshWorkflowsFromFirstPage();
|
|
100
|
+
} else {
|
|
101
|
+
showMcpAccessUpdatedToast(1, false);
|
|
102
|
+
await fetchAvailableWorkflows();
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
toast.showError(error, i18n.baseText('workflowSettings.toggleMCP.error.title'));
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const onBulkEnableWorkflowsMCPAccess = async (workflowIds: string[]) => {
|
|
111
|
+
try {
|
|
112
|
+
const response = await mcpStore.toggleWorkflowsMcpAccess({ workflowIds }, true);
|
|
113
|
+
showMcpAccessUpdatedToast(response.updatedCount, true);
|
|
114
|
+
await refreshWorkflowsFromFirstPage();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
toast.showError(error, i18n.baseText('workflowSettings.toggleMCP.error.title'));
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onBulkRemoveWorkflowsMCPAccess = async (workflowIds: string[]) => {
|
|
122
|
+
try {
|
|
123
|
+
const response = await mcpStore.toggleWorkflowsMcpAccess({ workflowIds }, false);
|
|
124
|
+
showMcpAccessUpdatedToast(response.updatedCount, false);
|
|
125
|
+
await fetchAvailableWorkflows();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
toast.showError(error, i18n.baseText('workflowSettings.toggleMCP.error.title'));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const onUpdateDescription = (workflow: McpWorkflow) => {
|
|
132
|
+
capabilityRegistry.use(capabilities.modalOpeners).openModalWithData({
|
|
133
|
+
name: WORKFLOW_DESCRIPTION_MODAL_KEY,
|
|
134
|
+
data: {
|
|
135
|
+
workflowId: workflow.id,
|
|
136
|
+
workflowName: workflow.name,
|
|
137
|
+
workflowDescription: workflow.description ?? '',
|
|
138
|
+
onSave: (updatedDescription: string | null) => {
|
|
139
|
+
const index = availableWorkflows.value.findIndex((w) => w.id === workflow.id);
|
|
140
|
+
if (index !== -1) {
|
|
141
|
+
availableWorkflows.value[index] = {
|
|
142
|
+
...availableWorkflows.value[index],
|
|
143
|
+
description: updatedDescription ?? undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const openConnectWorkflowsModal = () => {
|
|
152
|
+
showConnectWorkflowsDialog.value = true;
|
|
153
|
+
telemetry.track('User clicked connect workflows from mcp settings');
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const onBack = () => {
|
|
157
|
+
void router.push({ name: MCP_SETTINGS_VIEW });
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
onMounted(async () => {
|
|
161
|
+
documentTitle.set(i18n.baseText('settings.mcp.workflowsExposed.page.title'));
|
|
162
|
+
if (!mcpStore.mcpAccessEnabled) {
|
|
163
|
+
await router.replace({ name: MCP_SETTINGS_VIEW });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await fetchAvailableWorkflows();
|
|
167
|
+
});
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<template>
|
|
171
|
+
<N8nSettingsLayout
|
|
172
|
+
full-width
|
|
173
|
+
show-back
|
|
174
|
+
:back-label="i18n.baseText('settings.mcp.back')"
|
|
175
|
+
:class="$style.layout"
|
|
176
|
+
@back="onBack"
|
|
177
|
+
>
|
|
178
|
+
<N8nSettingsPageHeader
|
|
179
|
+
:title="i18n.baseText('settings.mcp.workflowsExposed.page.title')"
|
|
180
|
+
:description="i18n.baseText('settings.mcp.workflowsExposed.page.description')"
|
|
181
|
+
:docs-url="MCP_DOCS_PAGE_URL"
|
|
182
|
+
/>
|
|
183
|
+
<div data-test-id="mcp-workflows-view">
|
|
184
|
+
<div :class="$style.actions">
|
|
185
|
+
<N8nButton
|
|
186
|
+
v-if="showConnectWorkflowsButton"
|
|
187
|
+
variant="solid"
|
|
188
|
+
:label="i18n.baseText('settings.mcp.connectWorkflows')"
|
|
189
|
+
data-test-id="mcp-connect-workflows-header-button"
|
|
190
|
+
size="small"
|
|
191
|
+
@click="openConnectWorkflowsModal"
|
|
192
|
+
/>
|
|
193
|
+
<N8nTooltip :content="i18n.baseText('settings.mcp.refresh.tooltip')">
|
|
194
|
+
<N8nButton
|
|
195
|
+
variant="subtle"
|
|
196
|
+
icon-only
|
|
197
|
+
data-test-id="mcp-workflows-refresh-button"
|
|
198
|
+
size="small"
|
|
199
|
+
icon="refresh-cw"
|
|
200
|
+
@click="fetchAvailableWorkflows"
|
|
201
|
+
/>
|
|
202
|
+
</N8nTooltip>
|
|
203
|
+
</div>
|
|
204
|
+
<WorkflowsTable
|
|
205
|
+
v-model:table-options="workflowsTableState"
|
|
206
|
+
:workflows="availableWorkflows"
|
|
207
|
+
:total-count="availableWorkflowsTotal"
|
|
208
|
+
:loading="workflowsLoading"
|
|
209
|
+
@remove-mcp-access="(workflow) => onToggleWorkflowMCPAccess(workflow.id, false)"
|
|
210
|
+
@bulk-remove-mcp-access="onBulkRemoveWorkflowsMCPAccess"
|
|
211
|
+
@connect-workflows="openConnectWorkflowsModal"
|
|
212
|
+
@update-description="onUpdateDescription"
|
|
213
|
+
@update:options="onWorkflowsTableUpdate"
|
|
214
|
+
@refresh="fetchAvailableWorkflows"
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
<MCPConnectWorkflowsModal
|
|
218
|
+
v-model:open="showConnectWorkflowsDialog"
|
|
219
|
+
:enable-mcp-access="onBulkEnableWorkflowsMCPAccess"
|
|
220
|
+
/>
|
|
221
|
+
</N8nSettingsLayout>
|
|
222
|
+
</template>
|
|
223
|
+
|
|
224
|
+
<style lang="scss" module>
|
|
225
|
+
/* Collapse the layout's own top inset; the settings shell already pads the page top. */
|
|
226
|
+
.layout {
|
|
227
|
+
padding-top: 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* Pin the back action to the top-left of the settings area (the shell's
|
|
231
|
+
content container is position: relative), independent of the centered column. */
|
|
232
|
+
.layout > div:first-child {
|
|
233
|
+
position: absolute;
|
|
234
|
+
top: var(--spacing--lg);
|
|
235
|
+
left: var(--spacing--lg);
|
|
236
|
+
width: auto;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.actions {
|
|
240
|
+
display: flex;
|
|
241
|
+
justify-content: flex-end;
|
|
242
|
+
gap: var(--spacing--2xs);
|
|
243
|
+
margin-bottom: var(--spacing--xs);
|
|
244
|
+
}
|
|
245
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { N8nIcon } from '@n8n/design-system';
|
|
2
|
+
import { render } from '@testing-library/vue';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A build-config test, not a feature test. `@n8n/design-system` is consumed from source, so its
|
|
6
|
+
* two icon mechanisms are this package's problem, and each package carries its own
|
|
7
|
+
* `vite.config.ts`. Both cases fail loudly when a plugin goes missing from that file:
|
|
8
|
+
*
|
|
9
|
+
* - `box` comes from `~icons/lucide/box`, which only `unplugin-icons` resolves.
|
|
10
|
+
* - `webhook` comes from `custom/webhook.svg`, which only `svgLoader` turns into a component.
|
|
11
|
+
* Without it the import is a data-URI string, which Vue renders as a tag name.
|
|
12
|
+
*
|
|
13
|
+
* Keep this test while the module renders any design-system component.
|
|
14
|
+
*/
|
|
15
|
+
describe('design-system icons', () => {
|
|
16
|
+
it.each([
|
|
17
|
+
['a lucide icon', 'box'],
|
|
18
|
+
['a custom svg icon', 'webhook'],
|
|
19
|
+
])('renders %s', (_case, icon) => {
|
|
20
|
+
const { container } = render(N8nIcon, { props: { icon } });
|
|
21
|
+
|
|
22
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The shared frontend test harness: the jsdom environment, english messages, and a fresh pinia
|
|
2
|
+
// per test. It lives in `@n8n/frontend-test-utils` rather than in `@n8n/vitest-config`, because
|
|
3
|
+
// `@n8n/i18n` devDepends on the latter and importing i18n there closes a cycle in the turbo graph.
|
|
4
|
+
import '@n8n/frontend-test-utils/setup';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="#D97757" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><defs><linearGradient id="codex-blossom-gradient" x1="12" y1="3" x2="12" y2="21" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#9C8CFF"/><stop offset="1" stop-color="#3A45F5"/></linearGradient></defs><g fill="url(#codex-blossom-gradient)"><circle cx="12" cy="12" r="5"/><circle cx="12" cy="7.4" r="4.4"/><circle cx="15.6" cy="9.13" r="4.4"/><circle cx="16.48" cy="13.02" r="4.4"/><circle cx="14" cy="16.14" r="4.4"/><circle cx="10" cy="16.14" r="4.4"/><circle cx="7.52" cy="13.02" r="4.4"/><circle cx="8.4" cy="9.13" r="4.4"/></g><path d="M9 8.4 L12.9 12 L9 15.6" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.7 15.1 L17 15.1" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="#000000" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="gemini-spark-gradient" x1="3" y1="4" x2="21" y2="20" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4285F4"/><stop offset="0.5" stop-color="#9B72CB"/><stop offset="1" stop-color="#D96570"/></linearGradient></defs><path fill="url(#gemini-spark-gradient)" d="M12 1 Q12 12 23 12 Q12 12 12 23 Q12 12 1 12 Q12 12 12 1 Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Mistral AI</title><path fill="#FFD800" d="M3.429 3.429h3.428v3.428H3.429zM17.143 3.429h3.428v3.428h-3.428z"/><path fill="#FFAF00" d="M3.429 6.857h6.857v3.429H3.429zM13.714 6.857h6.857v3.429h-6.857z"/><path fill="#FF8205" d="M3.429 10.286h17.142v3.428H3.429z"/><path fill="#FA500F" d="M3.429 13.714h3.428v3.429H3.429zM10.286 13.714h3.428v3.429h-3.428zM17.143 13.714h3.428v3.429h-3.428z"/><path fill="#E10500" d="M0 17.143h6.857v3.428H0zM10.286 17.143h3.428v3.428h-3.428zM17.143 17.143H24v3.428h-6.857z"/></svg>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M36.8671 16.3718C37.7746 13.648 37.4621 10.6642 36.0108 8.18661C33.8282 4.38653 29.4407 2.43149 25.1556 3.35151C23.2493 1.20396 20.5105 -0.0173148 17.6392 0.000185533C13.2591 -0.00981468 9.37273 2.81025 8.0252 6.97783C5.21139 7.5541 2.78258 9.31538 1.3613 11.8117C-0.837493 15.6018 -0.336232 20.3794 2.60133 23.6294C1.69381 26.3532 2.00632 29.3371 3.4576 31.8146C5.64015 35.6147 10.0277 37.5697 14.3128 36.6497C16.2179 38.7973 18.9579 40.0185 21.8292 39.9998C26.2118 40.011 30.0994 37.1885 31.4469 33.0171C34.2608 32.4409 36.6896 30.6796 38.1108 28.1833C40.3071 24.3932 39.8046 19.6194 36.8683 16.3693L36.8671 16.3718ZM21.8317 37.386C20.078 37.3885 18.3792 36.7747 17.0329 35.6509C17.0941 35.6185 17.2004 35.5597 17.2691 35.5172L25.2343 30.9171C25.6418 30.6858 25.8918 30.2521 25.8893 29.7833V18.5543L29.2556 20.4981C29.2919 20.5156 29.3157 20.5506 29.3207 20.5906V29.8896C29.3157 34.0247 25.9668 37.3772 21.8317 37.386ZM5.7264 30.5071C4.84763 28.9896 4.53137 27.2108 4.83263 25.4845C4.89138 25.5195 4.99513 25.5832 5.06888 25.6257L13.0341 30.2258C13.4378 30.4621 13.9378 30.4621 14.3428 30.2258L24.0668 24.6107V28.4983C24.0693 28.5383 24.0505 28.577 24.0193 28.602L15.9679 33.2509C12.3815 35.3159 7.80144 34.0884 5.72765 30.5071H5.7264ZM3.6301 13.1205C4.50512 11.6004 5.8864 10.4379 7.53144 9.83415C7.53144 9.9029 7.52769 10.0241 7.52769 10.1092V19.3106C7.52519 19.7781 7.77519 20.2119 8.18145 20.4431L17.9054 26.057L14.5391 28.0008C14.5053 28.0233 14.4628 28.027 14.4253 28.0108L6.37266 23.3582C2.79383 21.2856 1.56631 16.7068 3.62885 13.1217L3.6301 13.1205ZM31.2882 19.5569L21.5642 13.9417L24.9306 11.9992C24.9643 11.9767 25.0068 11.9729 25.0443 11.9892L33.097 16.638C36.6821 18.7093 37.9108 23.2957 35.8395 26.8808C34.9633 28.3983 33.5832 29.5608 31.9395 30.1658V20.6894C31.9432 20.2219 31.6945 19.7894 31.2894 19.5569H31.2882ZM34.6383 14.5142C34.5795 14.478 34.4758 14.4155 34.402 14.373L26.4368 9.77289C26.0331 9.53664 25.5331 9.53664 25.1281 9.77289L15.4041 15.388V11.5004C15.4016 11.4604 15.4204 11.4217 15.4516 11.3967L23.503 6.75158C27.0894 4.68279 31.6745 5.91406 33.742 9.50164C34.6158 11.0167 34.932 12.7905 34.6358 14.5142H34.6383ZM13.5741 21.4431L10.2065 19.4994C10.1702 19.4819 10.1465 19.4468 10.1415 19.4068V10.1079C10.144 5.96781 13.5028 2.61274 17.6429 2.61524C19.3942 2.61524 21.0892 3.23025 22.4355 4.35028C22.3743 4.38278 22.2693 4.44153 22.1992 4.48403L14.2341 9.08413C13.8266 9.31538 13.5766 9.74789 13.5791 10.2167L13.5741 21.4406V21.4431ZM15.4029 17.5006L19.7342 14.9993L24.0655 17.4993V22.5007L19.7342 25.0007L15.4029 22.5007V17.5006Z" fill="black"/>
|
|
3
|
+
</svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><mask id="a" width="128" height="128" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#fff" fill-rule="evenodd" d="M90.767 127.126a7.968 7.968 0 0 0 6.35-.244l26.353-12.681a8 8 0 0 0 4.53-7.209V21.009a8 8 0 0 0-4.53-7.21L97.117 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026L15.6 32.013a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.335 5.335 0 0 0-.006 7.888L20.796 64 1.74 81.387a5.336 5.336 0 0 0 .006 7.887l7.048 6.411a5.327 5.327 0 0 0 6.807.303l21.974-16.68 50.45 46.025a7.96 7.96 0 0 0 2.743 1.793Zm5.252-92.183L57.74 64l38.28 29.058V34.943Z" clip-rule="evenodd"/></mask><g mask="url(#a)"><path fill="#0065A9" d="M123.471 13.82 97.097 1.12A7.973 7.973 0 0 0 88 2.668L1.662 81.387a5.333 5.333 0 0 0 .006 7.887l7.052 6.411a5.333 5.333 0 0 0 6.811.303l103.971-78.875c3.488-2.646 8.498-.158 8.498 4.22v-.306a8.001 8.001 0 0 0-4.529-7.208Z"/><g filter="url(#b)"><path fill="#007ACC" d="m123.471 114.181-26.374 12.698A7.973 7.973 0 0 1 88 125.333L1.662 46.613a5.333 5.333 0 0 1 .006-7.887l7.052-6.411a5.333 5.333 0 0 1 6.811-.303l103.971 78.874c3.488 2.647 8.498.159 8.498-4.219v.306a8.001 8.001 0 0 1-4.529 7.208Z"/></g><g filter="url(#c)"><path fill="#1F9CF0" d="M97.098 126.882A7.977 7.977 0 0 1 88 125.333c2.952 2.952 8 .861 8-3.314V5.98c0-4.175-5.048-6.266-8-3.313a7.977 7.977 0 0 1 9.098-1.549L123.467 13.8A8 8 0 0 1 128 21.01v85.982a8 8 0 0 1-4.533 7.21l-26.369 12.681Z"/></g><path fill="url(#d)" fill-rule="evenodd" d="M90.69 127.126a7.968 7.968 0 0 0 6.349-.244l26.353-12.681a8 8 0 0 0 4.53-7.21V21.009a8 8 0 0 0-4.53-7.21L97.039 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026-21.974-16.68a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.336 5.336 0 0 0-.006 7.888L20.718 64 1.662 81.386a5.335 5.335 0 0 0 .006 7.888l7.048 6.411a5.328 5.328 0 0 0 6.807.303l21.975-16.681 50.45 46.026a7.959 7.959 0 0 0 2.742 1.793Zm5.252-92.184L57.662 64l38.28 29.057V34.943Z" clip-rule="evenodd" opacity="0.25" style="mix-blend-mode:overlay"/></g><defs><filter id="b" width="144.744" height="113.408" x="-8.41115" y="22.5944" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="4.16667"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow_1_36"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_36" result="shape"/></filter><filter id="c" width="56.6667" height="144.007" x="79.6667" y="-8.0035" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="4.16667"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow_1_36"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_36" result="shape"/></filter><linearGradient id="d" x1="63.9222" x2="63.9222" y1="0.329902" y2="127.67" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { MCP_INSTANCE_SCOPES } from '@n8n/api-types';
|
|
4
|
+
import { i18n } from '@n8n/i18n';
|
|
5
|
+
|
|
6
|
+
import { getAccessSummary, getClientBrand, isFullAccessGrant } from './clients.utils';
|
|
7
|
+
|
|
8
|
+
describe('getClientBrand', () => {
|
|
9
|
+
it.each([
|
|
10
|
+
['Claude Code', 'cli'],
|
|
11
|
+
['Claude', 'assistant'],
|
|
12
|
+
['Cursor', 'ide'],
|
|
13
|
+
['Visual Studio Code', 'editor'],
|
|
14
|
+
['Codex CLI', 'cli'],
|
|
15
|
+
['ChatGPT', 'assistant'],
|
|
16
|
+
['Mistral Vibe', 'assistant'],
|
|
17
|
+
['Vibe', 'assistant'],
|
|
18
|
+
['Some Unknown Client', null],
|
|
19
|
+
])('derives the type of %s as %s', (name, type) => {
|
|
20
|
+
expect(getClientBrand(name).type).toBe(type);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('resolves a logo for recognized brands and none for unknown clients', () => {
|
|
24
|
+
expect(getClientBrand('Claude Code').icon).not.toBeNull();
|
|
25
|
+
expect(getClientBrand('Cursor').icon).not.toBeNull();
|
|
26
|
+
expect(getClientBrand('Mistral Vibe').icon).not.toBeNull();
|
|
27
|
+
expect(getClientBrand('Some Unknown Client').icon).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('isFullAccessGrant', () => {
|
|
32
|
+
const nonAgentScopes = MCP_INSTANCE_SCOPES.filter((scope) => !scope.startsWith('agent:'));
|
|
33
|
+
|
|
34
|
+
it('treats a grant covering every scope as full access', () => {
|
|
35
|
+
expect(isFullAccessGrant([...MCP_INSTANCE_SCOPES])).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('treats an empty or partial grant as not full access', () => {
|
|
39
|
+
expect(isFullAccessGrant([])).toBe(false);
|
|
40
|
+
expect(isFullAccessGrant(['workflow:read'])).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('counts a grant as full access when it covers every scope the instance offers', () => {
|
|
44
|
+
expect(isFullAccessGrant(nonAgentScopes, nonAgentScopes)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('still reports missing scopes that the instance does offer', () => {
|
|
48
|
+
expect(isFullAccessGrant(['workflow:read'], nonAgentScopes)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('getAccessSummary', () => {
|
|
53
|
+
it('reports a grant with no scopes as no access', () => {
|
|
54
|
+
expect(getAccessSummary(i18n, { scopes: [] })).toBe('No access');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('reports a grant covering every offered scope as full access', () => {
|
|
58
|
+
expect(getAccessSummary(i18n, { scopes: [...MCP_INSTANCE_SCOPES] })).toBe('Full access');
|
|
59
|
+
expect(
|
|
60
|
+
getAccessSummary(i18n, { scopes: ['workflow:read', 'execution:read'] }, [
|
|
61
|
+
'workflow:read',
|
|
62
|
+
'execution:read',
|
|
63
|
+
]),
|
|
64
|
+
).toBe('Full access');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('lists up to two scope labels verbatim', () => {
|
|
68
|
+
expect(getAccessSummary(i18n, { scopes: ['workflow:read'] })).toBe('List workflows');
|
|
69
|
+
expect(getAccessSummary(i18n, { scopes: ['workflow:read', 'execution:read'] })).toBe(
|
|
70
|
+
'List workflows, Get execution details',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('collapses the remaining scopes into a +N overflow', () => {
|
|
75
|
+
expect(
|
|
76
|
+
getAccessSummary(i18n, {
|
|
77
|
+
scopes: ['workflow:read', 'execution:read', 'workflow:write', 'workflow:execute'],
|
|
78
|
+
}),
|
|
79
|
+
).toBe('List workflows, Get execution details +2');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('renders unknown scopes verbatim', () => {
|
|
83
|
+
expect(getAccessSummary(i18n, { scopes: ['custom:thing'] })).toBe('custom:thing');
|
|
84
|
+
});
|
|
85
|
+
});
|