@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,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>
|