@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.
Files changed (77) hide show
  1. package/LICENSE.md +88 -0
  2. package/README.md +51 -0
  3. package/biome.jsonc +4 -0
  4. package/eslint.config.mjs +4 -0
  5. package/package.json +72 -0
  6. package/src/SettingsMCPAgentsView.test.ts +256 -0
  7. package/src/SettingsMCPAgentsView.vue +223 -0
  8. package/src/SettingsMCPClientsView.test.ts +280 -0
  9. package/src/SettingsMCPClientsView.vue +168 -0
  10. package/src/SettingsMCPView.test.ts +832 -0
  11. package/src/SettingsMCPView.vue +496 -0
  12. package/src/SettingsMCPWorkflowsView.test.ts +274 -0
  13. package/src/SettingsMCPWorkflowsView.vue +245 -0
  14. package/src/__tests__/design-system-icons.test.ts +24 -0
  15. package/src/__tests__/setup.ts +4 -0
  16. package/src/assets/client-icons/claude.svg +1 -0
  17. package/src/assets/client-icons/codex.svg +1 -0
  18. package/src/assets/client-icons/cursor.svg +1 -0
  19. package/src/assets/client-icons/gemini.svg +1 -0
  20. package/src/assets/client-icons/mistral.svg +1 -0
  21. package/src/assets/client-icons/openai.svg +3 -0
  22. package/src/assets/client-icons/vscode.svg +1 -0
  23. package/src/clients.utils.test.ts +85 -0
  24. package/src/clients.utils.ts +124 -0
  25. package/src/components/ConnectionParameter.vue +213 -0
  26. package/src/components/MCPAgentsSelect.vue +176 -0
  27. package/src/components/MCPEmptyState.vue +75 -0
  28. package/src/components/MCPWorkflowsSelect.test.ts +384 -0
  29. package/src/components/MCPWorkflowsSelect.vue +159 -0
  30. package/src/components/McpAccessToggle.vue +77 -0
  31. package/src/components/McpAccessTokenTab.test.ts +220 -0
  32. package/src/components/McpAccessTokenTab.vue +201 -0
  33. package/src/components/McpAllowedCallbackUrlsDialog.test.ts +122 -0
  34. package/src/components/McpAllowedCallbackUrlsDialog.vue +359 -0
  35. package/src/components/McpClientLogoCards.vue +134 -0
  36. package/src/components/McpConfigSnippet.vue +92 -0
  37. package/src/components/McpConnectClientDialog.test.ts +115 -0
  38. package/src/components/McpConnectClientDialog.vue +432 -0
  39. package/src/components/McpConnectedClientRow.test.ts +86 -0
  40. package/src/components/McpConnectedClientRow.vue +152 -0
  41. package/src/components/McpEmptyStateCard.vue +83 -0
  42. package/src/components/McpStatusControl.test.ts +33 -0
  43. package/src/components/McpStatusControl.vue +96 -0
  44. package/src/components/OAuthClientDetailsModal.vue +182 -0
  45. package/src/components/RevokeOAuthClientConfirmModal.vue +60 -0
  46. package/src/components/WorkflowLocation.test.ts +329 -0
  47. package/src/components/WorkflowLocation.vue +198 -0
  48. package/src/components/tabs/AgentsTable.vue +312 -0
  49. package/src/components/tabs/OAuthClientOwnerCell.vue +71 -0
  50. package/src/components/tabs/OAuthClientsFilters.test.ts +86 -0
  51. package/src/components/tabs/OAuthClientsFilters.vue +201 -0
  52. package/src/components/tabs/OAuthClientsTable.test.ts +394 -0
  53. package/src/components/tabs/OAuthClientsTable.vue +473 -0
  54. package/src/components/tabs/WorkflowsTable.test.ts +661 -0
  55. package/src/components/tabs/WorkflowsTable.vue +398 -0
  56. package/src/composables/useMcp.test.ts +59 -0
  57. package/src/composables/useMcp.ts +61 -0
  58. package/src/composables/useOAuthClientRevoke.ts +90 -0
  59. package/src/index.ts +10 -0
  60. package/src/mcp.api.test.ts +81 -0
  61. package/src/mcp.api.ts +206 -0
  62. package/src/mcp.clients.catalog.test.ts +63 -0
  63. package/src/mcp.clients.catalog.ts +183 -0
  64. package/src/mcp.constants.ts +45 -0
  65. package/src/mcp.module.test.ts +106 -0
  66. package/src/mcp.module.ts +90 -0
  67. package/src/mcp.store.test.ts +628 -0
  68. package/src/mcp.store.ts +490 -0
  69. package/src/mcp.test.utils.ts +57 -0
  70. package/src/mcp.types.ts +20 -0
  71. package/src/modals/MCPConnectAgentsModal.test.ts +141 -0
  72. package/src/modals/MCPConnectAgentsModal.vue +122 -0
  73. package/src/modals/MCPConnectWorkflowsModal.test.ts +303 -0
  74. package/src/modals/MCPConnectWorkflowsModal.vue +101 -0
  75. package/src/redirect-uris.utils.ts +22 -0
  76. package/stylelint.config.mjs +9 -0
  77. package/vite.config.ts +52 -0
