@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,312 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import type { McpAgent } from '../../mcp.types';
5
+ import type { TableHeader, TableOptions, UserAction } from '@n8n/design-system';
6
+ import {
7
+ N8nActionToggle,
8
+ N8nButton,
9
+ N8nDataTableServer,
10
+ N8nLink,
11
+ N8nLoading,
12
+ N8nSelectedItemsInfo,
13
+ N8nText,
14
+ } from '@n8n/design-system';
15
+ import { AGENT_VIEW, PROJECT_AGENTS } from '@n8n/frontend-constants/agents';
16
+ import { useRouter } from 'vue-router';
17
+
18
+ type Props = {
19
+ agents: McpAgent[];
20
+ totalCount?: number;
21
+ loading: boolean;
22
+ };
23
+
24
+ const props = defineProps<Props>();
25
+
26
+ const tableOptions = defineModel<TableOptions>('tableOptions', {
27
+ default: () => ({
28
+ page: 0,
29
+ itemsPerPage: 10,
30
+ sortBy: [],
31
+ }),
32
+ });
33
+
34
+ const tablePage = computed({
35
+ get: () => tableOptions.value.page,
36
+ set: (page: number) => {
37
+ tableOptions.value = { ...tableOptions.value, page };
38
+ },
39
+ });
40
+
41
+ const tableItemsPerPage = computed({
42
+ get: () => tableOptions.value.itemsPerPage,
43
+ set: (itemsPerPage: number) => {
44
+ tableOptions.value = { ...tableOptions.value, itemsPerPage };
45
+ },
46
+ });
47
+
48
+ const tableSortBy = computed({
49
+ get: () => tableOptions.value.sortBy,
50
+ set: (sortBy: TableOptions['sortBy']) => {
51
+ tableOptions.value = { ...tableOptions.value, sortBy };
52
+ },
53
+ });
54
+
55
+ const emit = defineEmits<{
56
+ removeMcpAccess: [agent: McpAgent];
57
+ bulkRemoveMcpAccess: [agentIds: string[]];
58
+ connectAgents: [];
59
+ 'update:options': [payload: TableOptions];
60
+ }>();
61
+
62
+ const i18n = useI18n();
63
+ const router = useRouter();
64
+
65
+ const itemsLength = computed(() => props.totalCount ?? props.agents.length);
66
+
67
+ const selectedAgentIds = ref<string[]>([]);
68
+
69
+ // Selection references loaded rows, so any data reload invalidates it
70
+ watch(
71
+ () => props.agents,
72
+ () => {
73
+ selectedAgentIds.value = [];
74
+ },
75
+ );
76
+
77
+ const clearSelection = () => {
78
+ selectedAgentIds.value = [];
79
+ };
80
+
81
+ const onBulkRemoveMcpAccess = () => {
82
+ emit('bulkRemoveMcpAccess', selectedAgentIds.value);
83
+ };
84
+
85
+ const tableHeaders = ref<Array<TableHeader<McpAgent>>>([
86
+ {
87
+ title: i18n.baseText('settings.mcp.agents.table.column.name'),
88
+ key: 'agent',
89
+ width: 300,
90
+ disableSort: true,
91
+ value() {
92
+ return;
93
+ },
94
+ },
95
+ {
96
+ title: i18n.baseText('settings.mcp.agents.table.column.location'),
97
+ key: 'location',
98
+ width: 300,
99
+ disableSort: true,
100
+ value() {
101
+ return;
102
+ },
103
+ },
104
+ {
105
+ title: '',
106
+ key: 'actions',
107
+ align: 'end',
108
+ width: 50,
109
+ disableSort: true,
110
+ value() {
111
+ return;
112
+ },
113
+ },
114
+ ]);
115
+
116
+ const availableActions: Array<UserAction<McpAgent>> = [
117
+ {
118
+ label: i18n.baseText('settings.mcp.agents.table.action.removeMCPAccess'),
119
+ value: 'removeFromMCP',
120
+ },
121
+ ];
122
+
123
+ const onAgentAction = (action: string, agent: McpAgent) => {
124
+ if (action === 'removeFromMCP') {
125
+ emit('removeMcpAccess', agent);
126
+ }
127
+ };
128
+
129
+ const onConnectClick = () => {
130
+ emit('connectAgents');
131
+ };
132
+
133
+ const agentLink = (agent: McpAgent) =>
134
+ router.resolve({
135
+ name: AGENT_VIEW,
136
+ params: { projectId: agent.projectId, agentId: agent.id },
137
+ }).fullPath;
138
+
139
+ const projectName = (agent: McpAgent) =>
140
+ agent.project?.type === 'personal'
141
+ ? i18n.baseText('projects.menu.personal')
142
+ : (agent.project?.name ?? '');
143
+
144
+ const projectLink = (agent: McpAgent) =>
145
+ router.resolve({
146
+ name: PROJECT_AGENTS,
147
+ params: { projectId: agent.projectId },
148
+ }).fullPath;
149
+ </script>
150
+
151
+ <template>
152
+ <div>
153
+ <div v-if="props.loading">
154
+ <N8nLoading :loading="props.loading" variant="h1" class="mb-l" />
155
+ <N8nLoading :loading="props.loading" variant="p" :rows="5" :shrink-last="false" />
156
+ </div>
157
+ <div v-else class="mt-s mb-xl" :class="$style['table-container']">
158
+ <N8nDataTableServer
159
+ v-model:sort-by="tableSortBy"
160
+ v-model:page="tablePage"
161
+ v-model:items-per-page="tableItemsPerPage"
162
+ v-model:selection="selectedAgentIds"
163
+ :class="$style['agent-table']"
164
+ data-test-id="mcp-agent-table"
165
+ :headers="tableHeaders"
166
+ :items="props.agents"
167
+ :items-length="itemsLength"
168
+ :page-sizes="[10, 25, 50]"
169
+ :show-select="itemsLength > 0"
170
+ @update:options="emit('update:options', $event)"
171
+ >
172
+ <template v-if="itemsLength === 0" #cover>
173
+ <div :class="$style['empty-state']">
174
+ <N8nText data-test-id="mcp-agent-table-empty-state" size="large" color="text-base">
175
+ {{ i18n.baseText('settings.mcp.agents.table.empty.title') }}
176
+ </N8nText>
177
+ <N8nText
178
+ data-test-id="mcp-agent-table-empty-state-description"
179
+ size="small"
180
+ color="text-base"
181
+ >
182
+ {{ i18n.baseText('settings.mcp.agents.table.empty.description') }}
183
+ </N8nText>
184
+ <N8nButton
185
+ variant="solid"
186
+ data-test-id="mcp-agent-table-empty-state-button"
187
+ :label="i18n.baseText('settings.mcp.connectAgents')"
188
+ @click="onConnectClick"
189
+ />
190
+ </div>
191
+ </template>
192
+ <template #[`item.agent`]="{ item }">
193
+ <div :class="$style['agent-cell']" data-test-id="mcp-agent-cell">
194
+ <N8nLink
195
+ data-test-id="mcp-agent-name-link"
196
+ :new-window="true"
197
+ :to="agentLink(item)"
198
+ :theme="'text'"
199
+ :class="[$style['table-link'], $style.truncate]"
200
+ >
201
+ <N8nText :class="$style.truncate" data-test-id="mcp-agent-name">{{
202
+ item.name
203
+ }}</N8nText>
204
+ </N8nLink>
205
+ </div>
206
+ </template>
207
+ <template #[`item.location`]="{ item }">
208
+ <div :class="$style['location-cell']" data-test-id="mcp-agent-location-cell">
209
+ <N8nLink
210
+ data-test-id="mcp-agent-project-link"
211
+ :new-window="true"
212
+ :to="projectLink(item)"
213
+ :theme="'text'"
214
+ :class="[$style['table-link'], $style.truncate]"
215
+ >
216
+ <N8nText :class="$style.truncate" data-test-id="mcp-agent-project-name">{{
217
+ projectName(item)
218
+ }}</N8nText>
219
+ </N8nLink>
220
+ </div>
221
+ </template>
222
+ <template #[`item.actions`]="{ item }">
223
+ <N8nActionToggle
224
+ :class="$style['action-toggle']"
225
+ data-test-id="mcp-agent-action-toggle"
226
+ placement="bottom"
227
+ :actions="availableActions"
228
+ theme="dark"
229
+ @action="onAgentAction($event, item)"
230
+ />
231
+ </template>
232
+ </N8nDataTableServer>
233
+ <N8nSelectedItemsInfo
234
+ :class="$style['selection-bar']"
235
+ :selected-count="selectedAgentIds.length"
236
+ @clear-selection="clearSelection"
237
+ >
238
+ <template #actions>
239
+ <N8nButton
240
+ variant="subtle"
241
+ data-test-id="mcp-bulk-remove-agent-access-button"
242
+ :label="i18n.baseText('settings.mcp.agents.table.action.removeMCPAccess')"
243
+ @click="onBulkRemoveMcpAccess"
244
+ />
245
+ </template>
246
+ </N8nSelectedItemsInfo>
247
+ </div>
248
+ </div>
249
+ </template>
250
+
251
+ <style module lang="scss">
252
+ // The selection bar's default absolute positioning assumes a tall anchor
253
+ // container and overlaps the header when the table is short. Sticky keeps
254
+ // it below the table, floating at the viewport bottom only while a long
255
+ // table extends past it.
256
+ .table-container .selection-bar {
257
+ position: sticky;
258
+ bottom: var(--spacing--3xl);
259
+ left: auto;
260
+ transform: none;
261
+ width: fit-content;
262
+ margin: 0 auto;
263
+ }
264
+
265
+ .agent-table {
266
+ margin-bottom: var(--spacing--sm);
267
+
268
+ :global(.table-scroll) {
269
+ overflow-y: hidden;
270
+ }
271
+
272
+ tr:last-child {
273
+ border-bottom: none !important;
274
+ }
275
+ }
276
+
277
+ .empty-state {
278
+ display: flex;
279
+ flex-direction: column;
280
+ align-items: center;
281
+ justify-content: center;
282
+ gap: var(--spacing--sm);
283
+ padding: var(--spacing--lg) 0;
284
+ min-height: 250px;
285
+ }
286
+
287
+ .agent-cell {
288
+ display: flex;
289
+ padding: var(--spacing--2xs) 0;
290
+ min-width: 0;
291
+ overflow: hidden;
292
+ }
293
+
294
+ .location-cell {
295
+ display: flex;
296
+ padding: var(--spacing--2xs) 0;
297
+ min-width: 0;
298
+ overflow: hidden;
299
+ }
300
+
301
+ .table-link {
302
+ color: var(--color--text);
303
+ }
304
+
305
+ .truncate {
306
+ display: block;
307
+ overflow: hidden;
308
+ text-overflow: ellipsis;
309
+ white-space: nowrap;
310
+ min-width: 0;
311
+ }
312
+ </style>
@@ -0,0 +1,71 @@
1
+ <script lang="ts" setup>
2
+ import { computed } from 'vue';
3
+ import type { OAuthClientResponseDto } from '@n8n/api-types';
4
+ import { N8nAvatar, N8nText } from '@n8n/design-system';
5
+ import { useI18n } from '@n8n/i18n';
6
+
7
+ const props = defineProps<{
8
+ owner: NonNullable<OAuthClientResponseDto['owner']>;
9
+ isCurrentUser?: boolean;
10
+ }>();
11
+
12
+ const i18n = useI18n();
13
+
14
+ // Pending or nameless users fall back to their email, which is never empty.
15
+ const displayName = computed(() => {
16
+ const name = [props.owner.firstName, props.owner.lastName].filter(Boolean).join(' ').trim();
17
+ return name || props.owner.email;
18
+ });
19
+ </script>
20
+
21
+ <template>
22
+ <div :class="$style.cell" data-test-id="mcp-client-owner-cell">
23
+ <N8nAvatar
24
+ :first-name="owner.firstName ?? ''"
25
+ :last-name="owner.lastName ?? ''"
26
+ size="xsmall"
27
+ :class="$style.avatar"
28
+ />
29
+ <div :class="$style.info">
30
+ <N8nText size="small" color="text-dark" :class="$style.name">
31
+ {{ displayName }}
32
+ <!-- text-base: subtler than the name but the lightest DS text color
33
+ that still passes WCAG AA contrast on the row background. -->
34
+ <N8nText v-if="isCurrentUser" size="small" color="text-base">
35
+ {{ i18n.baseText('settings.mcp.oAuthClients.owner.you') }}
36
+ </N8nText>
37
+ </N8nText>
38
+ <N8nText size="xsmall" color="text-light" :class="$style.email" data-test-id="user-email">
39
+ {{ owner.email }}
40
+ </N8nText>
41
+ </div>
42
+ </div>
43
+ </template>
44
+
45
+ <style lang="scss" module>
46
+ /* Same compact owner treatment as the API keys table, so the two settings tables read alike. */
47
+ .cell {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: var(--spacing--2xs);
51
+ min-width: 0;
52
+ }
53
+
54
+ .avatar {
55
+ flex-shrink: 0;
56
+ }
57
+
58
+ .info {
59
+ display: flex;
60
+ flex-direction: column;
61
+ min-width: 0;
62
+ }
63
+
64
+ .name,
65
+ .email {
66
+ display: block;
67
+ overflow: hidden;
68
+ text-overflow: ellipsis;
69
+ white-space: nowrap;
70
+ }
71
+ </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 OAuthClientsFilters from './OAuthClientsFilters.vue';
5
+ import { EMPTY_OAUTH_CLIENT_FILTERS } from '../../clients.utils';
6
+
7
+ const createComponent = createComponentRenderer(OAuthClientsFilters, {
8
+ pinia: createTestingPinia(),
9
+ });
10
+
11
+ describe('OAuthClientsFilters', () => {
12
+ afterEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+
16
+ it('should render the type and connected selects but no owner select on the mine view', async () => {
17
+ const { getByTestId, queryByTestId } = createComponent({
18
+ props: {
19
+ modelValue: { ...EMPTY_OAUTH_CLIENT_FILTERS },
20
+ showOwnerFilter: false,
21
+ },
22
+ });
23
+
24
+ await userEvent.click(getByTestId('mcp-clients-filters-trigger'));
25
+
26
+ expect(getByTestId('mcp-clients-filters-dropdown')).toBeVisible();
27
+ expect(getByTestId('mcp-clients-filter-type')).toBeInTheDocument();
28
+ expect(getByTestId('mcp-clients-filter-connected')).toBeInTheDocument();
29
+ expect(queryByTestId('mcp-clients-filter-owner')).not.toBeInTheDocument();
30
+ });
31
+
32
+ it('should render the owner select on the all view', async () => {
33
+ const { getByTestId } = createComponent({
34
+ props: {
35
+ modelValue: { ...EMPTY_OAUTH_CLIENT_FILTERS },
36
+ showOwnerFilter: true,
37
+ owners: [{ id: 'user-1', firstName: 'Jane', lastName: 'Doe', email: 'jane@n8n.io' }],
38
+ },
39
+ });
40
+
41
+ await userEvent.click(getByTestId('mcp-clients-filters-trigger'));
42
+
43
+ expect(getByTestId('mcp-clients-filter-owner')).toBeInTheDocument();
44
+ });
45
+
46
+ it('should show the active filter count on the trigger', () => {
47
+ const { getByTestId } = createComponent({
48
+ props: {
49
+ modelValue: { ...EMPTY_OAUTH_CLIENT_FILTERS, type: 'cli', connected: 'last7' },
50
+ },
51
+ });
52
+
53
+ expect(getByTestId('mcp-clients-filters-count')).toHaveTextContent('2');
54
+ });
55
+
56
+ it('should not count the text search as an active filter', () => {
57
+ const { queryByTestId } = createComponent({
58
+ props: {
59
+ modelValue: { ...EMPTY_OAUTH_CLIENT_FILTERS, search: 'claude' },
60
+ },
61
+ });
62
+
63
+ expect(queryByTestId('mcp-clients-filters-count')).not.toBeInTheDocument();
64
+ });
65
+
66
+ it('should reset the popover filters but keep the search term', async () => {
67
+ const { getByTestId, emitted } = createComponent({
68
+ props: {
69
+ modelValue: {
70
+ search: 'claude',
71
+ type: 'cli' as const,
72
+ ownerId: 'user-1',
73
+ connected: 'last7' as const,
74
+ },
75
+ showOwnerFilter: true,
76
+ },
77
+ });
78
+
79
+ await userEvent.click(getByTestId('mcp-clients-filters-trigger'));
80
+ await userEvent.click(getByTestId('mcp-clients-filters-reset'));
81
+
82
+ expect(emitted('update:modelValue')).toEqual([
83
+ [{ search: 'claude', type: null, ownerId: null, connected: null }],
84
+ ]);
85
+ });
86
+ });
@@ -0,0 +1,201 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import type { BaseTextKey } from '@n8n/i18n';
5
+ import type { IUser } from '@n8n/design-system';
6
+ import {
7
+ N8nBadge,
8
+ N8nButton,
9
+ N8nInputLabel,
10
+ N8nLink,
11
+ N8nOption,
12
+ N8nPopover,
13
+ N8nSelect,
14
+ N8nUserSelect,
15
+ } from '@n8n/design-system';
16
+
17
+ import type { McpClientConnectedPeriod, McpClientTypeFilter } from '@n8n/api-types';
18
+ import { MCP_CLIENT_CONNECTED_PERIODS, MCP_CLIENT_TYPE_FILTERS } from '@n8n/api-types';
19
+
20
+ import type { OAuthClientFilters } from '../../clients.utils';
21
+
22
+ const props = defineProps<{
23
+ modelValue: OAuthClientFilters;
24
+ /** Consent owners offered by the "Connected by" select (All tab only). */
25
+ owners?: IUser[];
26
+ showOwnerFilter?: boolean;
27
+ currentUserId?: string;
28
+ }>();
29
+
30
+ const emit = defineEmits<{
31
+ 'update:modelValue': [value: OAuthClientFilters];
32
+ }>();
33
+
34
+ const i18n = useI18n();
35
+
36
+ const CLIENT_TYPE_OPTIONS: readonly McpClientTypeFilter[] = MCP_CLIENT_TYPE_FILTERS;
37
+ const CONNECTED_OPTIONS: readonly McpClientConnectedPeriod[] = MCP_CLIENT_CONNECTED_PERIODS;
38
+
39
+ const connectedOptionLabels: Record<McpClientConnectedPeriod, string> = {
40
+ last7: i18n.baseText('settings.mcp.oAuthClients.filters.connected.lastXDays', {
41
+ interpolate: { count: 7 },
42
+ }),
43
+ last30: i18n.baseText('settings.mcp.oAuthClients.filters.connected.lastXDays', {
44
+ interpolate: { count: 30 },
45
+ }),
46
+ older: i18n.baseText('settings.mcp.oAuthClients.filters.connected.older'),
47
+ };
48
+
49
+ // The search input lives outside the popover, so it doesn't count as a filter.
50
+ const filtersLength = computed(() => {
51
+ const { type, ownerId, connected } = props.modelValue;
52
+ return [type, ownerId, connected].filter((value) => value !== null).length;
53
+ });
54
+
55
+ const hasFilters = computed(() => filtersLength.value > 0);
56
+
57
+ // Selects can't carry null values, so '' stands in for "no filter".
58
+ function setKeyValue(key: keyof OAuthClientFilters, value: string) {
59
+ emit('update:modelValue', { ...props.modelValue, [key]: value === '' ? null : value });
60
+ }
61
+
62
+ function resetFilters() {
63
+ emit('update:modelValue', { ...props.modelValue, type: null, ownerId: null, connected: null });
64
+ }
65
+ </script>
66
+
67
+ <template>
68
+ <N8nPopover width="304px" :content-class="$style['popover-content']" align="end">
69
+ <template #trigger>
70
+ <span :class="$style['trigger-wrapper']">
71
+ <N8nButton
72
+ variant="outline"
73
+ icon="funnel"
74
+ size="medium"
75
+ :icon-only="!hasFilters"
76
+ :active="hasFilters"
77
+ :aria-label="i18n.baseText('forms.resourceFiltersDropdown.filters')"
78
+ data-test-id="mcp-clients-filters-trigger"
79
+ >
80
+ <N8nBadge
81
+ v-if="hasFilters"
82
+ :class="$style['filter-button-count']"
83
+ data-test-id="mcp-clients-filters-count"
84
+ variant="primary"
85
+ >
86
+ {{ filtersLength }}
87
+ </N8nBadge>
88
+ <span v-if="hasFilters">
89
+ {{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
90
+ </span>
91
+ </N8nButton>
92
+ </span>
93
+ </template>
94
+ <template #content>
95
+ <div :class="$style['filters-dropdown']" data-test-id="mcp-clients-filters-dropdown">
96
+ <N8nInputLabel
97
+ :label="i18n.baseText('settings.mcp.oAuthClients.filters.clientType')"
98
+ :bold="false"
99
+ size="small"
100
+ color="text-base"
101
+ class="mb-3xs"
102
+ />
103
+ <N8nSelect
104
+ :model-value="modelValue.type ?? ''"
105
+ size="medium"
106
+ data-test-id="mcp-clients-filter-type"
107
+ @update:model-value="setKeyValue('type', $event)"
108
+ >
109
+ <N8nOption
110
+ value=""
111
+ :label="i18n.baseText('settings.mcp.oAuthClients.filters.clientType.all')"
112
+ />
113
+ <N8nOption
114
+ v-for="type in CLIENT_TYPE_OPTIONS"
115
+ :key="type"
116
+ :value="type"
117
+ :label="
118
+ i18n.baseText(`settings.mcp.oAuthClients.filters.clientType.${type}` as BaseTextKey)
119
+ "
120
+ />
121
+ </N8nSelect>
122
+
123
+ <template v-if="showOwnerFilter">
124
+ <N8nInputLabel
125
+ :label="i18n.baseText('settings.mcp.oAuthClients.filters.connectedBy')"
126
+ :bold="false"
127
+ size="small"
128
+ color="text-base"
129
+ class="mt-s mb-3xs"
130
+ />
131
+ <N8nUserSelect
132
+ :users="owners ?? []"
133
+ :model-value="modelValue.ownerId ?? ''"
134
+ :current-user-id="currentUserId"
135
+ :placeholder="i18n.baseText('settings.mcp.oAuthClients.filters.connectedBy.all')"
136
+ size="medium"
137
+ clearable
138
+ data-test-id="mcp-clients-filter-owner"
139
+ @update:model-value="setKeyValue('ownerId', $event ?? '')"
140
+ />
141
+ </template>
142
+
143
+ <N8nInputLabel
144
+ :label="i18n.baseText('settings.mcp.oAuthClients.filters.connected')"
145
+ :bold="false"
146
+ size="small"
147
+ color="text-base"
148
+ class="mt-s mb-3xs"
149
+ />
150
+ <N8nSelect
151
+ :model-value="modelValue.connected ?? ''"
152
+ size="medium"
153
+ data-test-id="mcp-clients-filter-connected"
154
+ @update:model-value="setKeyValue('connected', $event)"
155
+ >
156
+ <N8nOption
157
+ value=""
158
+ :label="i18n.baseText('settings.mcp.oAuthClients.filters.connected.allTime')"
159
+ />
160
+ <N8nOption
161
+ v-for="period in CONNECTED_OPTIONS"
162
+ :key="period"
163
+ :value="period"
164
+ :label="connectedOptionLabels[period]"
165
+ />
166
+ </N8nSelect>
167
+
168
+ <div v-if="hasFilters" :class="[$style['filters-dropdown-footer'], 'mt-s']">
169
+ <N8nLink data-test-id="mcp-clients-filters-reset" @click="resetFilters">
170
+ {{ i18n.baseText('forms.resourceFiltersDropdown.reset') }}
171
+ </N8nLink>
172
+ </div>
173
+ </div>
174
+ </template>
175
+ </N8nPopover>
176
+ </template>
177
+
178
+ <style lang="scss" module>
179
+ .popover-content {
180
+ padding: var(--spacing--sm);
181
+ }
182
+
183
+ .trigger-wrapper {
184
+ display: inline-flex;
185
+
186
+ &[data-state='open'] button {
187
+ background-color: var(--button--color--background-active);
188
+ }
189
+ }
190
+
191
+ .filter-button-count > span {
192
+ font-size: var(--font-size--3xs);
193
+ font-weight: var(--font-weight--semibold);
194
+ }
195
+
196
+ .filters-dropdown-footer {
197
+ display: flex;
198
+ justify-content: space-between;
199
+ align-items: center;
200
+ }
201
+ </style>