@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,223 @@
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 {
6
+ N8nButton,
7
+ N8nSettingsLayout,
8
+ N8nSettingsPageHeader,
9
+ N8nTooltip,
10
+ } from '@n8n/design-system';
11
+ import type { TableOptions } from '@n8n/design-system';
12
+
13
+ import { useDocumentTitle } from '@n8n/composables/useDocumentTitle';
14
+ import { useTelemetry } from '@n8n/composables/useTelemetry';
15
+ import { TELEMETRY_EVENT } from '@n8n/telemetry';
16
+ import { useToast } from '@n8n/composables/useToast';
17
+ import { useSettingsStore } from '@n8n/stores/settings.store';
18
+ import type { McpAgent } from './mcp.types';
19
+ import { useMCPStore } from './mcp.store';
20
+ import { LOADING_INDICATOR_TIMEOUT, MCP_DOCS_PAGE_URL, MCP_SETTINGS_VIEW } from './mcp.constants';
21
+ import AgentsTable from './components/tabs/AgentsTable.vue';
22
+ import MCPConnectAgentsModal from './modals/MCPConnectAgentsModal.vue';
23
+
24
+ const i18n = useI18n();
25
+ const toast = useToast();
26
+ const telemetry = useTelemetry();
27
+ const router = useRouter();
28
+ const documentTitle = useDocumentTitle({
29
+ releaseChannel: useSettingsStore().settings.releaseChannel,
30
+ });
31
+ const mcpStore = useMCPStore();
32
+ const settingsStore = useSettingsStore();
33
+
34
+ const agentsLoading = ref(false);
35
+ const showConnectAgentsDialog = ref(false);
36
+ const availableAgents = ref<McpAgent[]>([]);
37
+ const availableAgentsTotal = ref(0);
38
+ const agentsTableState = ref<TableOptions>({
39
+ page: 0,
40
+ itemsPerPage: 10,
41
+ sortBy: [],
42
+ });
43
+ const agentsTableItemsPerPage = ref(agentsTableState.value.itemsPerPage);
44
+
45
+ const showConnectAgentsButton = computed(() => availableAgentsTotal.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.agents.enableAccess.success.title'
53
+ : 'settings.mcp.agents.removeAccess.success.title',
54
+ {
55
+ adjustToNumber: count,
56
+ interpolate: { count: String(count) },
57
+ },
58
+ ),
59
+ });
60
+ };
61
+
62
+ const fetchAvailableAgents = async () => {
63
+ agentsLoading.value = true;
64
+ try {
65
+ const response = await mcpStore.fetchAgentsAvailableForMCPPage(
66
+ agentsTableState.value.page + 1,
67
+ agentsTableState.value.itemsPerPage,
68
+ );
69
+ if (response.page !== agentsTableState.value.page + 1) {
70
+ agentsTableState.value = { ...agentsTableState.value, page: response.page - 1 };
71
+ }
72
+ availableAgents.value = response.data;
73
+ availableAgentsTotal.value = response.count;
74
+ } catch (error) {
75
+ toast.showError(error, i18n.baseText('settings.mcp.agents.list.error.fetching'));
76
+ } finally {
77
+ setTimeout(() => {
78
+ agentsLoading.value = false;
79
+ }, LOADING_INDICATOR_TIMEOUT);
80
+ }
81
+ };
82
+
83
+ const refreshAgentsFromFirstPage = async () => {
84
+ agentsTableState.value = { ...agentsTableState.value, page: 0 };
85
+ await fetchAvailableAgents();
86
+ };
87
+
88
+ const onAgentsTableUpdate = async (options: TableOptions) => {
89
+ const pageSizeChanged = options.itemsPerPage !== agentsTableItemsPerPage.value;
90
+ agentsTableState.value = { ...options, page: pageSizeChanged ? 0 : options.page };
91
+ agentsTableItemsPerPage.value = options.itemsPerPage;
92
+ await fetchAvailableAgents();
93
+ };
94
+
95
+ const onToggleAgentMCPAccess = async (agentId: string, isEnabled: boolean) => {
96
+ try {
97
+ await mcpStore.toggleAgentMcpAccess(agentId, isEnabled);
98
+ if (isEnabled) {
99
+ await refreshAgentsFromFirstPage();
100
+ } else {
101
+ showMcpAccessUpdatedToast(1, false);
102
+ await fetchAvailableAgents();
103
+ }
104
+ } catch (error) {
105
+ toast.showError(error, i18n.baseText('agents.toggleMCP.error.title'));
106
+ throw error;
107
+ }
108
+ };
109
+
110
+ const onBulkEnableAgentsMCPAccess = async (agentIds: string[]) => {
111
+ try {
112
+ const response = await mcpStore.toggleAgentsMcpAccess({ agentIds }, true);
113
+ showMcpAccessUpdatedToast(response.updatedCount, true);
114
+ await refreshAgentsFromFirstPage();
115
+ } catch (error) {
116
+ toast.showError(error, i18n.baseText('agents.toggleMCP.error.title'));
117
+ throw error;
118
+ }
119
+ };
120
+
121
+ const onBulkRemoveAgentsMCPAccess = async (agentIds: string[]) => {
122
+ try {
123
+ const response = await mcpStore.toggleAgentsMcpAccess({ agentIds }, false);
124
+ showMcpAccessUpdatedToast(response.updatedCount, false);
125
+ await fetchAvailableAgents();
126
+ } catch (error) {
127
+ toast.showError(error, i18n.baseText('agents.toggleMCP.error.title'));
128
+ }
129
+ };
130
+
131
+ const openConnectAgentsModal = () => {
132
+ showConnectAgentsDialog.value = true;
133
+ telemetry.track(TELEMETRY_EVENT.AGENTS.USER_CLICKED_CONNECT_AGENTS_FROM_MCP_SETTINGS, {});
134
+ };
135
+
136
+ const onBack = () => {
137
+ void router.push({ name: MCP_SETTINGS_VIEW });
138
+ };
139
+
140
+ onMounted(async () => {
141
+ documentTitle.set(i18n.baseText('settings.mcp.agentsExposed.page.title'));
142
+ if (!mcpStore.mcpAccessEnabled || !settingsStore.isAgentsEnabled) {
143
+ await router.replace({ name: MCP_SETTINGS_VIEW });
144
+ return;
145
+ }
146
+ await fetchAvailableAgents();
147
+ });
148
+ </script>
149
+
150
+ <template>
151
+ <N8nSettingsLayout
152
+ full-width
153
+ show-back
154
+ :back-label="i18n.baseText('settings.mcp.back')"
155
+ :class="$style.layout"
156
+ @back="onBack"
157
+ >
158
+ <N8nSettingsPageHeader
159
+ :title="i18n.baseText('settings.mcp.agentsExposed.page.title')"
160
+ :description="i18n.baseText('settings.mcp.agentsExposed.page.description')"
161
+ :docs-url="MCP_DOCS_PAGE_URL"
162
+ />
163
+ <div data-test-id="mcp-agents-view">
164
+ <div :class="$style.actions">
165
+ <N8nButton
166
+ v-if="showConnectAgentsButton"
167
+ variant="solid"
168
+ :label="i18n.baseText('settings.mcp.connectAgents')"
169
+ data-test-id="mcp-connect-agents-header-button"
170
+ size="small"
171
+ @click="openConnectAgentsModal"
172
+ />
173
+ <N8nTooltip :content="i18n.baseText('settings.mcp.refresh.tooltip')">
174
+ <N8nButton
175
+ variant="subtle"
176
+ icon-only
177
+ data-test-id="mcp-agents-refresh-button"
178
+ size="small"
179
+ icon="refresh-cw"
180
+ @click="fetchAvailableAgents"
181
+ />
182
+ </N8nTooltip>
183
+ </div>
184
+ <AgentsTable
185
+ v-model:table-options="agentsTableState"
186
+ :agents="availableAgents"
187
+ :total-count="availableAgentsTotal"
188
+ :loading="agentsLoading"
189
+ @remove-mcp-access="(agent) => onToggleAgentMCPAccess(agent.id, false)"
190
+ @bulk-remove-mcp-access="onBulkRemoveAgentsMCPAccess"
191
+ @connect-agents="openConnectAgentsModal"
192
+ @update:options="onAgentsTableUpdate"
193
+ />
194
+ </div>
195
+ <MCPConnectAgentsModal
196
+ v-model:open="showConnectAgentsDialog"
197
+ :enable-mcp-access="onBulkEnableAgentsMCPAccess"
198
+ />
199
+ </N8nSettingsLayout>
200
+ </template>
201
+
202
+ <style lang="scss" module>
203
+ /* Collapse the layout's own top inset; the settings shell already pads the page top. */
204
+ .layout {
205
+ padding-top: 0;
206
+ }
207
+
208
+ /* Pin the back action to the top-left of the settings area (the shell's
209
+ content container is position: relative), independent of the centered column. */
210
+ .layout > div:first-child {
211
+ position: absolute;
212
+ top: var(--spacing--lg);
213
+ left: var(--spacing--lg);
214
+ width: auto;
215
+ }
216
+
217
+ .actions {
218
+ display: flex;
219
+ justify-content: flex-end;
220
+ gap: var(--spacing--2xs);
221
+ margin-bottom: var(--spacing--xs);
222
+ }
223
+ </style>
@@ -0,0 +1,280 @@
1
+ import { nextTick } from 'vue';
2
+ import { createTestingPinia } from '@pinia/testing';
3
+ import { waitFor, within } from '@testing-library/vue';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { createComponentRenderer, mockedStore, type MockedStore } from '@n8n/frontend-test-utils';
6
+ import SettingsMCPClientsView from './SettingsMCPClientsView.vue';
7
+ import { useMCPStore } from './mcp.store';
8
+ import { useSettingsStore } from '@n8n/stores/settings.store';
9
+ import { useUsersStore } from '@n8n/stores/users.store';
10
+ import { useRBACStore } from '@n8n/stores/rbac.store';
11
+ import { mock } from 'vitest-mock-extended';
12
+ import type { IUser } from '@n8n/rest-api-client/api/users';
13
+ import type { FrontendSettings } from '@n8n/api-types';
14
+ import { MCP_SETTINGS_VIEW } from './mcp.constants';
15
+ import { TELEMETRY_EVENT } from '@n8n/telemetry';
16
+
17
+ const { routerPush, routerReplace, route } = vi.hoisted(() => ({
18
+ routerPush: vi.fn(),
19
+ routerReplace: vi.fn(),
20
+ route: { params: {}, query: {} as Record<string, string> },
21
+ }));
22
+
23
+ const { trackSpy } = vi.hoisted(() => ({ trackSpy: vi.fn() }));
24
+
25
+ vi.mock('@n8n/composables/useTelemetry', () => ({
26
+ useTelemetry: () => ({ track: trackSpy }),
27
+ }));
28
+
29
+ vi.mock('vue-router', async (importOriginal) => ({
30
+ ...(await importOriginal()),
31
+ useRouter: () => ({ push: routerPush, replace: routerReplace }),
32
+ useRoute: vi.fn(() => route),
33
+ RouterLink: {
34
+ template: '<a><slot /></a>',
35
+ },
36
+ }));
37
+
38
+ vi.mock('@n8n/composables/useDocumentTitle', () => ({
39
+ useDocumentTitle: () => ({
40
+ set: vi.fn(),
41
+ }),
42
+ }));
43
+
44
+ let pinia: ReturnType<typeof createTestingPinia>;
45
+ let mcpStore: MockedStore<typeof useMCPStore>;
46
+ let settingsStore: MockedStore<typeof useSettingsStore>;
47
+ let usersStore: MockedStore<typeof useUsersStore>;
48
+ let rbacStore: MockedStore<typeof useRBACStore>;
49
+
50
+ const createComponent = createComponentRenderer(SettingsMCPClientsView, {
51
+ global: {
52
+ stubs: {
53
+ OAuthClientsTable: {
54
+ inheritAttrs: true,
55
+ template:
56
+ "<div>OAuth Clients Table<button data-test-id=\"stub-revoke-client\" @click=\"$emit('revokeClient', { id: 'client-1', name: 'Claude Code', owner: { id: 'user-2', firstName: 'Jane', lastName: 'Doe', email: 'jane@n8n.io' } })\">Revoke</button><button data-test-id=\"stub-ownership-all\" @click=\"$emit('update:ownership', 'all')\">All</button></div>",
57
+ },
58
+ },
59
+ },
60
+ });
61
+
62
+ describe('SettingsMCPClientsView', () => {
63
+ beforeEach(() => {
64
+ pinia = createTestingPinia();
65
+ mcpStore = mockedStore(useMCPStore);
66
+ settingsStore = mockedStore(useSettingsStore);
67
+ usersStore = mockedStore(useUsersStore);
68
+ rbacStore = mockedStore(useRBACStore);
69
+ rbacStore.hasScope.mockReturnValue(false);
70
+ route.query = {};
71
+ mcpStore.oauthClientsOwnership = 'mine';
72
+
73
+ // The stub row's consent belongs to user-2, so a different current user is
74
+ // what makes revoked_for_other meaningful rather than trivially true.
75
+ usersStore.currentUser = mock<IUser>({ id: 'user-1' });
76
+
77
+ settingsStore.settings = {
78
+ enterprise: {},
79
+ } as FrontendSettings;
80
+
81
+ settingsStore.moduleSettings = {
82
+ mcp: {
83
+ mcpAccessEnabled: true,
84
+ mcpManagedByEnv: false,
85
+ autoExposeNewWorkflows: false,
86
+ },
87
+ };
88
+
89
+ mcpStore.getAllOAuthClients.mockResolvedValue([]);
90
+ });
91
+
92
+ afterEach(() => {
93
+ vi.clearAllMocks();
94
+ });
95
+
96
+ const revokeAndConfirm = async (getByTestId: (id: string) => HTMLElement) => {
97
+ await userEvent.click(getByTestId('stub-revoke-client'));
98
+
99
+ await waitFor(() => {
100
+ expect(
101
+ within(document.body).getByText('Revoke access for "Claude Code"?'),
102
+ ).toBeInTheDocument();
103
+ });
104
+
105
+ await userEvent.click(within(document.body).getByRole('button', { name: 'Revoke' }));
106
+ };
107
+
108
+ it('should redirect to the MCP settings view when MCP is disabled', async () => {
109
+ settingsStore.moduleSettings = {
110
+ mcp: {
111
+ mcpAccessEnabled: false,
112
+ mcpManagedByEnv: false,
113
+ autoExposeNewWorkflows: false,
114
+ },
115
+ };
116
+
117
+ createComponent({ pinia });
118
+ await nextTick();
119
+
120
+ expect(routerReplace).toHaveBeenCalledWith({ name: MCP_SETTINGS_VIEW });
121
+ expect(mcpStore.getAllOAuthClients).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('should fetch the clients on mount and render the table', async () => {
125
+ const { getByTestId } = createComponent({ pinia });
126
+ await nextTick();
127
+
128
+ expect(getByTestId('mcp-oauth-clients-table')).toBeVisible();
129
+ expect(mcpStore.getAllOAuthClients).toHaveBeenCalled();
130
+ });
131
+
132
+ it('should confirm before revoking and pass the consent owner to the store', async () => {
133
+ const { getByTestId } = createComponent({ pinia });
134
+ await nextTick();
135
+
136
+ await userEvent.click(getByTestId('stub-revoke-client'));
137
+
138
+ // nothing is revoked until the dialog is confirmed
139
+ await waitFor(() => {
140
+ expect(
141
+ within(document.body).getByText('Revoke access for "Claude Code"?'),
142
+ ).toBeInTheDocument();
143
+ });
144
+ expect(mcpStore.removeOAuthClient).not.toHaveBeenCalled();
145
+
146
+ await userEvent.click(within(document.body).getByRole('button', { name: 'Revoke' }));
147
+
148
+ await waitFor(() => {
149
+ // The clients page shows the list, so it lets the store refetch it.
150
+ expect(mcpStore.removeOAuthClient).toHaveBeenCalledWith('client-1', 'user-2', {
151
+ refreshList: true,
152
+ });
153
+ });
154
+ });
155
+
156
+ it('should track the revoke once it succeeds, not when the dialog opens', async () => {
157
+ const { getByTestId } = createComponent({ pinia });
158
+ await nextTick();
159
+
160
+ await userEvent.click(getByTestId('stub-revoke-client'));
161
+
162
+ await waitFor(() => {
163
+ expect(
164
+ within(document.body).getByText('Revoke access for "Claude Code"?'),
165
+ ).toBeInTheDocument();
166
+ });
167
+ expect(trackSpy).not.toHaveBeenCalledWith(
168
+ TELEMETRY_EVENT.MCP.USER_REVOKED_MCP_CLIENT_ACCESS,
169
+ expect.anything(),
170
+ );
171
+
172
+ await userEvent.click(within(document.body).getByRole('button', { name: 'Revoke' }));
173
+
174
+ await waitFor(() => {
175
+ expect(trackSpy).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.USER_REVOKED_MCP_CLIENT_ACCESS, {
176
+ client_id: 'client-1',
177
+ client_brand: 'claude',
178
+ client_type: 'cli',
179
+ revoked_for_other: true,
180
+ });
181
+ });
182
+ });
183
+
184
+ it("should report revoked_for_other as false when the grant is the current user's own", async () => {
185
+ usersStore.currentUser = mock<IUser>({ id: 'user-2' });
186
+
187
+ const { getByTestId } = createComponent({ pinia });
188
+ await nextTick();
189
+
190
+ await revokeAndConfirm(getByTestId);
191
+
192
+ await waitFor(() => {
193
+ expect(trackSpy).toHaveBeenCalledWith(
194
+ TELEMETRY_EVENT.MCP.USER_REVOKED_MCP_CLIENT_ACCESS,
195
+ expect.objectContaining({ revoked_for_other: false }),
196
+ );
197
+ });
198
+ });
199
+
200
+ it('should track the switch to the instance-wide clients tab', async () => {
201
+ const { getByTestId } = createComponent({ pinia });
202
+ await nextTick();
203
+
204
+ expect(trackSpy).not.toHaveBeenCalledWith(
205
+ TELEMETRY_EVENT.MCP.USER_VIEWED_ALL_MCP_CLIENTS,
206
+ expect.anything(),
207
+ );
208
+
209
+ await userEvent.click(getByTestId('stub-ownership-all'));
210
+
211
+ await waitFor(() => {
212
+ expect(trackSpy).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.USER_VIEWED_ALL_MCP_CLIENTS, {});
213
+ });
214
+ });
215
+
216
+ it('should reflect a tab switch in the URL before the fetch settles', async () => {
217
+ // Keep the fetch pending: the URL must follow the user's choice, not the
218
+ // (possibly slower, possibly superseded) response.
219
+ mcpStore.setOAuthClientsOwnership.mockReturnValue(new Promise<void>(() => {}));
220
+
221
+ const { getByTestId } = createComponent({ pinia });
222
+ await nextTick();
223
+
224
+ await userEvent.click(getByTestId('stub-ownership-all'));
225
+
226
+ expect(routerReplace).toHaveBeenCalledWith({ query: { tab: 'all' } });
227
+ });
228
+
229
+ describe('?tab deep link', () => {
230
+ it("should open everyone's clients for a manager", async () => {
231
+ rbacStore.hasScope.mockReturnValue(true);
232
+ route.query = { tab: 'all' };
233
+
234
+ createComponent({ pinia });
235
+
236
+ await waitFor(() => {
237
+ expect(mcpStore.setOAuthClientsOwnership).toHaveBeenCalledWith('all');
238
+ });
239
+ expect(mcpStore.getAllOAuthClients).not.toHaveBeenCalled();
240
+ expect(trackSpy).toHaveBeenCalledWith(TELEMETRY_EVENT.MCP.USER_VIEWED_ALL_MCP_CLIENTS, {});
241
+ });
242
+
243
+ it("should stay on the user's own clients without mcp:manage", async () => {
244
+ rbacStore.hasScope.mockReturnValue(false);
245
+ route.query = { tab: 'all' };
246
+
247
+ createComponent({ pinia });
248
+
249
+ await waitFor(() => {
250
+ expect(mcpStore.getAllOAuthClients).toHaveBeenCalled();
251
+ });
252
+ expect(mcpStore.setOAuthClientsOwnership).not.toHaveBeenCalled();
253
+ });
254
+
255
+ it("should switch back to the user's own clients when the store is on everyone's", async () => {
256
+ rbacStore.hasScope.mockReturnValue(true);
257
+ mcpStore.oauthClientsOwnership = 'all';
258
+ route.query = { tab: 'mine' };
259
+
260
+ createComponent({ pinia });
261
+
262
+ await waitFor(() => {
263
+ expect(mcpStore.setOAuthClientsOwnership).toHaveBeenCalledWith('mine');
264
+ });
265
+ });
266
+
267
+ it('should just fetch when the requested tab is already active', async () => {
268
+ rbacStore.hasScope.mockReturnValue(true);
269
+ mcpStore.oauthClientsOwnership = 'all';
270
+ route.query = { tab: 'all' };
271
+
272
+ createComponent({ pinia });
273
+
274
+ await waitFor(() => {
275
+ expect(mcpStore.getAllOAuthClients).toHaveBeenCalled();
276
+ });
277
+ expect(mcpStore.setOAuthClientsOwnership).not.toHaveBeenCalled();
278
+ });
279
+ });
280
+ });
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref } from 'vue';
3
+ import { useRoute, useRouter } from 'vue-router';
4
+ import { useI18n } from '@n8n/i18n';
5
+ import { N8nSettingsLayout, N8nSettingsPageHeader } from '@n8n/design-system';
6
+
7
+ import { useDocumentTitle } from '@n8n/composables/useDocumentTitle';
8
+ import { useSettingsStore } from '@n8n/stores/settings.store';
9
+ import { useToast } from '@n8n/composables/useToast';
10
+ import type { OAuthClientFilters } from './clients.utils';
11
+ import OAuthClientsTable from './components/tabs/OAuthClientsTable.vue';
12
+ import RevokeOAuthClientConfirmModal from './components/RevokeOAuthClientConfirmModal.vue';
13
+ import { LOADING_INDICATOR_TIMEOUT, MCP_DOCS_PAGE_URL, MCP_SETTINGS_VIEW } from './mcp.constants';
14
+ import { useMCPStore } from './mcp.store';
15
+ import { useMcp } from './composables/useMcp';
16
+ import { useOAuthClientRevoke } from './composables/useOAuthClientRevoke';
17
+ import { useRBACStore } from '@n8n/stores/rbac.store';
18
+
19
+ const i18n = useI18n();
20
+ const toast = useToast();
21
+ const mcp = useMcp();
22
+ const route = useRoute();
23
+ const router = useRouter();
24
+ const documentTitle = useDocumentTitle({
25
+ releaseChannel: useSettingsStore().settings.releaseChannel,
26
+ });
27
+ const mcpStore = useMCPStore();
28
+ const rbacStore = useRBACStore();
29
+
30
+ const oAuthClientsLoading = ref(false);
31
+ const { revokeClient, revoking, isRevokingForOther, requestRevoke, cancelRevoke, confirmRevoke } =
32
+ useOAuthClientRevoke();
33
+
34
+ const fetchoAuthCLients = async () => {
35
+ try {
36
+ oAuthClientsLoading.value = true;
37
+ await mcpStore.getAllOAuthClients();
38
+ } catch (error) {
39
+ toast.showError(error, i18n.baseText('settings.mcp.error.fetching.oAuthClients'));
40
+ } finally {
41
+ setTimeout(() => {
42
+ oAuthClientsLoading.value = false;
43
+ }, LOADING_INDICATOR_TIMEOUT);
44
+ }
45
+ };
46
+
47
+ const onOwnershipChange = async (ownership: 'mine' | 'all') => {
48
+ // Reflect the tab in the URL right away (replace keeps history clean /
49
+ // back-button safe). Written before the fetch: the tab is the user's choice,
50
+ // not the fetch result, so a slow earlier fetch can't stamp a stale tab later.
51
+ if (route.query.tab !== ownership) {
52
+ void router.replace({ query: { ...route.query, tab: ownership } });
53
+ }
54
+ try {
55
+ oAuthClientsLoading.value = true;
56
+ await mcpStore.setOAuthClientsOwnership(ownership);
57
+ if (ownership === 'all') {
58
+ mcp.trackViewedAllClients();
59
+ }
60
+ } catch (error) {
61
+ toast.showError(error, i18n.baseText('settings.mcp.error.fetching.oAuthClients'));
62
+ } finally {
63
+ setTimeout(() => {
64
+ oAuthClientsLoading.value = false;
65
+ }, LOADING_INDICATOR_TIMEOUT);
66
+ }
67
+ };
68
+
69
+ /**
70
+ * `?tab=all` deep-links to everyone's clients (the overview sends managers here
71
+ * when they have none of their own). Only honoured for `mcp:manage` holders: the
72
+ * endpoint rejects the instance-wide list for anyone else.
73
+ */
74
+ const requestedOwnership = (): 'mine' | 'all' | undefined => {
75
+ if (route.query.tab === 'all') return rbacStore.hasScope('mcp:manage') ? 'all' : 'mine';
76
+ if (route.query.tab === 'mine') return 'mine';
77
+ return undefined;
78
+ };
79
+
80
+ const onClientsFiltersChange = async (filters: OAuthClientFilters) => {
81
+ try {
82
+ await mcpStore.setOAuthClientsFilters(filters);
83
+ } catch (error) {
84
+ toast.showError(error, i18n.baseText('settings.mcp.error.fetching.oAuthClients'));
85
+ }
86
+ };
87
+
88
+ const onClientsOptionsChange = async (options: { page: number; itemsPerPage: number }) => {
89
+ try {
90
+ await mcpStore.setOAuthClientsPagination(options.page, options.itemsPerPage);
91
+ } catch (error) {
92
+ toast.showError(error, i18n.baseText('settings.mcp.error.fetching.oAuthClients'));
93
+ }
94
+ };
95
+
96
+ const onBack = () => {
97
+ void router.push({ name: MCP_SETTINGS_VIEW });
98
+ };
99
+
100
+ onMounted(async () => {
101
+ documentTitle.set(i18n.baseText('settings.mcp.connectedClients.title'));
102
+ if (!mcpStore.mcpAccessEnabled) {
103
+ await router.replace({ name: MCP_SETTINGS_VIEW });
104
+ return;
105
+ }
106
+ const ownership = requestedOwnership();
107
+ if (ownership && ownership !== mcpStore.oauthClientsOwnership) {
108
+ await onOwnershipChange(ownership);
109
+ return;
110
+ }
111
+ await fetchoAuthCLients();
112
+ });
113
+ </script>
114
+
115
+ <template>
116
+ <N8nSettingsLayout
117
+ full-width
118
+ show-back
119
+ :back-label="i18n.baseText('settings.mcp.back')"
120
+ :class="$style.layout"
121
+ @back="onBack"
122
+ >
123
+ <N8nSettingsPageHeader
124
+ :title="i18n.baseText('settings.mcp.connectedClients.title')"
125
+ :description="i18n.baseText('settings.mcp.connectedClients.description')"
126
+ :docs-url="MCP_DOCS_PAGE_URL"
127
+ />
128
+ <div data-test-id="mcp-clients-view">
129
+ <OAuthClientsTable
130
+ :data-test-id="'mcp-oauth-clients-table'"
131
+ :clients="mcpStore.oauthClients"
132
+ :scope-tools="mcpStore.oauthClientScopeTools"
133
+ :loading="oAuthClientsLoading"
134
+ @revoke-client="requestRevoke"
135
+ @update:ownership="onOwnershipChange"
136
+ @update:filters="onClientsFiltersChange"
137
+ @update:options="onClientsOptionsChange"
138
+ @refresh="fetchoAuthCLients"
139
+ />
140
+ </div>
141
+
142
+ <RevokeOAuthClientConfirmModal
143
+ :client="revokeClient"
144
+ :open="!!revokeClient"
145
+ :loading="revoking"
146
+ :revoking-for-other="!!revokeClient && isRevokingForOther(revokeClient)"
147
+ @confirm="confirmRevoke"
148
+ @cancel="cancelRevoke"
149
+ @update:open="cancelRevoke"
150
+ />
151
+ </N8nSettingsLayout>
152
+ </template>
153
+
154
+ <style lang="scss" module>
155
+ /* Collapse the layout's own top inset; the settings shell already pads the page top. */
156
+ .layout {
157
+ padding-top: 0;
158
+ }
159
+
160
+ /* Pin the back action to the top-left of the settings area (the shell's
161
+ content container is position: relative), independent of the centered column. */
162
+ .layout > div:first-child {
163
+ position: absolute;
164
+ top: var(--spacing--lg);
165
+ left: var(--spacing--lg);
166
+ width: auto;
167
+ }
168
+ </style>