@@ -0,0 +1,398 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import type { McpWorkflow } from '../../mcp.types';
5
+ import type { TableHeader, TableOptions, UserAction } from '@n8n/design-system';
6
+ import {
7
+ N8nActionToggle,
8
+ N8nButton,
9
+ N8nDataTableServer,
10
+ N8nIcon,
11
+ N8nLink,
12
+ N8nLoading,
13
+ N8nSelectedItemsInfo,
14
+ N8nText,
15
+ N8nTooltip,
16
+ } from '@n8n/design-system';
17
+ import { VIEWS } from '@n8n/frontend-constants/views';
18
+ import { useRouter } from 'vue-router';
19
+ import WorkflowLocation from '../WorkflowLocation.vue';
20
+ import { MCP_TOOLTIP_DELAY } from '../../mcp.constants';
21
+ import { getResourcePermissions } from '@n8n/permissions';
22
+
23
+ type Props = {
24
+ workflows: McpWorkflow[];
25
+ totalCount?: number;
26
+ loading: boolean;
27
+ };
28
+
29
+ const props = defineProps<Props>();
30
+
31
+ const tableOptions = defineModel<TableOptions>('tableOptions', {
32
+ default: () => ({
33
+ page: 0,
34
+ itemsPerPage: 10,
35
+ sortBy: [],
36
+ }),
37
+ });
38
+
39
+ const tablePage = computed({
40
+ get: () => tableOptions.value.page,
41
+ set: (page: number) => {
42
+ tableOptions.value = { ...tableOptions.value, page };
43
+ },
44
+ });
45
+
46
+ const tableItemsPerPage = computed({
47
+ get: () => tableOptions.value.itemsPerPage,
48
+ set: (itemsPerPage: number) => {
49
+ tableOptions.value = { ...tableOptions.value, itemsPerPage };
50
+ },
51
+ });
52
+
53
+ const tableSortBy = computed({
54
+ get: () => tableOptions.value.sortBy,
55
+ set: (sortBy: TableOptions['sortBy']) => {
56
+ tableOptions.value = { ...tableOptions.value, sortBy };
57
+ },
58
+ });
59
+
60
+ const emit = defineEmits<{
61
+ removeMcpAccess: [workflow: McpWorkflow];
62
+ bulkRemoveMcpAccess: [workflowIds: string[]];
63
+ connectWorkflows: [];
64
+ updateDescription: [workflow: McpWorkflow];
65
+ 'update:options': [payload: TableOptions];
66
+ }>();
67
+
68
+ const i18n = useI18n();
69
+ const router = useRouter();
70
+
71
+ const itemsLength = computed(() => props.totalCount ?? props.workflows.length);
72
+
73
+ const selectedWorkflowIds = ref<string[]>([]);
74
+
75
+ // Selection references loaded rows, so any data reload invalidates it
76
+ watch(
77
+ () => props.workflows,
78
+ () => {
79
+ selectedWorkflowIds.value = [];
80
+ },
81
+ );
82
+
83
+ const isRowSelectable = (workflow: McpWorkflow) =>
84
+ !!getResourcePermissions(workflow.scopes).workflow.update;
85
+
86
+ const clearSelection = () => {
87
+ selectedWorkflowIds.value = [];
88
+ };
89
+
90
+ const onBulkRemoveMcpAccess = () => {
91
+ emit('bulkRemoveMcpAccess', selectedWorkflowIds.value);
92
+ };
93
+
94
+ const tableHeaders = ref<Array<TableHeader<McpWorkflow>>>([
95
+ {
96
+ title: i18n.baseText('settings.mcp.workflows.table.column.name'),
97
+ key: 'workflow',
98
+ width: 150,
99
+ disableSort: true,
100
+ value() {
101
+ return;
102
+ },
103
+ },
104
+ {
105
+ title: i18n.baseText('settings.mcp.workflows.table.column.location'),
106
+ key: 'location',
107
+ width: 200,
108
+ disableSort: true,
109
+ value() {
110
+ return;
111
+ },
112
+ },
113
+ {
114
+ title: i18n.baseText('generic.description'),
115
+ key: 'description',
116
+ width: 350,
117
+ disableSort: true,
118
+ value() {
119
+ return;
120
+ },
121
+ },
122
+ {
123
+ title: '',
124
+ key: 'actions',
125
+ align: 'end',
126
+ width: 50,
127
+ disableSort: true,
128
+ value() {
129
+ return;
130
+ },
131
+ },
132
+ ]);
133
+
134
+ const getAvailableActions = (workflow: McpWorkflow): Array<UserAction<McpWorkflow>> => {
135
+ const permissions = getResourcePermissions(workflow.scopes);
136
+
137
+ return [
138
+ {
139
+ label: i18n.baseText('settings.mcp.workflows.table.action.removeMCPAccess'),
140
+ value: 'removeFromMCP',
141
+ disabled: !permissions.workflow.update,
142
+ },
143
+ {
144
+ label: i18n.baseText('settings.mcp.workflows.table.action.updateDescription'),
145
+ value: 'updateDescription',
146
+ disabled: !permissions.workflow.update,
147
+ },
148
+ ];
149
+ };
150
+
151
+ const onWorkflowAction = (action: string, workflow: McpWorkflow) => {
152
+ switch (action) {
153
+ case 'removeFromMCP':
154
+ emit('removeMcpAccess', workflow);
155
+ break;
156
+ case 'updateDescription':
157
+ emit('updateDescription', workflow);
158
+ break;
159
+ default:
160
+ break;
161
+ }
162
+ };
163
+
164
+ const onConnectClick = () => {
165
+ emit('connectWorkflows');
166
+ };
167
+ </script>
168
+
169
+ <template>
170
+ <div>
171
+ <div v-if="props.loading">
172
+ <N8nLoading :loading="props.loading" variant="h1" class="mb-l" />
173
+ <N8nLoading :loading="props.loading" variant="p" :rows="5" :shrink-last="false" />
174
+ </div>
175
+ <div v-else class="mt-s mb-xl" :class="$style['table-container']">
176
+ <N8nDataTableServer
177
+ v-model:sort-by="tableSortBy"
178
+ v-model:page="tablePage"
179
+ v-model:items-per-page="tableItemsPerPage"
180
+ v-model:selection="selectedWorkflowIds"
181
+ :class="$style['workflow-table']"
182
+ data-test-id="mcp-workflow-table"
183
+ :headers="tableHeaders"
184
+ :items="props.workflows"
185
+ :items-length="itemsLength"
186
+ :page-sizes="[10, 25, 50]"
187
+ :show-select="itemsLength > 0"
188
+ :item-selectable="isRowSelectable"
189
+ @update:options="emit('update:options', $event)"
190
+ >
191
+ <template v-if="itemsLength === 0" #cover>
192
+ <div :class="$style['empty-state']">
193
+ <N8nText data-test-id="mcp-workflow-table-empty-state" size="large" color="text-base">
194
+ {{ i18n.baseText('settings.mcp.workflows.table.empty.title') }}
195
+ </N8nText>
196
+ <N8nText
197
+ data-test-id="mcp-workflow-table-empty-state-description"
198
+ size="small"
199
+ color="text-base"
200
+ >
201
+ {{ i18n.baseText('settings.mcp.workflows.table.empty.description') }}
202
+ </N8nText>
203
+ <N8nButton
204
+ variant="solid"
205
+ data-test-id="mcp-workflow-table-empty-state-button"
206
+ :label="i18n.baseText('settings.mcp.connectWorkflows')"
207
+ @click="onConnectClick"
208
+ />
209
+ </div>
210
+ </template>
211
+ <template #[`item.workflow`]="{ item }">
212
+ <div :class="$style['workflow-cell']" data-test-id="mcp-workflow-cell">
213
+ <N8nLink
214
+ data-test-id="mcp-workflow-name-link"
215
+ :new-window="true"
216
+ :to="
217
+ router.resolve({
218
+ name: VIEWS.WORKFLOW,
219
+ params: { workflowId: item.id },
220
+ }).fullPath
221
+ "
222
+ :theme="'text'"
223
+ :class="[$style['table-link'], $style.truncate]"
224
+ >
225
+ <N8nText :class="$style.truncate" data-test-id="mcp-workflow-name">{{
226
+ item.name
227
+ }}</N8nText>
228
+ </N8nLink>
229
+ </div>
230
+ </template>
231
+ <template #[`item.location`]="{ item }">
232
+ <div :class="$style['location-cell']" data-test-id="mcp-workflow-location-cell">
233
+ <WorkflowLocation
234
+ :workflow-id="item.id"
235
+ :home-project="item.homeProject"
236
+ :parent-folder="item.parentFolder"
237
+ :as-links="true"
238
+ />
239
+ </div>
240
+ </template>
241
+ <template #[`item.description`]="{ item }">
242
+ <!-- as-child anchors the tooltip to the cell content itself, keeping it
243
+ above the cell instead of positioning against an inline wrapper span -->
244
+ <N8nTooltip
245
+ :content="
246
+ item.description
247
+ ? i18n.baseText('settings.mcp.workflows.table.column.description.editTooltip')
248
+ : i18n.baseText('settings.mcp.workflows.table.column.description.emptyTooltip')
249
+ "
250
+ :show-after="MCP_TOOLTIP_DELAY"
251
+ as-child
252
+ >
253
+ <div
254
+ data-test-id="mcp-workflow-description-cell"
255
+ :class="$style['description-cell']"
256
+ @click="emit('updateDescription', item)"
257
+ >
258
+ <N8nText
259
+ v-if="item.description"
260
+ :class="$style['description-text']"
261
+ data-test-id="mcp-workflow-description"
262
+ >
263
+ {{ item.description }}
264
+ </N8nText>
265
+ <span v-else :class="$style['empty-description']">
266
+ <N8nIcon icon="triangle-alert" :size="14" color="warning" class="mr-2xs" />
267
+ <N8nText data-test-id="mcp-workflow-description-empty">
268
+ {{
269
+ i18n.baseText('settings.mcp.workflows.table.column.description.emptyContent')
270
+ }}
271
+ </N8nText>
272
+ </span>
273
+ </div>
274
+ </N8nTooltip>
275
+ </template>
276
+ <template #[`item.actions`]="{ item }">
277
+ <N8nActionToggle
278
+ :class="$style['action-toggle']"
279
+ data-test-id="mcp-workflow-action-toggle"
280
+ placement="bottom"
281
+ :actions="getAvailableActions(item)"
282
+ theme="dark"
283
+ @action="onWorkflowAction($event, item)"
284
+ />
285
+ </template>
286
+ </N8nDataTableServer>
287
+ <N8nSelectedItemsInfo
288
+ :class="$style['selection-bar']"
289
+ :selected-count="selectedWorkflowIds.length"
290
+ @clear-selection="clearSelection"
291
+ >
292
+ <template #actions>
293
+ <N8nButton
294
+ variant="subtle"
295
+ data-test-id="mcp-bulk-remove-access-button"
296
+ :label="i18n.baseText('settings.mcp.workflows.table.action.removeMCPAccess')"
297
+ @click="onBulkRemoveMcpAccess"
298
+ />
299
+ </template>
300
+ </N8nSelectedItemsInfo>
301
+ </div>
302
+ </div>
303
+ </template>
304
+
305
+ <style module lang="scss">
306
+ .header {
307
+ display: flex;
308
+ justify-content: space-between;
309
+ align-items: center;
310
+ }
311
+
312
+ // The selection bar's default absolute positioning assumes a tall anchor
313
+ // container and overlaps the header when the table is short. Sticky keeps
314
+ // it below the table, floating at the viewport bottom only while a long
315
+ // table extends past it.
316
+ .table-container .selection-bar {
317
+ position: sticky;
318
+ bottom: var(--spacing--3xl);
319
+ left: auto;
320
+ transform: none;
321
+ width: fit-content;
322
+ margin: 0 auto;
323
+ }
324
+
325
+ .workflow-table {
326
+ margin-bottom: var(--spacing--sm);
327
+
328
+ :global(.table-scroll) {
329
+ overflow-y: hidden;
330
+ }
331
+
332
+ tr:last-child {
333
+ border-bottom: none !important;
334
+ }
335
+ }
336
+
337
+ .empty-state {
338
+ display: flex;
339
+ flex-direction: column;
340
+ align-items: center;
341
+ justify-content: center;
342
+ gap: var(--spacing--sm);
343
+ padding: var(--spacing--lg) 0;
344
+ min-height: 250px;
345
+ }
346
+
347
+ .workflow-cell {
348
+ display: flex;
349
+ padding: var(--spacing--2xs) 0;
350
+ min-width: 0;
351
+ overflow: hidden;
352
+ }
353
+
354
+ .location-cell {
355
+ padding: var(--spacing--2xs) 0;
356
+ }
357
+
358
+ .description-cell {
359
+ // Shrink to the text so the tooltip is anchored near it, not centered on the column
360
+ display: inline-block;
361
+ max-width: 100%;
362
+ color: var(--color--text);
363
+ padding: var(--spacing--2xs) 0;
364
+ cursor: pointer;
365
+
366
+ &:hover span {
367
+ color: var(--color--text--shade-1);
368
+ }
369
+ }
370
+
371
+ // Line clamping only works on inline content, so it has to live on the
372
+ // text element itself rather than on a wrapper with non-inline children
373
+ .description-text {
374
+ display: -webkit-box;
375
+ -webkit-box-orient: vertical;
376
+ -webkit-line-clamp: 3;
377
+ line-clamp: 3;
378
+ overflow: hidden;
379
+ word-break: break-word;
380
+ }
381
+
382
+ .empty-description {
383
+ display: flex;
384
+ align-items: center;
385
+ }
386
+
387
+ .table-link {
388
+ color: var(--color--text);
389
+ }
390
+
391
+ .truncate {
392
+ display: block;
393
+ overflow: hidden;
394
+ text-overflow: ellipsis;
395
+ white-space: nowrap;
396
+ min-width: 0;
397
+ }
398
+ </style>
@@ -0,0 +1,59 @@
1
+ import { TELEMETRY_EVENT } from '@n8n/telemetry';
2
+
3
+ import { useMcp } from './useMcp';
4
+
5
+ const { trackSpy } = vi.hoisted(() => ({ trackSpy: vi.fn() }));
6
+
7
+ vi.mock('@n8n/composables/useTelemetry', () => ({
8
+ useTelemetry: () => ({ track: trackSpy }),
9
+ }));
10
+
11
+ describe('useMcp', () => {
12
+ afterEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+
16
+ it('should report where the connect dialog was opened from', () => {
17
+ useMcp().trackConnectClientClicked('settings');
18
+
19
+ expect(trackSpy).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.USER_CLICKED_CONNECT_CLIENT, {
20
+ source: 'settings',
21
+ });
22
+ });
23
+
24
+ it('should track the instance-wide clients view', () => {
25
+ useMcp().trackViewedAllClients();
26
+
27
+ expect(trackSpy).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.USER_VIEWED_ALL_MCP_CLIENTS, {});
28
+ });
29
+
30
+ it('should resolve brand and type from the revoked client name', () => {
31
+ useMcp().trackClientAccessRevoked({
32
+ clientId: 'client-1',
33
+ clientName: 'Cursor',
34
+ revokedForOther: false,
35
+ });
36
+
37
+ expect(trackSpy).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.USER_REVOKED_MCP_CLIENT_ACCESS, {
38
+ client_id: 'client-1',
39
+ client_brand: 'cursor',
40
+ client_type: 'ide',
41
+ revoked_for_other: false,
42
+ });
43
+ });
44
+
45
+ it('should send null brand and type for a client name matching no known brand', () => {
46
+ useMcp().trackClientAccessRevoked({
47
+ clientId: 'client-2',
48
+ clientName: 'some-in-house-agent',
49
+ revokedForOther: true,
50
+ });
51
+
52
+ expect(trackSpy).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.USER_REVOKED_MCP_CLIENT_ACCESS, {
53
+ client_id: 'client-2',
54
+ client_brand: null,
55
+ client_type: null,
56
+ revoked_for_other: true,
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,61 @@
1
+ import { useTelemetry } from '@n8n/composables/useTelemetry';
2
+ import { TELEMETRY_EVENT } from '@n8n/telemetry';
3
+ import { getMcpClientBrand, getMcpClientType } from '@n8n/api-types';
4
+
5
+ export function useMcp() {
6
+ const telemetry = useTelemetry();
7
+
8
+ const trackMcpAccessEnabledForWorkflow = (workflowId: string) => {
9
+ telemetry.track(TELEMETRY_EVENT.MCP.USER_GAVE_MCP_ACCESS_TO_WORKFLOW, {
10
+ workflow_id: workflowId,
11
+ });
12
+ };
13
+
14
+ const trackMcpAccessEnabledForAgent = (agentId: string) => {
15
+ telemetry.track(TELEMETRY_EVENT.AGENTS.USER_GAVE_MCP_ACCESS_TO_AGENT, { agent_id: agentId });
16
+ };
17
+
18
+ const trackUserToggledMcpAccess = (enabled: boolean) => {
19
+ telemetry.track(TELEMETRY_EVENT.MCP.USER_TOGGLED_MCP_ACCESS, { state: enabled });
20
+ };
21
+
22
+ const trackAutoExposeToggled = ({
23
+ enabled,
24
+ source,
25
+ }: { enabled: boolean; source: 'settings' | 'expose_all' }) => {
26
+ telemetry.track(TELEMETRY_EVENT.MCP.AUTO_EXPOSE_NEW_WORKFLOWS_TOGGLED, { enabled, source });
27
+ };
28
+
29
+ const trackConnectClientClicked = (source: 'settings') => {
30
+ telemetry.track(TELEMETRY_EVENT.MCP.USER_CLICKED_CONNECT_CLIENT, { source });
31
+ };
32
+
33
+ const trackViewedAllClients = () => {
34
+ telemetry.track(TELEMETRY_EVENT.MCP.USER_VIEWED_ALL_MCP_CLIENTS, {});
35
+ };
36
+
37
+ // Brand and type come from the client's self-registered name, so the revoke
38
+ // can be segmented the same way the connect dialog's client slug is.
39
+ const trackClientAccessRevoked = ({
40
+ clientId,
41
+ clientName,
42
+ revokedForOther,
43
+ }: { clientId: string; clientName: string; revokedForOther: boolean }) => {
44
+ telemetry.track(TELEMETRY_EVENT.MCP.USER_REVOKED_MCP_CLIENT_ACCESS, {
45
+ client_id: clientId,
46
+ client_brand: getMcpClientBrand(clientName),
47
+ client_type: getMcpClientType(clientName),
48
+ revoked_for_other: revokedForOther,
49
+ });
50
+ };
51
+
52
+ return {
53
+ trackMcpAccessEnabledForWorkflow,
54
+ trackMcpAccessEnabledForAgent,
55
+ trackUserToggledMcpAccess,
56
+ trackAutoExposeToggled,
57
+ trackConnectClientClicked,
58
+ trackViewedAllClients,
59
+ trackClientAccessRevoked,
60
+ };
61
+ }
@@ -0,0 +1,90 @@
1
+ import { ref } from 'vue';
2
+ import type { OAuthClientResponseDto } from '@n8n/api-types';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import { useToast } from '@n8n/composables/useToast';
5
+ import { useUsersStore } from '@n8n/stores/users.store';
6
+
7
+ import { useMcp } from './useMcp';
8
+ import { useMCPStore } from '../mcp.store';
9
+
10
+ /**
11
+ * The confirm-then-revoke flow for a connected client, shared by the settings
12
+ * overview and the clients page. `requestRevoke` opens the confirmation (the
13
+ * pending client is the dialog's `open` state), `confirmRevoke` performs the
14
+ * revoke and reports it, `cancelRevoke` dismisses.
15
+ *
16
+ * `onRevoked` runs after a successful revoke so the caller can refresh its own
17
+ * view of the data. It owns its error handling: the revoke already succeeded,
18
+ * so a failed refresh must not be reported as a failed revoke.
19
+ *
20
+ * `refreshList` (default true) refetches the clients page's list after the
21
+ * revoke. Callers that don't render that list (the overview) turn it off and
22
+ * refresh their own data in `onRevoked`, so the list's persisted ownership and
23
+ * filters are never requested from a page that doesn't show them.
24
+ */
25
+ export function useOAuthClientRevoke(
26
+ options: { onRevoked?: () => Promise<void> | void; refreshList?: boolean } = {},
27
+ ) {
28
+ const i18n = useI18n();
29
+ const toast = useToast();
30
+ const mcp = useMcp();
31
+ const mcpStore = useMCPStore();
32
+ const usersStore = useUsersStore();
33
+
34
+ const revokeClient = ref<OAuthClientResponseDto | null>(null);
35
+ const revoking = ref(false);
36
+
37
+ /** An admin revoking someone else's grant rather than their own. */
38
+ const isRevokingForOther = (client: OAuthClientResponseDto) =>
39
+ !!client.owner && client.owner.id !== usersStore.currentUser?.id;
40
+
41
+ const requestRevoke = (client: OAuthClientResponseDto) => {
42
+ revokeClient.value = client;
43
+ };
44
+
45
+ const cancelRevoke = () => {
46
+ revokeClient.value = null;
47
+ };
48
+
49
+ const confirmRevoke = async () => {
50
+ const client = revokeClient.value;
51
+ if (!client) return;
52
+ let revoked = false;
53
+ try {
54
+ revoking.value = true;
55
+ await mcpStore.removeOAuthClient(client.id, client.owner?.id, {
56
+ refreshList: options.refreshList ?? true,
57
+ });
58
+ revoked = true;
59
+ mcp.trackClientAccessRevoked({
60
+ clientId: client.id,
61
+ clientName: client.name,
62
+ revokedForOther: isRevokingForOther(client),
63
+ });
64
+ toast.showMessage({
65
+ type: 'success',
66
+ title: i18n.baseText('settings.mcp.oAuthClients.revoke.success.title'),
67
+ message: i18n.baseText('settings.mcp.oAuthClients.revoke.success.message', {
68
+ interpolate: { name: client.name },
69
+ }),
70
+ });
71
+ } catch (error) {
72
+ toast.showError(error, i18n.baseText('settings.mcp.oAuthClients.revoke.error'));
73
+ } finally {
74
+ revoking.value = false;
75
+ revokeClient.value = null;
76
+ }
77
+ if (revoked) {
78
+ await options.onRevoked?.();
79
+ }
80
+ };
81
+
82
+ return {
83
+ revokeClient,
84
+ revoking,
85
+ isRevokingForOther,
86
+ requestRevoke,
87
+ cancelRevoke,
88
+ confirmRevoke,
89
+ };
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // The module's public entry for consumers. The shell imports the descriptor from
2
+ // `./mcp.module` instead, so the boot chunk does not pull in the store and the
3
+ // components below.
4
+ export { useMCPStore } from './mcp.store';
5
+ export { useMcp } from './composables/useMcp';
6
+ export type { ToggleWorkflowsMcpAccessResponse } from './mcp.api';
7
+ export { MCP_DOCS_PAGE_URL, MCP_SCOPE_GROUPS, MCP_SETTINGS_VIEW } from './mcp.constants';
8
+ export { getClientBrand } from './clients.utils';
9
+ export { default as McpAccessToggle } from './components/McpAccessToggle.vue';
10
+ export { default as McpClientLogoCards } from './components/McpClientLogoCards.vue';
@@ -0,0 +1,81 @@
1
+ import type { IRestApiContext } from '@n8n/rest-api-client';
2
+ import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client';
3
+
4
+ import { fetchMcpAgents, fetchMcpExposedWorkflows, updateMcpSettings } from './mcp.api';
5
+
6
+ vi.mock('@n8n/rest-api-client', () => ({
7
+ getFullApiResponse: vi.fn(),
8
+ makeRestApiRequest: vi.fn(),
9
+ }));
10
+
11
+ describe('fetchMcpAgents', () => {
12
+ const context = {} as IRestApiContext;
13
+
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ vi.mocked(getFullApiResponse).mockResolvedValue({ count: 0, data: [] });
17
+ });
18
+
19
+ it('omits the filter for a whitespace-only query', async () => {
20
+ await fetchMcpAgents(context, { take: 10, query: ' ' });
21
+
22
+ expect(getFullApiResponse).toHaveBeenCalledWith(context, 'GET', '/mcp/agents', {
23
+ take: 10,
24
+ });
25
+ });
26
+
27
+ it('trims a non-empty query', async () => {
28
+ await fetchMcpAgents(context, { query: ' sales ' });
29
+
30
+ expect(getFullApiResponse).toHaveBeenCalledWith(context, 'GET', '/mcp/agents', {
31
+ filter: JSON.stringify({ query: 'sales' }),
32
+ });
33
+ });
34
+
35
+ it('serializes the MCP availability filter', async () => {
36
+ await fetchMcpAgents(context, { availableInMCP: true });
37
+
38
+ expect(getFullApiResponse).toHaveBeenCalledWith(context, 'GET', '/mcp/agents', {
39
+ filter: JSON.stringify({ availableInMCP: true }),
40
+ });
41
+ });
42
+ });
43
+
44
+ describe('fetchMcpExposedWorkflows', () => {
45
+ const context = {} as IRestApiContext;
46
+
47
+ beforeEach(() => {
48
+ vi.clearAllMocks();
49
+ vi.mocked(getFullApiResponse).mockResolvedValue({ count: 0, data: [] });
50
+ });
51
+
52
+ it('queries the generic workflow list for the exposed page', async () => {
53
+ await fetchMcpExposedWorkflows(context, { skip: 25, take: 25 });
54
+
55
+ expect(getFullApiResponse).toHaveBeenCalledWith(context, 'GET', '/workflows', {
56
+ includeScopes: true,
57
+ filter: { isArchived: false, availableInMCP: true },
58
+ skip: 25,
59
+ take: 25,
60
+ sortBy: 'updatedAt:desc',
61
+ });
62
+ });
63
+ });
64
+
65
+ describe('updateMcpSettings', () => {
66
+ const context = {} as IRestApiContext;
67
+
68
+ beforeEach(() => {
69
+ vi.clearAllMocks();
70
+ });
71
+
72
+ it('sends the given settings as the PATCH body', async () => {
73
+ vi.mocked(makeRestApiRequest).mockResolvedValue({ mcpAccessEnabled: true });
74
+
75
+ await updateMcpSettings(context, { mcpAccessEnabled: true });
76
+
77
+ expect(makeRestApiRequest).toHaveBeenCalledWith(context, 'PATCH', '/mcp/settings', {
78
+ mcpAccessEnabled: true,
79
+ });
80
+ });
81
+ });