@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
package/src/mcp.store.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { defineStore } from 'pinia';
|
|
2
|
+
import { capabilities, capabilityRegistry } from '@n8n/frontend-module-sdk';
|
|
3
|
+
import { MCP_CLIENTS_PREVIEW_LIMIT, MCP_ENDPOINT, MCP_STORE } from './mcp.constants';
|
|
4
|
+
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
5
|
+
import {
|
|
6
|
+
updateMcpSettings,
|
|
7
|
+
toggleWorkflowsMcpAccessApi,
|
|
8
|
+
toggleAgentsMcpAccessApi,
|
|
9
|
+
fetchApiKey,
|
|
10
|
+
rotateApiKey,
|
|
11
|
+
fetchOAuthClients,
|
|
12
|
+
fetchInstanceMcpClientStats,
|
|
13
|
+
deleteOAuthClient,
|
|
14
|
+
fetchMcpEligibleWorkflows,
|
|
15
|
+
fetchMcpExposedWorkflows,
|
|
16
|
+
fetchMcpAgents,
|
|
17
|
+
getAllowedRedirectUris,
|
|
18
|
+
updateAllowedRedirectUris,
|
|
19
|
+
type McpSettingsResponse,
|
|
20
|
+
type ToggleWorkflowsMcpAccessResponse,
|
|
21
|
+
type ToggleWorkflowsMcpAccessTarget,
|
|
22
|
+
type ToggleAgentsMcpAccessResponse,
|
|
23
|
+
type ToggleAgentsMcpAccessTarget,
|
|
24
|
+
} from './mcp.api';
|
|
25
|
+
import type { McpAgent, McpWorkflow } from './mcp.types';
|
|
26
|
+
import { computed, ref } from 'vue';
|
|
27
|
+
import { useSettingsStore } from '@n8n/stores/settings.store';
|
|
28
|
+
import { EMPTY_OAUTH_CLIENT_FILTERS, type OAuthClientFilters } from './clients.utils';
|
|
29
|
+
import type {
|
|
30
|
+
ApiKey,
|
|
31
|
+
InstanceMcpClientStatsResponseDto,
|
|
32
|
+
ListOAuthClientsResponseDto,
|
|
33
|
+
OAuthClientResponseDto,
|
|
34
|
+
DeleteOAuthClientResponseDto,
|
|
35
|
+
} from '@n8n/api-types';
|
|
36
|
+
import { i18n } from '@n8n/i18n';
|
|
37
|
+
|
|
38
|
+
export const useMCPStore = defineStore(MCP_STORE, () => {
|
|
39
|
+
const rootStore = useRootStore();
|
|
40
|
+
const settingsStore = useSettingsStore();
|
|
41
|
+
|
|
42
|
+
const currentUserMCPKey = ref<ApiKey | null>(null);
|
|
43
|
+
const oauthClients = ref<OAuthClientResponseDto[]>([]);
|
|
44
|
+
/** The current user's first few connected clients, previewed on the settings overview. */
|
|
45
|
+
const oauthClientsPreview = ref<OAuthClientResponseDto[]>([]);
|
|
46
|
+
const oauthClientScopeTools = ref<Record<string, string[]> | undefined>(undefined);
|
|
47
|
+
const oauthClientsOwnership = ref<'mine' | 'all'>('mine');
|
|
48
|
+
const oauthClientTotals = ref<{ mine: number; all?: number }>({ mine: 0 });
|
|
49
|
+
const oauthClientsPage = ref(0);
|
|
50
|
+
const oauthClientsPageSize = ref(10);
|
|
51
|
+
const oauthClientsFilters = ref<OAuthClientFilters>({ ...EMPTY_OAUTH_CLIENT_FILTERS });
|
|
52
|
+
/** Total rows matching the filters (across all pages) for the current ownership. */
|
|
53
|
+
const oauthClientsCount = ref(0);
|
|
54
|
+
/** Distinct consent owners for the "Connected by" filter (managers only). */
|
|
55
|
+
const oauthClientOwners = ref<Array<NonNullable<OAuthClientResponseDto['owner']>>>([]);
|
|
56
|
+
/** Monotonic token so a slow in-flight list fetch can't overwrite a newer one. */
|
|
57
|
+
let oauthClientsRequestSeq = 0;
|
|
58
|
+
/** Same guard for the overview preview fetch. */
|
|
59
|
+
let oauthClientsPreviewRequestSeq = 0;
|
|
60
|
+
/**
|
|
61
|
+
* Totals and scope tools are instance-wide and come back with both the list
|
|
62
|
+
* and the preview fetch; this token keeps an older response of either kind
|
|
63
|
+
* from overwriting a newer one.
|
|
64
|
+
*/
|
|
65
|
+
let oauthClientMetadataRequestSeq = 0;
|
|
66
|
+
const allowedRedirectUris = ref<string[]>([]);
|
|
67
|
+
const instanceClientStats = ref<InstanceMcpClientStatsResponseDto | null>(null);
|
|
68
|
+
const connectPopoverOpen = ref(false);
|
|
69
|
+
|
|
70
|
+
const mcpAccessEnabled = computed(() => !!settingsStore.moduleSettings.mcp?.mcpAccessEnabled);
|
|
71
|
+
const mcpManagedByEnv = computed(() => !!settingsStore.moduleSettings.mcp?.mcpManagedByEnv);
|
|
72
|
+
const autoExposeNewWorkflows = computed(
|
|
73
|
+
() => !!settingsStore.moduleSettings.mcp?.autoExposeNewWorkflows,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Backend-provided canonical URL, so a configured dedicated MCP base URL is
|
|
77
|
+
// reflected; the editor-base fallback covers settings not yet loaded.
|
|
78
|
+
const serverUrl = computed(
|
|
79
|
+
() =>
|
|
80
|
+
settingsStore.moduleSettings.mcp?.serverUrl ?? `${rootStore.urlBaseEditor}${MCP_ENDPOINT}`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
async function fetchWorkflowsAvailableForMCP(
|
|
84
|
+
page = 1,
|
|
85
|
+
pageSize = 50,
|
|
86
|
+
): Promise<{ data: McpWorkflow[]; count: number }> {
|
|
87
|
+
const { data, count } = await fetchMcpExposedWorkflows(rootStore.restApiContext, {
|
|
88
|
+
skip: (page - 1) * pageSize,
|
|
89
|
+
take: pageSize,
|
|
90
|
+
});
|
|
91
|
+
return { data, count };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Runs a page fetch, clamping to the last non-empty page when the requested
|
|
96
|
+
* one shrank away (e.g. after removing access). Returns the effective 1-based
|
|
97
|
+
* page so callers can sync their table state.
|
|
98
|
+
*/
|
|
99
|
+
async function clampToLastPage<T>(
|
|
100
|
+
fetchPage: (page: number, pageSize: number) => Promise<{ data: T[]; count: number }>,
|
|
101
|
+
page: number,
|
|
102
|
+
pageSize: number,
|
|
103
|
+
): Promise<{ data: T[]; count: number; page: number }> {
|
|
104
|
+
const response = await fetchPage(page, pageSize);
|
|
105
|
+
if (response.data.length === 0 && response.count > 0 && page > 1) {
|
|
106
|
+
const maxPage = Math.max(1, Math.ceil(response.count / pageSize));
|
|
107
|
+
const clamped = await fetchPage(maxPage, pageSize);
|
|
108
|
+
return { ...clamped, page: maxPage };
|
|
109
|
+
}
|
|
110
|
+
return { ...response, page };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchWorkflowsAvailableForMCPPage(
|
|
114
|
+
page: number,
|
|
115
|
+
pageSize: number,
|
|
116
|
+
): Promise<{ data: McpWorkflow[]; count: number; page: number }> {
|
|
117
|
+
return await clampToLastPage(fetchWorkflowsAvailableForMCP, page, pageSize);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchAgentsAvailableForMCP(
|
|
121
|
+
page = 1,
|
|
122
|
+
pageSize = 50,
|
|
123
|
+
): Promise<{ data: McpAgent[]; count: number }> {
|
|
124
|
+
const { data, count } = await fetchMcpAgents(rootStore.restApiContext, {
|
|
125
|
+
skip: (page - 1) * pageSize,
|
|
126
|
+
take: pageSize,
|
|
127
|
+
availableInMCP: true,
|
|
128
|
+
});
|
|
129
|
+
return { data, count };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function fetchAgentsAvailableForMCPPage(
|
|
133
|
+
page: number,
|
|
134
|
+
pageSize: number,
|
|
135
|
+
): Promise<{ data: McpAgent[]; count: number; page: number }> {
|
|
136
|
+
return await clampToLastPage(fetchAgentsAvailableForMCP, page, pageSize);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The PATCH endpoint always returns both current values, so local state can
|
|
140
|
+
// be synced from the response without a follow-up module-settings fetch.
|
|
141
|
+
function applyMcpSettingsResponse(response: McpSettingsResponse) {
|
|
142
|
+
settingsStore.moduleSettings.mcp = {
|
|
143
|
+
mcpManagedByEnv: false,
|
|
144
|
+
...(settingsStore.moduleSettings.mcp ?? {}),
|
|
145
|
+
mcpAccessEnabled: response.mcpAccessEnabled,
|
|
146
|
+
autoExposeNewWorkflows: response.autoExposeNewWorkflows,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function setMcpAccessEnabled(enabled: boolean): Promise<boolean> {
|
|
151
|
+
const response = await updateMcpSettings(rootStore.restApiContext, {
|
|
152
|
+
mcpAccessEnabled: enabled,
|
|
153
|
+
});
|
|
154
|
+
applyMcpSettingsResponse(response);
|
|
155
|
+
return response.mcpAccessEnabled;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function setAutoExposeNewWorkflows(enabled: boolean): Promise<boolean> {
|
|
159
|
+
const response = await updateMcpSettings(rootStore.restApiContext, {
|
|
160
|
+
autoExposeNewWorkflows: enabled,
|
|
161
|
+
});
|
|
162
|
+
applyMcpSettingsResponse(response);
|
|
163
|
+
return response.autoExposeNewWorkflows;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Toggle MCP access for a single workflow
|
|
167
|
+
async function toggleWorkflowMcpAccess(
|
|
168
|
+
workflowId: string,
|
|
169
|
+
availableInMCP: boolean,
|
|
170
|
+
): Promise<ToggleWorkflowsMcpAccessResponse> {
|
|
171
|
+
const response = await toggleWorkflowsMcpAccessApi(
|
|
172
|
+
rootStore.restApiContext,
|
|
173
|
+
{ workflowIds: [workflowId] },
|
|
174
|
+
availableInMCP,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const confirmedIds = new Set([
|
|
178
|
+
...(response.updatedIds ?? []),
|
|
179
|
+
...(response.unchangedIds ?? []),
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
if (!confirmedIds.has(workflowId)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
i18n.baseText('workflowSettings.toggleMCP.updateSkippedError', {
|
|
185
|
+
interpolate: { workflowId },
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
capabilityRegistry.use(capabilities.workflowMcpAccessSync)([workflowId], availableInMCP);
|
|
191
|
+
|
|
192
|
+
return response;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Bulk-toggle MCP availability, scoped by an id list, a project,
|
|
197
|
+
* or a folder (+ descendants)
|
|
198
|
+
*/
|
|
199
|
+
async function toggleWorkflowsMcpAccess(
|
|
200
|
+
target: ToggleWorkflowsMcpAccessTarget,
|
|
201
|
+
availableInMCP: boolean,
|
|
202
|
+
): Promise<ToggleWorkflowsMcpAccessResponse> {
|
|
203
|
+
const response = await toggleWorkflowsMcpAccessApi(
|
|
204
|
+
rootStore.restApiContext,
|
|
205
|
+
target,
|
|
206
|
+
availableInMCP,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const confirmedIds = new Set([
|
|
210
|
+
...(response.updatedIds ?? []),
|
|
211
|
+
...(response.unchangedIds ?? []),
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
// Scope-mode responses carry no ids, so there is nothing to sync back.
|
|
215
|
+
if (confirmedIds.size > 0) {
|
|
216
|
+
capabilityRegistry.use(capabilities.workflowMcpAccessSync)([...confirmedIds], availableInMCP);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return response;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Toggle MCP access for a single agent
|
|
223
|
+
async function toggleAgentMcpAccess(
|
|
224
|
+
agentId: string,
|
|
225
|
+
availableInMCP: boolean,
|
|
226
|
+
): Promise<ToggleAgentsMcpAccessResponse> {
|
|
227
|
+
const response = await toggleAgentsMcpAccessApi(
|
|
228
|
+
rootStore.restApiContext,
|
|
229
|
+
{ agentIds: [agentId] },
|
|
230
|
+
availableInMCP,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const confirmedIds = new Set([
|
|
234
|
+
...(response.updatedIds ?? []),
|
|
235
|
+
...(response.unchangedIds ?? []),
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
if (!confirmedIds.has(agentId)) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
i18n.baseText('agents.toggleMCP.updateSkippedError', {
|
|
241
|
+
interpolate: { agentId },
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return response;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Bulk-toggle MCP availability for agents, scoped by an id list, a project, or all agents. */
|
|
250
|
+
async function toggleAgentsMcpAccess(
|
|
251
|
+
target: ToggleAgentsMcpAccessTarget,
|
|
252
|
+
availableInMCP: boolean,
|
|
253
|
+
): Promise<ToggleAgentsMcpAccessResponse> {
|
|
254
|
+
return await toggleAgentsMcpAccessApi(rootStore.restApiContext, target, availableInMCP);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function getOrCreateApiKey(): Promise<ApiKey> {
|
|
258
|
+
const apiKey = await fetchApiKey(rootStore.restApiContext);
|
|
259
|
+
currentUserMCPKey.value = apiKey;
|
|
260
|
+
return apiKey;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function generateNewApiKey(): Promise<ApiKey> {
|
|
264
|
+
const apiKey = await rotateApiKey(rootStore.restApiContext);
|
|
265
|
+
currentUserMCPKey.value = apiKey;
|
|
266
|
+
return apiKey;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function resetCurrentUserMCPKey(): void {
|
|
270
|
+
currentUserMCPKey.value = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function applyOAuthClientMetadata(seq: number, response: ListOAuthClientsResponseDto) {
|
|
274
|
+
if (seq !== oauthClientMetadataRequestSeq) return;
|
|
275
|
+
oauthClientScopeTools.value = response.scopeTools;
|
|
276
|
+
oauthClientTotals.value = response.totals;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function getAllOAuthClients(): Promise<OAuthClientResponseDto[]> {
|
|
280
|
+
const seq = ++oauthClientsRequestSeq;
|
|
281
|
+
const metadataSeq = ++oauthClientMetadataRequestSeq;
|
|
282
|
+
const filters = oauthClientsFilters.value;
|
|
283
|
+
const response = await fetchOAuthClients(rootStore.restApiContext, {
|
|
284
|
+
ownership: oauthClientsOwnership.value,
|
|
285
|
+
skip: oauthClientsPage.value * oauthClientsPageSize.value,
|
|
286
|
+
take: oauthClientsPageSize.value,
|
|
287
|
+
name: filters.search.trim() || undefined,
|
|
288
|
+
ownerId: filters.ownerId ?? undefined,
|
|
289
|
+
type: filters.type ?? undefined,
|
|
290
|
+
connected: filters.connected ?? undefined,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// A newer request (tab switch, search, pagination) superseded this one
|
|
294
|
+
// while it was in flight; drop the stale response so it can't overwrite
|
|
295
|
+
// the current selection.
|
|
296
|
+
if (seq !== oauthClientsRequestSeq) return response.data;
|
|
297
|
+
|
|
298
|
+
// Clamp to the last page when the requested one shrank away (e.g. after a revoke)
|
|
299
|
+
if (response.data.length === 0 && response.count > 0 && oauthClientsPage.value > 0) {
|
|
300
|
+
oauthClientsPage.value = Math.max(
|
|
301
|
+
0,
|
|
302
|
+
Math.ceil(response.count / oauthClientsPageSize.value) - 1,
|
|
303
|
+
);
|
|
304
|
+
return await getAllOAuthClients();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
oauthClients.value = response.data;
|
|
308
|
+
applyOAuthClientMetadata(metadataSeq, response);
|
|
309
|
+
oauthClientsCount.value = response.count;
|
|
310
|
+
oauthClientOwners.value = response.owners ?? [];
|
|
311
|
+
return response.data;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Fetches the current user's first connected clients for the overview preview.
|
|
316
|
+
* Always scoped to `mine`, and independent of the clients page's list state
|
|
317
|
+
* (ownership, page, filters), so a visit to the "All" tab can't leak other
|
|
318
|
+
* users' clients onto the overview. Totals and scope tools are instance-wide,
|
|
319
|
+
* so the response refreshes them too.
|
|
320
|
+
*/
|
|
321
|
+
async function fetchOAuthClientsPreview(
|
|
322
|
+
limit = MCP_CLIENTS_PREVIEW_LIMIT,
|
|
323
|
+
): Promise<OAuthClientResponseDto[]> {
|
|
324
|
+
const seq = ++oauthClientsPreviewRequestSeq;
|
|
325
|
+
const metadataSeq = ++oauthClientMetadataRequestSeq;
|
|
326
|
+
const response = await fetchOAuthClients(rootStore.restApiContext, {
|
|
327
|
+
ownership: 'mine',
|
|
328
|
+
skip: 0,
|
|
329
|
+
take: limit,
|
|
330
|
+
});
|
|
331
|
+
// A newer preview fetch (e.g. after a revoke) superseded this one.
|
|
332
|
+
if (seq !== oauthClientsPreviewRequestSeq) return response.data;
|
|
333
|
+
|
|
334
|
+
oauthClientsPreview.value = response.data;
|
|
335
|
+
applyOAuthClientMetadata(metadataSeq, response);
|
|
336
|
+
return response.data;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Drops the cached preview. The overview calls this when it unmounts so the
|
|
341
|
+
* per-user rows never outlive the page, e.g. into another user's session
|
|
342
|
+
* after a soft-redirect logout and login. Bumping the sequence also
|
|
343
|
+
* invalidates a fetch still in flight, so its response can't repopulate the
|
|
344
|
+
* preview after the page is gone.
|
|
345
|
+
*/
|
|
346
|
+
function clearOAuthClientsPreview(): void {
|
|
347
|
+
oauthClientsPreviewRequestSeq++;
|
|
348
|
+
oauthClientsPreview.value = [];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function setOAuthClientsOwnership(ownership: 'mine' | 'all'): Promise<void> {
|
|
352
|
+
oauthClientsOwnership.value = ownership;
|
|
353
|
+
oauthClientsPage.value = 0;
|
|
354
|
+
oauthClientsFilters.value = { ...EMPTY_OAUTH_CLIENT_FILTERS };
|
|
355
|
+
await getAllOAuthClients();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function setOAuthClientsFilters(filters: OAuthClientFilters): Promise<void> {
|
|
359
|
+
oauthClientsFilters.value = filters;
|
|
360
|
+
oauthClientsPage.value = 0;
|
|
361
|
+
await getAllOAuthClients();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function setOAuthClientsPagination(page: number, pageSize: number): Promise<void> {
|
|
365
|
+
// A page-size change restarts from the first page
|
|
366
|
+
oauthClientsPage.value = pageSize === oauthClientsPageSize.value ? page : 0;
|
|
367
|
+
oauthClientsPageSize.value = pageSize;
|
|
368
|
+
await getAllOAuthClients();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function getInstanceClientStats(): Promise<InstanceMcpClientStatsResponseDto | null> {
|
|
372
|
+
try {
|
|
373
|
+
const stats = await fetchInstanceMcpClientStats(rootStore.restApiContext);
|
|
374
|
+
instanceClientStats.value = stats;
|
|
375
|
+
return stats;
|
|
376
|
+
} catch {
|
|
377
|
+
// Endpoint is admin-only; non-admin members get 403. Swallow silently
|
|
378
|
+
// so the settings page still renders for them.
|
|
379
|
+
instanceClientStats.value = null;
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Revokes a client's grant. By default the clients page's list is refetched
|
|
386
|
+
* afterwards; callers that don't show that list (the overview) pass
|
|
387
|
+
* `refreshList: false` and refresh their own data instead, so the list's
|
|
388
|
+
* persisted ownership and filters are never requested from elsewhere.
|
|
389
|
+
*/
|
|
390
|
+
async function removeOAuthClient(
|
|
391
|
+
clientId: string,
|
|
392
|
+
userId?: string,
|
|
393
|
+
{ refreshList = true }: { refreshList?: boolean } = {},
|
|
394
|
+
): Promise<DeleteOAuthClientResponseDto> {
|
|
395
|
+
const response = await deleteOAuthClient(rootStore.restApiContext, clientId, userId);
|
|
396
|
+
if (!refreshList) return response;
|
|
397
|
+
// Refetch instead of splicing locally so the tab totals stay accurate. The
|
|
398
|
+
// revoke already succeeded, so keep the refresh best-effort: a failed
|
|
399
|
+
// refetch must not turn a successful revoke into a reported error.
|
|
400
|
+
try {
|
|
401
|
+
await getAllOAuthClients();
|
|
402
|
+
} catch {
|
|
403
|
+
// Stale list/totals are acceptable; the next interaction refetches.
|
|
404
|
+
}
|
|
405
|
+
return response;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function getMcpEligibleWorkflows(options?: {
|
|
409
|
+
take?: number;
|
|
410
|
+
skip?: number;
|
|
411
|
+
query?: string;
|
|
412
|
+
}): Promise<{ count: number; data: McpWorkflow[] }> {
|
|
413
|
+
return await fetchMcpEligibleWorkflows(rootStore.restApiContext, options);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function getMcpEligibleAgents(options?: {
|
|
417
|
+
take?: number;
|
|
418
|
+
skip?: number;
|
|
419
|
+
query?: string;
|
|
420
|
+
}): Promise<{ count: number; data: McpAgent[] }> {
|
|
421
|
+
return await fetchMcpAgents(rootStore.restApiContext, options);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function openConnectPopover(): void {
|
|
425
|
+
connectPopoverOpen.value = true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function closeConnectPopover(): void {
|
|
429
|
+
connectPopoverOpen.value = false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function fetchAllowedRedirectUris(): Promise<string[]> {
|
|
433
|
+
const response = await getAllowedRedirectUris(rootStore.restApiContext);
|
|
434
|
+
allowedRedirectUris.value = response.uris;
|
|
435
|
+
return response.uris;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function setAllowedRedirectUris(uris: string[]): Promise<void> {
|
|
439
|
+
await updateAllowedRedirectUris(rootStore.restApiContext, uris);
|
|
440
|
+
allowedRedirectUris.value = uris;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
mcpAccessEnabled,
|
|
445
|
+
mcpManagedByEnv,
|
|
446
|
+
autoExposeNewWorkflows,
|
|
447
|
+
serverUrl,
|
|
448
|
+
fetchWorkflowsAvailableForMCP,
|
|
449
|
+
fetchWorkflowsAvailableForMCPPage,
|
|
450
|
+
fetchAgentsAvailableForMCP,
|
|
451
|
+
fetchAgentsAvailableForMCPPage,
|
|
452
|
+
setMcpAccessEnabled,
|
|
453
|
+
setAutoExposeNewWorkflows,
|
|
454
|
+
toggleWorkflowMcpAccess,
|
|
455
|
+
toggleWorkflowsMcpAccess,
|
|
456
|
+
toggleAgentMcpAccess,
|
|
457
|
+
toggleAgentsMcpAccess,
|
|
458
|
+
currentUserMCPKey,
|
|
459
|
+
getOrCreateApiKey,
|
|
460
|
+
generateNewApiKey,
|
|
461
|
+
resetCurrentUserMCPKey,
|
|
462
|
+
oauthClients,
|
|
463
|
+
oauthClientsPreview,
|
|
464
|
+
fetchOAuthClientsPreview,
|
|
465
|
+
clearOAuthClientsPreview,
|
|
466
|
+
oauthClientsOwnership,
|
|
467
|
+
oauthClientTotals,
|
|
468
|
+
oauthClientOwners,
|
|
469
|
+
oauthClientsPage,
|
|
470
|
+
oauthClientsPageSize,
|
|
471
|
+
oauthClientsFilters,
|
|
472
|
+
oauthClientsCount,
|
|
473
|
+
setOAuthClientsOwnership,
|
|
474
|
+
setOAuthClientsFilters,
|
|
475
|
+
setOAuthClientsPagination,
|
|
476
|
+
instanceClientStats,
|
|
477
|
+
getAllOAuthClients,
|
|
478
|
+
oauthClientScopeTools,
|
|
479
|
+
getInstanceClientStats,
|
|
480
|
+
removeOAuthClient,
|
|
481
|
+
getMcpEligibleWorkflows,
|
|
482
|
+
getMcpEligibleAgents,
|
|
483
|
+
allowedRedirectUris,
|
|
484
|
+
fetchAllowedRedirectUris,
|
|
485
|
+
setAllowedRedirectUris,
|
|
486
|
+
connectPopoverOpen,
|
|
487
|
+
openConnectPopover,
|
|
488
|
+
closeConnectPopover,
|
|
489
|
+
};
|
|
490
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { McpWorkflow } from './mcp.types';
|
|
2
|
+
import type { ProjectSharingData } from 'n8n-workflow';
|
|
3
|
+
import type { OAuthClientResponseDto } from '@n8n/api-types';
|
|
4
|
+
|
|
5
|
+
export const createHomeProject = (
|
|
6
|
+
overrides: Partial<ProjectSharingData> = {},
|
|
7
|
+
): ProjectSharingData => ({
|
|
8
|
+
id: 'project-1',
|
|
9
|
+
type: 'team',
|
|
10
|
+
name: 'Test Project',
|
|
11
|
+
icon: null,
|
|
12
|
+
createdAt: '2025-01-01',
|
|
13
|
+
updatedAt: '2025-01-01',
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const createParentFolder = (
|
|
18
|
+
overrides: Partial<{ id: string; name: string; parentFolderId: string | null }> = {},
|
|
19
|
+
) => ({
|
|
20
|
+
id: 'folder-1',
|
|
21
|
+
name: 'Test Folder',
|
|
22
|
+
parentFolderId: null,
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const createWorkflow = (overrides: Partial<McpWorkflow> = {}): McpWorkflow => ({
|
|
27
|
+
id: 'test-workflow-1',
|
|
28
|
+
name: 'Test Workflow',
|
|
29
|
+
scopes: ['workflow:read', 'workflow:update'],
|
|
30
|
+
homeProject: {
|
|
31
|
+
id: 'project1',
|
|
32
|
+
type: 'team',
|
|
33
|
+
name: 'Test Project',
|
|
34
|
+
icon: {
|
|
35
|
+
type: 'icon',
|
|
36
|
+
value: 'bot',
|
|
37
|
+
},
|
|
38
|
+
createdAt: '2025-09-09T14:13:50.000Z',
|
|
39
|
+
updatedAt: '2025-09-09T14:13:50.000Z',
|
|
40
|
+
},
|
|
41
|
+
...overrides,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const createOAuthClient = (
|
|
45
|
+
overrides: Partial<OAuthClientResponseDto> = {},
|
|
46
|
+
): OAuthClientResponseDto => ({
|
|
47
|
+
id: 'client-1',
|
|
48
|
+
name: 'Test Client',
|
|
49
|
+
createdAt: '2025-09-09T14:14:04.155Z',
|
|
50
|
+
updatedAt: '2025-09-09T14:14:04.155Z',
|
|
51
|
+
redirectUris: [],
|
|
52
|
+
grantTypes: ['authorization_code'],
|
|
53
|
+
tokenEndpointAuthMethod: 'client_secret_basic',
|
|
54
|
+
grantedAt: new Date('2025-09-09T14:14:04.155Z').getTime(),
|
|
55
|
+
scopes: ['workflow:read', 'execution:read'],
|
|
56
|
+
...overrides,
|
|
57
|
+
});
|
package/src/mcp.types.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Scope } from '@n8n/permissions';
|
|
2
|
+
import type { ProjectSharingData } from 'n8n-workflow';
|
|
3
|
+
|
|
4
|
+
/** Workflow fields the MCP views read off a list response. `WorkflowListItem` satisfies it. */
|
|
5
|
+
export type McpWorkflow = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string | null;
|
|
9
|
+
scopes?: Scope[];
|
|
10
|
+
homeProject?: ProjectSharingData;
|
|
11
|
+
parentFolder?: { id: string; name: string; parentFolderId: string | null };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Agent fields the MCP views read off a list response. `Agent` satisfies it. */
|
|
15
|
+
export type McpAgent = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
projectId: string;
|
|
19
|
+
project?: Pick<ProjectSharingData, 'id' | 'name' | 'type'> | null;
|
|
20
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { defineComponent, h } from 'vue';
|
|
2
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
3
|
+
import { waitFor } from '@testing-library/vue';
|
|
4
|
+
import userEvent from '@testing-library/user-event';
|
|
5
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
6
|
+
import MCPConnectAgentsModal from './MCPConnectAgentsModal.vue';
|
|
7
|
+
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|
8
|
+
import { TELEMETRY_EVENT } from '@n8n/telemetry';
|
|
9
|
+
import { type Mock } from 'vitest';
|
|
10
|
+
|
|
11
|
+
vi.mock('@n8n/composables/useTelemetry', () => {
|
|
12
|
+
const track = vi.fn();
|
|
13
|
+
return {
|
|
14
|
+
useTelemetry: () => ({
|
|
15
|
+
track,
|
|
16
|
+
}),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Replaces the remote-search select with a button that selects one agent.
|
|
21
|
+
const MCPAgentsSelectStub = defineComponent({
|
|
22
|
+
props: { modelValue: { type: Array, default: () => [] } },
|
|
23
|
+
emits: ['update:modelValue'],
|
|
24
|
+
setup(props, { emit }) {
|
|
25
|
+
return () =>
|
|
26
|
+
h(
|
|
27
|
+
'button',
|
|
28
|
+
{
|
|
29
|
+
'data-test-id': 'mcp-agents-select-stub',
|
|
30
|
+
onClick: () => emit('update:modelValue', ['agent-1']),
|
|
31
|
+
},
|
|
32
|
+
props.modelValue.join(','),
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const renderModal = createComponentRenderer(MCPConnectAgentsModal, {
|
|
38
|
+
global: {
|
|
39
|
+
stubs: { MCPAgentsSelect: MCPAgentsSelectStub },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const telemetry = useTelemetry();
|
|
44
|
+
|
|
45
|
+
let mockEnableMcpAccess: Mock;
|
|
46
|
+
|
|
47
|
+
describe('MCPConnectAgentsModal', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
mockEnableMcpAccess = vi.fn().mockResolvedValue(undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const renderOpen = () =>
|
|
57
|
+
renderModal({
|
|
58
|
+
pinia: createTestingPinia(),
|
|
59
|
+
props: { open: true, enableMcpAccess: mockEnableMcpAccess },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should enable access, track the selection and close on save', async () => {
|
|
63
|
+
const { getByTestId, findByTestId, emitted } = renderOpen();
|
|
64
|
+
|
|
65
|
+
await userEvent.click(await findByTestId('mcp-agents-select-stub'));
|
|
66
|
+
await userEvent.click(getByTestId('mcp-connect-agents-save-button'));
|
|
67
|
+
|
|
68
|
+
await waitFor(() => expect(emitted()['update:open']).toEqual([[false]]));
|
|
69
|
+
expect(mockEnableMcpAccess).toHaveBeenCalledWith(['agent-1']);
|
|
70
|
+
expect(telemetry.track).toHaveBeenCalledWith(
|
|
71
|
+
TELEMETRY_EVENT.AGENTS.USER_SELECTED_AGENTS_FOR_MCP,
|
|
72
|
+
{ agentIds: ['agent-1'], count: 1 },
|
|
73
|
+
);
|
|
74
|
+
expect(telemetry.track).not.toHaveBeenCalledWith(
|
|
75
|
+
TELEMETRY_EVENT.AGENTS.USER_DISMISSED_MCP_AGENTS_DIALOG,
|
|
76
|
+
{},
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should close and track a dismissal on cancel', async () => {
|
|
81
|
+
const { findByTestId, emitted, rerender } = renderOpen();
|
|
82
|
+
|
|
83
|
+
await userEvent.click(await findByTestId('mcp-connect-agents-cancel-button'));
|
|
84
|
+
expect(emitted()['update:open']).toEqual([[false]]);
|
|
85
|
+
|
|
86
|
+
// The parent applies the v-model update.
|
|
87
|
+
await rerender({ open: false });
|
|
88
|
+
|
|
89
|
+
expect(telemetry.track).toHaveBeenCalledWith(
|
|
90
|
+
TELEMETRY_EVENT.AGENTS.USER_DISMISSED_MCP_AGENTS_DIALOG,
|
|
91
|
+
{},
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should track a dismissal when the parent closes it without an action', async () => {
|
|
96
|
+
const { rerender } = renderOpen();
|
|
97
|
+
|
|
98
|
+
await rerender({ open: false });
|
|
99
|
+
|
|
100
|
+
expect(telemetry.track).toHaveBeenCalledWith(
|
|
101
|
+
TELEMETRY_EVENT.AGENTS.USER_DISMISSED_MCP_AGENTS_DIALOG,
|
|
102
|
+
{},
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should start from an empty selection when reopened', async () => {
|
|
107
|
+
const { getByTestId, findByTestId, rerender } = renderOpen();
|
|
108
|
+
|
|
109
|
+
await userEvent.click(await findByTestId('mcp-agents-select-stub'));
|
|
110
|
+
expect(getByTestId('mcp-connect-agents-save-button')).toBeEnabled();
|
|
111
|
+
|
|
112
|
+
await rerender({ open: false });
|
|
113
|
+
await rerender({ open: true });
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(getByTestId('mcp-connect-agents-save-button')).toBeDisabled());
|
|
116
|
+
expect(getByTestId('mcp-agents-select-stub')).toHaveTextContent('');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should stay open without tracking when enabling access fails', async () => {
|
|
120
|
+
const error = new Error('network');
|
|
121
|
+
mockEnableMcpAccess.mockRejectedValue(error);
|
|
122
|
+
// The view shows the error; the rejection only has to keep the modal open.
|
|
123
|
+
const errorHandler = vi.fn();
|
|
124
|
+
const { getByTestId, findByTestId, emitted } = renderModal({
|
|
125
|
+
pinia: createTestingPinia(),
|
|
126
|
+
props: { open: true, enableMcpAccess: mockEnableMcpAccess },
|
|
127
|
+
global: { config: { errorHandler } },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await userEvent.click(await findByTestId('mcp-agents-select-stub'));
|
|
131
|
+
await userEvent.click(getByTestId('mcp-connect-agents-save-button'));
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
expect(getByTestId('mcp-connect-agents-save-button')).toBeEnabled();
|
|
135
|
+
expect(getByTestId('mcp-connect-agents-cancel-button')).toBeEnabled();
|
|
136
|
+
});
|
|
137
|
+
expect(errorHandler).toHaveBeenCalledWith(error, expect.anything(), expect.any(String));
|
|
138
|
+
expect(emitted()['update:open']).toBeUndefined();
|
|
139
|
+
expect(telemetry.track).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
});
|