@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,432 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue';
|
|
3
|
+
import { useI18n } from '@n8n/i18n';
|
|
4
|
+
import type { BaseTextKey } from '@n8n/i18n';
|
|
5
|
+
import {
|
|
6
|
+
N8nButton,
|
|
7
|
+
N8nDialog,
|
|
8
|
+
N8nDropdownMenu,
|
|
9
|
+
N8nIcon,
|
|
10
|
+
N8nSettingsRow,
|
|
11
|
+
N8nSettingsRowGroup,
|
|
12
|
+
N8nTabs,
|
|
13
|
+
N8nText,
|
|
14
|
+
} from '@n8n/design-system';
|
|
15
|
+
import type { DropdownMenuItemProps, TabOptions } from '@n8n/design-system';
|
|
16
|
+
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|
17
|
+
import { useMCPStore } from '../mcp.store';
|
|
18
|
+
import { getMcpClientCatalog } from '../mcp.clients.catalog';
|
|
19
|
+
import type { McpClientSetup } from '../mcp.clients.catalog';
|
|
20
|
+
import ConnectionParameter from './ConnectionParameter.vue';
|
|
21
|
+
import McpConfigSnippet from './McpConfigSnippet.vue';
|
|
22
|
+
import McpAccessTokenTab from './McpAccessTokenTab.vue';
|
|
23
|
+
|
|
24
|
+
const i18n = useI18n();
|
|
25
|
+
const telemetry = useTelemetry();
|
|
26
|
+
const mcpStore = useMCPStore();
|
|
27
|
+
|
|
28
|
+
// Prefer the backend's dedicated MCP server URL (falls back to the editor URL in
|
|
29
|
+
// the store), so every copied command/config and the token tab target the endpoint
|
|
30
|
+
// the instance actually exposes.
|
|
31
|
+
const serverUrl = computed(() => mcpStore.serverUrl);
|
|
32
|
+
const catalog = computed(() => getMcpClientCatalog(serverUrl.value));
|
|
33
|
+
const clientsById = computed(
|
|
34
|
+
() =>
|
|
35
|
+
new Map<string, McpClientSetup>(
|
|
36
|
+
catalog.value.flatMap((group) => group.clients.map((client) => [client.id, client])),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
type ConnectMethod = 'oauth' | 'apiKey';
|
|
41
|
+
const activeMethod = ref<ConnectMethod>('oauth');
|
|
42
|
+
const methodTabs = computed<Array<TabOptions<ConnectMethod>>>(() => [
|
|
43
|
+
{ label: i18n.baseText('settings.mcp.connectDialog.method.oauth'), value: 'oauth' },
|
|
44
|
+
{ label: i18n.baseText('settings.mcp.connectDialog.method.apiKey'), value: 'apiKey' },
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const activeClientId = ref('claude-ai');
|
|
48
|
+
|
|
49
|
+
const activeClient = computed(() => clientsById.value.get(activeClientId.value));
|
|
50
|
+
|
|
51
|
+
type MenuItemData = { kind: 'header' } | { kind: 'client'; client: McpClientSetup };
|
|
52
|
+
|
|
53
|
+
// Grouped picker items: each category is a disabled, divided header item
|
|
54
|
+
// (non-selectable) followed by its clients; the active client carries `checked`.
|
|
55
|
+
const clientMenuItems = computed<Array<DropdownMenuItemProps<string, MenuItemData>>>(() => {
|
|
56
|
+
const items: Array<DropdownMenuItemProps<string, MenuItemData>> = [];
|
|
57
|
+
for (const group of catalog.value) {
|
|
58
|
+
items.push({
|
|
59
|
+
id: `header:${group.id}`,
|
|
60
|
+
label: i18n.baseText(`settings.mcp.connectDialog.category.${group.id}` as BaseTextKey),
|
|
61
|
+
disabled: true,
|
|
62
|
+
divided: items.length > 0,
|
|
63
|
+
data: { kind: 'header' },
|
|
64
|
+
});
|
|
65
|
+
for (const client of group.clients) {
|
|
66
|
+
items.push({
|
|
67
|
+
id: client.id,
|
|
68
|
+
label: client.name,
|
|
69
|
+
checked: client.id === activeClientId.value,
|
|
70
|
+
data: { kind: 'client', client },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return items;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const onSelectClient = (id: string) => {
|
|
78
|
+
const item = clientMenuItems.value.find((menuItem) => menuItem.id === id);
|
|
79
|
+
if (item?.data?.kind !== 'client') return;
|
|
80
|
+
activeClientId.value = id;
|
|
81
|
+
telemetry.track('User selected MCP client in connect dialog', { client: id });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const onOpenChange = (open: boolean) => {
|
|
85
|
+
if (open) {
|
|
86
|
+
mcpStore.openConnectPopover();
|
|
87
|
+
} else {
|
|
88
|
+
mcpStore.closeConnectPopover();
|
|
89
|
+
mcpStore.resetCurrentUserMCPKey();
|
|
90
|
+
activeMethod.value = 'oauth';
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const trackCopy = (parameter: string) => {
|
|
95
|
+
telemetry.track('User copied MCP connection parameter', {
|
|
96
|
+
parameter,
|
|
97
|
+
source: 'connect-dialog',
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleTokenTabCopy = (type: 'serverUrl' | 'accessToken' | 'mcpJson') => {
|
|
102
|
+
const itemMap = { serverUrl: 'server-url', accessToken: 'access-token', mcpJson: 'mcp-json' };
|
|
103
|
+
trackCopy(itemMap[type]);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const installTitle = computed(() =>
|
|
107
|
+
i18n.baseText('settings.mcp.connectDialog.install.title', {
|
|
108
|
+
interpolate: { client: activeClient.value?.name ?? '' },
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const installDescription = computed(() =>
|
|
112
|
+
i18n.baseText('settings.mcp.connectDialog.install.description', {
|
|
113
|
+
interpolate: { client: activeClient.value?.name ?? '' },
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
const authenticateDescription = computed(() =>
|
|
117
|
+
activeClient.value?.authCommand
|
|
118
|
+
? i18n.baseText('settings.mcp.connectDialog.authenticate.description.command')
|
|
119
|
+
: i18n.baseText('settings.mcp.connectDialog.authenticate.description.mcpCommand', {
|
|
120
|
+
interpolate: { client: activeClient.value?.name ?? '' },
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
const oneClickDescription = computed(() =>
|
|
124
|
+
activeClient.value?.category === 'web'
|
|
125
|
+
? i18n.baseText('settings.mcp.connectDialog.oneClick.description.web', {
|
|
126
|
+
interpolate: { client: activeClient.value?.name ?? '' },
|
|
127
|
+
})
|
|
128
|
+
: i18n.baseText('settings.mcp.connectDialog.oneClick.description.ide', {
|
|
129
|
+
interpolate: { client: activeClient.value?.name ?? '' },
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const addButtonLabel = computed(() =>
|
|
133
|
+
i18n.baseText('settings.mcp.connectDialog.oneClick.button', {
|
|
134
|
+
interpolate: { client: activeClient.value?.name ?? '' },
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
const serverUrlDescription = computed(() =>
|
|
138
|
+
i18n.baseText('settings.mcp.connectDialog.serverUrl.description', {
|
|
139
|
+
interpolate: { client: activeClient.value?.name ?? '' },
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<template>
|
|
145
|
+
<N8nDialog
|
|
146
|
+
:open="mcpStore.connectPopoverOpen"
|
|
147
|
+
size="large"
|
|
148
|
+
:header="i18n.baseText('settings.mcp.connectDialog.title')"
|
|
149
|
+
:description="i18n.baseText('settings.mcp.connectDialog.description')"
|
|
150
|
+
data-test-id="mcp-connect-dialog"
|
|
151
|
+
@update:open="onOpenChange"
|
|
152
|
+
>
|
|
153
|
+
<div :class="$style.body">
|
|
154
|
+
<N8nTabs
|
|
155
|
+
:model-value="activeMethod"
|
|
156
|
+
:options="methodTabs"
|
|
157
|
+
:class="$style.tabs"
|
|
158
|
+
data-test-id="mcp-connect-method-tabs"
|
|
159
|
+
@update:model-value="activeMethod = $event"
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
<N8nSettingsRowGroup v-if="activeMethod === 'oauth'">
|
|
163
|
+
<N8nSettingsRow
|
|
164
|
+
:title="i18n.baseText('settings.mcp.connectDialog.yourClient.title')"
|
|
165
|
+
:description="i18n.baseText('settings.mcp.connectDialog.yourClient.description')"
|
|
166
|
+
:show-divider="!!activeClient"
|
|
167
|
+
>
|
|
168
|
+
<template #action>
|
|
169
|
+
<N8nDropdownMenu
|
|
170
|
+
:items="clientMenuItems"
|
|
171
|
+
placement="bottom-end"
|
|
172
|
+
max-height="min(var(--reka-dropdown-menu-content-available-height), 30rem)"
|
|
173
|
+
:extra-popper-class="$style['picker-menu']"
|
|
174
|
+
data-test-id="mcp-connect-client-picker"
|
|
175
|
+
@select="onSelectClient"
|
|
176
|
+
>
|
|
177
|
+
<template #trigger>
|
|
178
|
+
<N8nButton
|
|
179
|
+
variant="outline"
|
|
180
|
+
size="medium"
|
|
181
|
+
:aria-label="i18n.baseText('settings.mcp.connectDialog.yourClient.title')"
|
|
182
|
+
data-test-id="mcp-connect-client-picker-trigger"
|
|
183
|
+
>
|
|
184
|
+
<span :class="$style['picker-trigger']">
|
|
185
|
+
<component
|
|
186
|
+
:is="activeClient.icon"
|
|
187
|
+
v-if="activeClient?.icon"
|
|
188
|
+
:class="$style['brand-icon']"
|
|
189
|
+
/>
|
|
190
|
+
<N8nIcon v-else icon="mcp" size="small" color="text-light" />
|
|
191
|
+
{{ activeClient?.name }}
|
|
192
|
+
<N8nIcon icon="chevron-down" size="small" />
|
|
193
|
+
</span>
|
|
194
|
+
</N8nButton>
|
|
195
|
+
</template>
|
|
196
|
+
<template #item-leading="{ item }">
|
|
197
|
+
<template v-if="item.data?.kind === 'client'">
|
|
198
|
+
<component
|
|
199
|
+
:is="item.data.client.icon"
|
|
200
|
+
v-if="item.data.client.icon"
|
|
201
|
+
:class="$style['brand-icon']"
|
|
202
|
+
/>
|
|
203
|
+
<N8nIcon v-else icon="mcp" size="large" color="text-light" />
|
|
204
|
+
</template>
|
|
205
|
+
</template>
|
|
206
|
+
<template #item-label="{ item }">
|
|
207
|
+
<span v-if="item.data?.kind === 'header'" :class="$style.category">
|
|
208
|
+
{{ item.label }}
|
|
209
|
+
</span>
|
|
210
|
+
<N8nText v-else size="medium" color="text-dark" :class="$style['client-label']">
|
|
211
|
+
{{ item.label }}
|
|
212
|
+
</N8nText>
|
|
213
|
+
</template>
|
|
214
|
+
</N8nDropdownMenu>
|
|
215
|
+
</template>
|
|
216
|
+
</N8nSettingsRow>
|
|
217
|
+
|
|
218
|
+
<!-- CLI: one command adds the server (requires the client installed and
|
|
219
|
+
writes its config), OR edit the config file manually → Authenticate. -->
|
|
220
|
+
<template v-if="activeClient?.category === 'cli'">
|
|
221
|
+
<N8nSettingsRow
|
|
222
|
+
layout="vertical"
|
|
223
|
+
:show-divider="false"
|
|
224
|
+
:title="installTitle"
|
|
225
|
+
:description="installDescription"
|
|
226
|
+
>
|
|
227
|
+
<template #action>
|
|
228
|
+
<ConnectionParameter
|
|
229
|
+
id="mcp-install-command"
|
|
230
|
+
:class="$style['copy-field']"
|
|
231
|
+
:label="''"
|
|
232
|
+
:value="activeClient.installCommand ?? ''"
|
|
233
|
+
@copy="trackCopy('install-command')"
|
|
234
|
+
/>
|
|
235
|
+
</template>
|
|
236
|
+
</N8nSettingsRow>
|
|
237
|
+
<N8nSettingsRow
|
|
238
|
+
layout="vertical"
|
|
239
|
+
:show-divider="false"
|
|
240
|
+
:title="i18n.baseText('settings.mcp.connectDialog.configure.titleAlt')"
|
|
241
|
+
:description="i18n.baseText('settings.mcp.connectDialog.configure.descriptionAlt')"
|
|
242
|
+
>
|
|
243
|
+
<template #action>
|
|
244
|
+
<McpConfigSnippet
|
|
245
|
+
:class="$style['copy-field']"
|
|
246
|
+
:value="activeClient.configSnippet ?? ''"
|
|
247
|
+
:language="activeClient.id === 'codex' ? 'toml' : 'json'"
|
|
248
|
+
@copy="trackCopy('config')"
|
|
249
|
+
/>
|
|
250
|
+
</template>
|
|
251
|
+
</N8nSettingsRow>
|
|
252
|
+
<N8nSettingsRow
|
|
253
|
+
layout="vertical"
|
|
254
|
+
:show-divider="false"
|
|
255
|
+
:title="i18n.baseText('settings.mcp.connectDialog.authenticate.title')"
|
|
256
|
+
:description="authenticateDescription"
|
|
257
|
+
>
|
|
258
|
+
<template #action>
|
|
259
|
+
<ConnectionParameter
|
|
260
|
+
id="mcp-auth-command"
|
|
261
|
+
:class="$style['copy-field']"
|
|
262
|
+
:label="''"
|
|
263
|
+
:value="activeClient.authCommand ?? '/mcp'"
|
|
264
|
+
@copy="trackCopy('auth-command')"
|
|
265
|
+
/>
|
|
266
|
+
</template>
|
|
267
|
+
</N8nSettingsRow>
|
|
268
|
+
</template>
|
|
269
|
+
|
|
270
|
+
<!-- Web: one-click connector (when supported) + the mandatory server URL to paste in. -->
|
|
271
|
+
<template v-else-if="activeClient?.category === 'web'">
|
|
272
|
+
<N8nSettingsRow
|
|
273
|
+
v-if="activeClient.addUrl"
|
|
274
|
+
:show-divider="false"
|
|
275
|
+
:title="i18n.baseText('settings.mcp.connectDialog.oneClick.title')"
|
|
276
|
+
:description="oneClickDescription"
|
|
277
|
+
>
|
|
278
|
+
<template #action>
|
|
279
|
+
<N8nButton
|
|
280
|
+
variant="outline"
|
|
281
|
+
size="medium"
|
|
282
|
+
:href="activeClient.addUrl"
|
|
283
|
+
target="_blank"
|
|
284
|
+
data-test-id="mcp-connect-one-click"
|
|
285
|
+
>
|
|
286
|
+
<component
|
|
287
|
+
:is="activeClient.icon"
|
|
288
|
+
v-if="activeClient.icon"
|
|
289
|
+
:class="$style['brand-icon']"
|
|
290
|
+
/>
|
|
291
|
+
{{ addButtonLabel }}
|
|
292
|
+
</N8nButton>
|
|
293
|
+
</template>
|
|
294
|
+
</N8nSettingsRow>
|
|
295
|
+
<N8nSettingsRow
|
|
296
|
+
layout="vertical"
|
|
297
|
+
:show-divider="false"
|
|
298
|
+
:title="i18n.baseText('settings.mcp.connectDialog.serverUrl')"
|
|
299
|
+
:description="serverUrlDescription"
|
|
300
|
+
>
|
|
301
|
+
<template #action>
|
|
302
|
+
<ConnectionParameter
|
|
303
|
+
id="mcp-web-server-url"
|
|
304
|
+
:class="$style['copy-field']"
|
|
305
|
+
:label="''"
|
|
306
|
+
:value="serverUrl"
|
|
307
|
+
@copy="trackCopy('server-url')"
|
|
308
|
+
/>
|
|
309
|
+
</template>
|
|
310
|
+
</N8nSettingsRow>
|
|
311
|
+
</template>
|
|
312
|
+
|
|
313
|
+
<!-- IDE: one-click deep link (when supported) + manual config -->
|
|
314
|
+
<template v-else-if="activeClient">
|
|
315
|
+
<N8nSettingsRow
|
|
316
|
+
v-if="activeClient.deepLink"
|
|
317
|
+
:show-divider="false"
|
|
318
|
+
:title="i18n.baseText('settings.mcp.connectDialog.oneClick.title')"
|
|
319
|
+
:description="oneClickDescription"
|
|
320
|
+
>
|
|
321
|
+
<template #action>
|
|
322
|
+
<N8nButton
|
|
323
|
+
variant="outline"
|
|
324
|
+
size="medium"
|
|
325
|
+
:href="activeClient.deepLink"
|
|
326
|
+
data-test-id="mcp-connect-one-click"
|
|
327
|
+
>
|
|
328
|
+
<component
|
|
329
|
+
:is="activeClient.icon"
|
|
330
|
+
v-if="activeClient.icon"
|
|
331
|
+
:class="$style['brand-icon']"
|
|
332
|
+
/>
|
|
333
|
+
{{ addButtonLabel }}
|
|
334
|
+
</N8nButton>
|
|
335
|
+
</template>
|
|
336
|
+
</N8nSettingsRow>
|
|
337
|
+
<N8nSettingsRow
|
|
338
|
+
layout="vertical"
|
|
339
|
+
:show-divider="false"
|
|
340
|
+
:title="i18n.baseText('settings.mcp.connectDialog.serverUrl')"
|
|
341
|
+
:description="serverUrlDescription"
|
|
342
|
+
>
|
|
343
|
+
<template #action>
|
|
344
|
+
<ConnectionParameter
|
|
345
|
+
id="mcp-server-url"
|
|
346
|
+
:class="$style['copy-field']"
|
|
347
|
+
:label="''"
|
|
348
|
+
:value="serverUrl"
|
|
349
|
+
@copy="trackCopy('server-url')"
|
|
350
|
+
/>
|
|
351
|
+
</template>
|
|
352
|
+
</N8nSettingsRow>
|
|
353
|
+
<N8nSettingsRow
|
|
354
|
+
layout="vertical"
|
|
355
|
+
:show-divider="false"
|
|
356
|
+
:title="i18n.baseText('settings.mcp.connectDialog.configure.title')"
|
|
357
|
+
:description="i18n.baseText('settings.mcp.connectDialog.configure.description')"
|
|
358
|
+
>
|
|
359
|
+
<template #action>
|
|
360
|
+
<McpConfigSnippet
|
|
361
|
+
:class="$style['copy-field']"
|
|
362
|
+
:value="activeClient.configSnippet ?? ''"
|
|
363
|
+
@copy="trackCopy('config')"
|
|
364
|
+
/>
|
|
365
|
+
</template>
|
|
366
|
+
</N8nSettingsRow>
|
|
367
|
+
</template>
|
|
368
|
+
</N8nSettingsRowGroup>
|
|
369
|
+
|
|
370
|
+
<div v-else :class="$style['token-setup']" data-test-id="mcp-connect-token-setup">
|
|
371
|
+
<McpAccessTokenTab :server-url="serverUrl" @copied="handleTokenTabCopy" />
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</N8nDialog>
|
|
375
|
+
</template>
|
|
376
|
+
|
|
377
|
+
<style lang="scss" module>
|
|
378
|
+
/* Body-only scroll so the dialog header stays pinned and tall clients
|
|
379
|
+
(e.g. Claude Code's config block) never run off-screen. */
|
|
380
|
+
.body {
|
|
381
|
+
max-height: calc(100dvh - 16rem);
|
|
382
|
+
/* Extra breathing room so the OAuth/API-key tabs don't crowd the description. */
|
|
383
|
+
margin-top: var(--spacing--md);
|
|
384
|
+
overflow-y: auto;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.tabs {
|
|
388
|
+
margin-bottom: var(--spacing--sm);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.picker-trigger {
|
|
392
|
+
display: inline-flex;
|
|
393
|
+
align-items: center;
|
|
394
|
+
gap: var(--spacing--2xs);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* Give the menu room so client names (e.g. "Claude Code") never wrap. */
|
|
398
|
+
.picker-menu {
|
|
399
|
+
min-width: 15rem;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.client-label {
|
|
403
|
+
white-space: nowrap;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.brand-icon {
|
|
407
|
+
width: 1em;
|
|
408
|
+
height: 1em;
|
|
409
|
+
flex: 0 0 auto;
|
|
410
|
+
font-size: var(--font-size--md);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.category {
|
|
414
|
+
display: block;
|
|
415
|
+
padding-block: var(--spacing--5xs);
|
|
416
|
+
font-size: var(--font-size--3xs);
|
|
417
|
+
font-weight: var(--font-weight--bold);
|
|
418
|
+
letter-spacing: 0.06em;
|
|
419
|
+
text-transform: uppercase;
|
|
420
|
+
color: var(--color--text--tint-1);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* Vertical settings rows hug the action to its content; the copy fields
|
|
424
|
+
should span the full row width. */
|
|
425
|
+
.copy-field {
|
|
426
|
+
width: 100%;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.token-setup {
|
|
430
|
+
margin-top: var(--spacing--2xs);
|
|
431
|
+
}
|
|
432
|
+
</style>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import userEvent from '@testing-library/user-event';
|
|
2
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
3
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
4
|
+
import McpConnectedClientRow from './McpConnectedClientRow.vue';
|
|
5
|
+
import { createOAuthClient } from '../mcp.test.utils';
|
|
6
|
+
|
|
7
|
+
const createComponent = createComponentRenderer(McpConnectedClientRow, {
|
|
8
|
+
pinia: createTestingPinia(),
|
|
9
|
+
global: {
|
|
10
|
+
stubs: {
|
|
11
|
+
N8nTimeAgo: {
|
|
12
|
+
props: ['date'],
|
|
13
|
+
template: '<span>2 minutes ago</span>',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('McpConnectedClientRow', () => {
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('renders the client with its type, connection time and access summary', () => {
|
|
25
|
+
const { getByTestId } = createComponent({
|
|
26
|
+
props: {
|
|
27
|
+
client: createOAuthClient({
|
|
28
|
+
name: 'Cursor',
|
|
29
|
+
scopes: ['workflow:read', 'execution:read', 'workflow:write'],
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const row = getByTestId('mcp-client-preview-row');
|
|
35
|
+
expect(getByTestId('mcp-client-preview-name')).toHaveTextContent('Cursor');
|
|
36
|
+
expect(row).toHaveTextContent('IDE · Connected 2 minutes ago');
|
|
37
|
+
expect(getByTestId('mcp-client-preview-access')).toHaveTextContent(
|
|
38
|
+
'List workflows, Get execution details +1',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('omits the type for clients without a recognized brand', () => {
|
|
43
|
+
const { getByTestId } = createComponent({
|
|
44
|
+
props: { client: createOAuthClient({ name: 'Some Unknown Client' }) },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(getByTestId('mcp-client-preview-row')).not.toHaveTextContent('·');
|
|
48
|
+
expect(getByTestId('mcp-client-preview-row')).toHaveTextContent('Connected 2 minutes ago');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('reads a grant covering every offered scope as full access', () => {
|
|
52
|
+
const { getByTestId } = createComponent({
|
|
53
|
+
props: {
|
|
54
|
+
client: createOAuthClient({ scopes: ['workflow:read', 'execution:read'] }),
|
|
55
|
+
scopeTools: { 'workflow:read': ['list_workflows'], 'execution:read': ['get_execution'] },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(getByTestId('mcp-client-preview-access')).toHaveTextContent('Full access');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('emits click when the row is activated', async () => {
|
|
63
|
+
const { getByTestId, emitted } = createComponent({
|
|
64
|
+
props: { client: createOAuthClient() },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await userEvent.click(getByTestId('mcp-client-preview-name'));
|
|
68
|
+
|
|
69
|
+
expect(emitted('click')).toHaveLength(1);
|
|
70
|
+
expect(emitted('revoke')).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('emits revoke, and not click, when the revoke action is used', async () => {
|
|
74
|
+
const { getByTestId, emitted } = createComponent({
|
|
75
|
+
props: { client: createOAuthClient({ name: 'Claude Code' }) },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const button = getByTestId('mcp-client-preview-revoke-button');
|
|
79
|
+
expect(button).toHaveAccessibleName('Revoke access for Claude Code');
|
|
80
|
+
|
|
81
|
+
await userEvent.click(button);
|
|
82
|
+
|
|
83
|
+
expect(emitted('revoke')).toHaveLength(1);
|
|
84
|
+
expect(emitted('click')).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { I18nT } from 'vue-i18n';
|
|
4
|
+
import type { OAuthClientResponseDto } from '@n8n/api-types';
|
|
5
|
+
import { N8nButton, N8nIcon, N8nSettingsRow, N8nText, N8nTimeAgo } from '@n8n/design-system';
|
|
6
|
+
import { useI18n } from '@n8n/i18n';
|
|
7
|
+
import type { BaseTextKey } from '@n8n/i18n';
|
|
8
|
+
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
9
|
+
|
|
10
|
+
import { getAccessSummary, getClientBrand } from '../clients.utils';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One of the current user's connected clients, previewed inline on the MCP
|
|
14
|
+
* settings overview. The row opens the client's details; the hover-revealed
|
|
15
|
+
* action starts the revoke flow.
|
|
16
|
+
*/
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
client: OAuthClientResponseDto;
|
|
19
|
+
/** Tool names each scope unlocks on this instance; decides when a grant reads as "Full access". */
|
|
20
|
+
scopeTools?: Record<string, string[]>;
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{
|
|
24
|
+
click: [];
|
|
25
|
+
revoke: [];
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const i18n = useI18n();
|
|
29
|
+
const rootStore = useRootStore();
|
|
30
|
+
|
|
31
|
+
const brand = computed(() => getClientBrand(props.client.name));
|
|
32
|
+
|
|
33
|
+
const typeLabel = computed(() => {
|
|
34
|
+
const type = brand.value.type;
|
|
35
|
+
if (!type) return null;
|
|
36
|
+
return i18n.baseText(`settings.mcp.oAuthClients.clientType.${type}` as BaseTextKey);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const offeredScopes = computed(() =>
|
|
40
|
+
props.scopeTools ? Object.keys(props.scopeTools) : undefined,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const accessSummary = computed(() => getAccessSummary(i18n, props.client, offeredScopes.value));
|
|
44
|
+
|
|
45
|
+
const grantedAt = computed(() => new Date(props.client.grantedAt).toISOString());
|
|
46
|
+
|
|
47
|
+
// One translatable phrase per shape ("IDE · Connected 2 minutes ago" / "Connected
|
|
48
|
+
// 2 minutes ago"), so locales own the separator and word order; the relative time
|
|
49
|
+
// is a component, hence slot interpolation rather than a string.
|
|
50
|
+
const metaKeypath = computed(() =>
|
|
51
|
+
typeLabel.value
|
|
52
|
+
? 'settings.mcp.connectedClients.preview.meta'
|
|
53
|
+
: 'settings.mcp.connectedClients.preview.metaWithoutType',
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const revokeLabel = computed(() =>
|
|
57
|
+
i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccessFor', {
|
|
58
|
+
interpolate: { name: props.client.name },
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<N8nSettingsRow
|
|
65
|
+
:title="client.name"
|
|
66
|
+
show-visual
|
|
67
|
+
clickable
|
|
68
|
+
reveal-actions-on-hover
|
|
69
|
+
data-test-id="mcp-client-preview-row"
|
|
70
|
+
@click="emit('click')"
|
|
71
|
+
>
|
|
72
|
+
<template #visual>
|
|
73
|
+
<span :class="$style.tile">
|
|
74
|
+
<component :is="brand.icon" v-if="brand.icon" :class="$style.icon" />
|
|
75
|
+
<N8nIcon v-else icon="mcp" :class="$style.icon" />
|
|
76
|
+
</span>
|
|
77
|
+
</template>
|
|
78
|
+
<template #info>
|
|
79
|
+
<div :class="$style.info">
|
|
80
|
+
<N8nText
|
|
81
|
+
bold
|
|
82
|
+
size="medium"
|
|
83
|
+
color="text-dark"
|
|
84
|
+
:class="$style.line"
|
|
85
|
+
data-test-id="mcp-client-preview-name"
|
|
86
|
+
>
|
|
87
|
+
{{ client.name }}
|
|
88
|
+
</N8nText>
|
|
89
|
+
<N8nText size="small" color="text-light" :class="$style.line">
|
|
90
|
+
<I18nT :keypath="metaKeypath" scope="global" tag="span">
|
|
91
|
+
<template v-if="typeLabel" #type>{{ typeLabel }}</template>
|
|
92
|
+
<template #timeAgo
|
|
93
|
+
><N8nTimeAgo :date="grantedAt" :locale="rootStore.defaultLocale"
|
|
94
|
+
/></template>
|
|
95
|
+
</I18nT>
|
|
96
|
+
</N8nText>
|
|
97
|
+
<N8nText
|
|
98
|
+
size="xsmall"
|
|
99
|
+
color="text-light"
|
|
100
|
+
:class="$style.line"
|
|
101
|
+
data-test-id="mcp-client-preview-access"
|
|
102
|
+
>
|
|
103
|
+
{{ accessSummary }}
|
|
104
|
+
</N8nText>
|
|
105
|
+
</div>
|
|
106
|
+
</template>
|
|
107
|
+
<template #action>
|
|
108
|
+
<N8nButton
|
|
109
|
+
variant="outline"
|
|
110
|
+
size="small"
|
|
111
|
+
:label="i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccess')"
|
|
112
|
+
:aria-label="revokeLabel"
|
|
113
|
+
data-test-id="mcp-client-preview-revoke-button"
|
|
114
|
+
@click="emit('revoke')"
|
|
115
|
+
/>
|
|
116
|
+
</template>
|
|
117
|
+
</N8nSettingsRow>
|
|
118
|
+
</template>
|
|
119
|
+
|
|
120
|
+
<style lang="scss" module>
|
|
121
|
+
/* Fills the row's bordered visual box. Fixed white so dark brand marks stay visible on the dark theme. */
|
|
122
|
+
.tile {
|
|
123
|
+
display: inline-flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: center;
|
|
126
|
+
width: 100%;
|
|
127
|
+
height: 100%;
|
|
128
|
+
background-color: var(--color--neutral-white);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.icon {
|
|
132
|
+
width: var(--spacing--md);
|
|
133
|
+
height: var(--spacing--md);
|
|
134
|
+
/* the tile is always white, so the fallback MCP glyph must stay dark in both themes */
|
|
135
|
+
color: var(--color--neutral-black);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* Mirrors the row's own title/description stack so the extra access line slots in beneath them. */
|
|
139
|
+
.info {
|
|
140
|
+
display: flex;
|
|
141
|
+
flex-direction: column;
|
|
142
|
+
gap: var(--spacing--5xs);
|
|
143
|
+
min-width: 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.line {
|
|
147
|
+
display: block;
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
text-overflow: ellipsis;
|
|
150
|
+
white-space: nowrap;
|
|
151
|
+
}
|
|
152
|
+
</style>
|