@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.api.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiKey,
|
|
3
|
+
InstanceMcpClientStatsResponseDto,
|
|
4
|
+
ListOAuthClientsResponseDto,
|
|
5
|
+
DeleteOAuthClientResponseDto,
|
|
6
|
+
McpClientConnectedPeriod,
|
|
7
|
+
McpClientTypeFilter,
|
|
8
|
+
} from '@n8n/api-types';
|
|
9
|
+
import type { McpAgent, McpWorkflow } from './mcp.types';
|
|
10
|
+
import type { IRestApiContext } from '@n8n/rest-api-client';
|
|
11
|
+
import { makeRestApiRequest, getFullApiResponse } from '@n8n/rest-api-client';
|
|
12
|
+
|
|
13
|
+
export type McpSettingsResponse = {
|
|
14
|
+
mcpAccessEnabled: boolean;
|
|
15
|
+
autoExposeNewWorkflows: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ToggleWorkflowsMcpAccessTarget =
|
|
19
|
+
| { workflowIds: string[] }
|
|
20
|
+
| { projectId: string }
|
|
21
|
+
| { folderId: string }
|
|
22
|
+
| { allWorkflows: true };
|
|
23
|
+
|
|
24
|
+
export type ToggleWorkflowsMcpAccessResponse = {
|
|
25
|
+
updatedCount: number;
|
|
26
|
+
unchangedCount: number;
|
|
27
|
+
skippedCount: number;
|
|
28
|
+
failedCount: number;
|
|
29
|
+
updatedIds?: string[];
|
|
30
|
+
unchangedIds?: string[];
|
|
31
|
+
autoExposeNewWorkflows?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ToggleAgentsMcpAccessTarget =
|
|
35
|
+
| { agentIds: string[] }
|
|
36
|
+
| { projectId: string }
|
|
37
|
+
| { allAgents: true };
|
|
38
|
+
|
|
39
|
+
export type ToggleAgentsMcpAccessResponse = {
|
|
40
|
+
updatedCount: number;
|
|
41
|
+
updatedIds?: string[];
|
|
42
|
+
unchangedIds?: string[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export async function updateMcpSettings(
|
|
46
|
+
context: IRestApiContext,
|
|
47
|
+
settings: { mcpAccessEnabled?: boolean; autoExposeNewWorkflows?: boolean },
|
|
48
|
+
): Promise<McpSettingsResponse> {
|
|
49
|
+
return await makeRestApiRequest(context, 'PATCH', '/mcp/settings', settings);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function fetchApiKey(context: IRestApiContext): Promise<ApiKey> {
|
|
53
|
+
return await makeRestApiRequest(context, 'GET', '/mcp/api-key');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function rotateApiKey(context: IRestApiContext): Promise<ApiKey> {
|
|
57
|
+
return await makeRestApiRequest(context, 'POST', '/mcp/api-key/rotate');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function getAllowedRedirectUris(
|
|
61
|
+
context: IRestApiContext,
|
|
62
|
+
): Promise<{ uris: string[] }> {
|
|
63
|
+
return await makeRestApiRequest(context, 'GET', '/mcp/oauth/allowed-redirect-uris');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function updateAllowedRedirectUris(
|
|
67
|
+
context: IRestApiContext,
|
|
68
|
+
uris: string[],
|
|
69
|
+
): Promise<{ success: boolean }> {
|
|
70
|
+
return await makeRestApiRequest(context, 'PATCH', '/mcp/oauth/allowed-redirect-uris', { uris });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bulk-toggles MCP availability for a set of workflows scoped by either an
|
|
75
|
+
* explicit id list, a project, a folder (+ its descendants), or all
|
|
76
|
+
* workflows the user can update.
|
|
77
|
+
*/
|
|
78
|
+
export async function toggleWorkflowsMcpAccessApi(
|
|
79
|
+
context: IRestApiContext,
|
|
80
|
+
target: ToggleWorkflowsMcpAccessTarget,
|
|
81
|
+
availableInMCP: boolean,
|
|
82
|
+
): Promise<ToggleWorkflowsMcpAccessResponse> {
|
|
83
|
+
return await makeRestApiRequest(context, 'PATCH', '/mcp/workflows/toggle-access', {
|
|
84
|
+
availableInMCP,
|
|
85
|
+
...target,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type FetchOAuthClientsOptions = {
|
|
90
|
+
ownership?: 'mine' | 'all';
|
|
91
|
+
skip?: number;
|
|
92
|
+
take?: number;
|
|
93
|
+
name?: string;
|
|
94
|
+
ownerId?: string;
|
|
95
|
+
type?: McpClientTypeFilter;
|
|
96
|
+
connected?: McpClientConnectedPeriod;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export async function fetchOAuthClients(
|
|
100
|
+
context: IRestApiContext,
|
|
101
|
+
options: FetchOAuthClientsOptions = {},
|
|
102
|
+
): Promise<ListOAuthClientsResponseDto> {
|
|
103
|
+
const params = Object.fromEntries(
|
|
104
|
+
Object.entries(options).filter(([, value]) => value !== undefined),
|
|
105
|
+
);
|
|
106
|
+
return await makeRestApiRequest(
|
|
107
|
+
context,
|
|
108
|
+
'GET',
|
|
109
|
+
'/mcp/oauth-clients',
|
|
110
|
+
Object.keys(params).length > 0 ? params : undefined,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fetchInstanceMcpClientStats(
|
|
115
|
+
context: IRestApiContext,
|
|
116
|
+
): Promise<InstanceMcpClientStatsResponseDto> {
|
|
117
|
+
return await makeRestApiRequest(context, 'GET', '/mcp/oauth-clients/instance-stats');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function deleteOAuthClient(
|
|
121
|
+
context: IRestApiContext,
|
|
122
|
+
clientId: string,
|
|
123
|
+
userId?: string,
|
|
124
|
+
): Promise<DeleteOAuthClientResponseDto> {
|
|
125
|
+
return await makeRestApiRequest(
|
|
126
|
+
context,
|
|
127
|
+
'DELETE',
|
|
128
|
+
`/mcp/oauth-clients/${encodeURIComponent(clientId)}`,
|
|
129
|
+
userId ? { userId } : undefined,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function fetchMcpEligibleWorkflows(
|
|
134
|
+
context: IRestApiContext,
|
|
135
|
+
options?: { take?: number; skip?: number; query?: string },
|
|
136
|
+
): Promise<{ count: number; data: McpWorkflow[] }> {
|
|
137
|
+
const params: Record<string, string | number> = {};
|
|
138
|
+
|
|
139
|
+
if (options?.take !== undefined) {
|
|
140
|
+
params.take = options.take;
|
|
141
|
+
}
|
|
142
|
+
if (options?.skip !== undefined) {
|
|
143
|
+
params.skip = options.skip;
|
|
144
|
+
}
|
|
145
|
+
if (options?.query) {
|
|
146
|
+
params.filter = JSON.stringify({ query: options.query });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return await getFullApiResponse<McpWorkflow[]>(context, 'GET', '/mcp/workflows', params);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Workflows already exposed to MCP, newest first. Uses the generic list endpoint:
|
|
154
|
+
* `/mcp/workflows` returns only workflows that are not exposed yet.
|
|
155
|
+
*/
|
|
156
|
+
export async function fetchMcpExposedWorkflows(
|
|
157
|
+
context: IRestApiContext,
|
|
158
|
+
options: { skip: number; take: number },
|
|
159
|
+
): Promise<{ count: number; data: McpWorkflow[] }> {
|
|
160
|
+
return await getFullApiResponse<McpWorkflow[]>(context, 'GET', '/workflows', {
|
|
161
|
+
includeScopes: true,
|
|
162
|
+
filter: { isArchived: false, availableInMCP: true },
|
|
163
|
+
skip: options.skip,
|
|
164
|
+
take: options.take,
|
|
165
|
+
sortBy: 'updatedAt:desc',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Bulk-toggles MCP availability for a set of agents scoped by either an
|
|
171
|
+
* explicit id list, a project, or all agents the user can update.
|
|
172
|
+
*/
|
|
173
|
+
export async function toggleAgentsMcpAccessApi(
|
|
174
|
+
context: IRestApiContext,
|
|
175
|
+
target: ToggleAgentsMcpAccessTarget,
|
|
176
|
+
availableInMCP: boolean,
|
|
177
|
+
): Promise<ToggleAgentsMcpAccessResponse> {
|
|
178
|
+
return await makeRestApiRequest(context, 'PATCH', '/mcp/agents/toggle-access', {
|
|
179
|
+
availableInMCP,
|
|
180
|
+
...target,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function fetchMcpAgents(
|
|
185
|
+
context: IRestApiContext,
|
|
186
|
+
options?: { take?: number; skip?: number; query?: string; availableInMCP?: boolean },
|
|
187
|
+
): Promise<{ count: number; data: McpAgent[] }> {
|
|
188
|
+
const params: Record<string, string | number> = {};
|
|
189
|
+
const query = options?.query?.trim();
|
|
190
|
+
const filter = {
|
|
191
|
+
...(query ? { query } : {}),
|
|
192
|
+
...(options?.availableInMCP !== undefined ? { availableInMCP: options.availableInMCP } : {}),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (options?.take !== undefined) {
|
|
196
|
+
params.take = options.take;
|
|
197
|
+
}
|
|
198
|
+
if (options?.skip !== undefined) {
|
|
199
|
+
params.skip = options.skip;
|
|
200
|
+
}
|
|
201
|
+
if (Object.keys(filter).length > 0) {
|
|
202
|
+
params.filter = JSON.stringify(filter);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return await getFullApiResponse<McpAgent[]>(context, 'GET', '/mcp/agents', params);
|
|
206
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getMcpClientCatalog } from './mcp.clients.catalog';
|
|
2
|
+
|
|
3
|
+
const SERVER_URL = 'https://acme.app.n8n.cloud/mcp-server/http';
|
|
4
|
+
|
|
5
|
+
describe('getMcpClientCatalog', () => {
|
|
6
|
+
const catalog = getMcpClientCatalog(SERVER_URL);
|
|
7
|
+
const clients = catalog.flatMap((group) => group.clients);
|
|
8
|
+
|
|
9
|
+
it('should group the clients by category in picker order', () => {
|
|
10
|
+
expect(catalog.map((group) => group.id)).toEqual(['cli', 'web', 'ide']);
|
|
11
|
+
expect(catalog.map((group) => group.clients.map((client) => client.id))).toEqual([
|
|
12
|
+
['claude-code', 'codex', 'gemini-cli'],
|
|
13
|
+
['claude-ai', 'chatgpt', 'mistral-vibe'],
|
|
14
|
+
['cursor', 'vscode', 'windsurf'],
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should embed the server URL in every install command and config snippet', () => {
|
|
19
|
+
for (const client of clients) {
|
|
20
|
+
if (client.installCommand) expect(client.installCommand).toContain(SERVER_URL);
|
|
21
|
+
if (client.configSnippet) expect(client.configSnippet).toContain(SERVER_URL);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should build the Cursor deep link with a base64 config payload', () => {
|
|
26
|
+
const cursor = clients.find((client) => client.id === 'cursor');
|
|
27
|
+
const config = new URL(cursor!.deepLink!.replace('cursor://', 'https://cursor.test/'))
|
|
28
|
+
.searchParams;
|
|
29
|
+
expect(config.get('name')).toBe('n8n');
|
|
30
|
+
expect(JSON.parse(atob(config.get('config')!))).toEqual({ url: SERVER_URL });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should build the VS Code deep link with a URL-encoded JSON payload', () => {
|
|
34
|
+
const vscode = clients.find((client) => client.id === 'vscode');
|
|
35
|
+
const encoded = vscode!.deepLink!.replace('vscode:mcp/install?', '');
|
|
36
|
+
expect(JSON.parse(decodeURIComponent(encoded))).toEqual({
|
|
37
|
+
name: 'n8n',
|
|
38
|
+
type: 'http',
|
|
39
|
+
url: SERVER_URL,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should prefill the server URL in the Mistral Vibe connector template link', () => {
|
|
44
|
+
const vibe = clients.find((client) => client.id === 'mistral-vibe');
|
|
45
|
+
const params = new URL(vibe!.addUrl!).searchParams;
|
|
46
|
+
expect(params.get('template')).toBe('n8n');
|
|
47
|
+
expect(params.get('server_url')).toBe(SERVER_URL);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should give web connector URLs as https and every CLI an install command', () => {
|
|
51
|
+
const web = catalog.find((group) => group.id === 'web')!;
|
|
52
|
+
// A one-click connector URL is optional per web client, but when present it must be https.
|
|
53
|
+
for (const client of web.clients) {
|
|
54
|
+
if (client.addUrl) expect(client.addUrl).toMatch(/^https:\/\//);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const cli = catalog.find((group) => group.id === 'cli')!;
|
|
58
|
+
for (const client of cli.clients) {
|
|
59
|
+
expect(client.installCommand).toBeTruthy();
|
|
60
|
+
expect(client.configSnippet).toBeTruthy();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { Component } from 'vue';
|
|
2
|
+
|
|
3
|
+
import ClaudeIcon from './assets/client-icons/claude.svg?component';
|
|
4
|
+
import CodexIcon from './assets/client-icons/codex.svg?component';
|
|
5
|
+
import CursorIcon from './assets/client-icons/cursor.svg?component';
|
|
6
|
+
import GeminiIcon from './assets/client-icons/gemini.svg?component';
|
|
7
|
+
import MistralIcon from './assets/client-icons/mistral.svg?component';
|
|
8
|
+
import OpenAiIcon from './assets/client-icons/openai.svg?component';
|
|
9
|
+
import VsCodeIcon from './assets/client-icons/vscode.svg?component';
|
|
10
|
+
|
|
11
|
+
export type McpSetupCategory = 'cli' | 'web' | 'ide';
|
|
12
|
+
|
|
13
|
+
export interface McpClientSetup {
|
|
14
|
+
id: string;
|
|
15
|
+
/** Brand name, not translatable. */
|
|
16
|
+
name: string;
|
|
17
|
+
category: McpSetupCategory;
|
|
18
|
+
/** Brand mark; null falls back to the neutral MCP icon (Windsurf has no official inlineable mark). */
|
|
19
|
+
icon: Component | null;
|
|
20
|
+
installCommand?: string;
|
|
21
|
+
/** Snippet for the client's MCP config file (JSON, or TOML for Codex). */
|
|
22
|
+
configSnippet?: string;
|
|
23
|
+
/** Command that triggers the client's OAuth login; clients without one use '/mcp' in-app. */
|
|
24
|
+
authCommand?: string;
|
|
25
|
+
/** One-click install deep link (IDEs). */
|
|
26
|
+
deepLink?: string;
|
|
27
|
+
/** Connector-settings page for one-click web clients. */
|
|
28
|
+
addUrl?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface McpClientCategoryGroup {
|
|
32
|
+
id: McpSetupCategory;
|
|
33
|
+
clients: McpClientSetup[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The "Connect a client" setup catalogue, in picker order. Each client carries
|
|
38
|
+
* the per-category payload its setup steps render: CLIs an install command +
|
|
39
|
+
* config + auth step, web clients a one-click connector URL (when the client
|
|
40
|
+
* supports one), IDEs a deep link (when the editor supports one) + manual config.
|
|
41
|
+
*/
|
|
42
|
+
export function getMcpClientCatalog(serverUrl: string): McpClientCategoryGroup[] {
|
|
43
|
+
const claudeSnippet = `{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"n8n": {
|
|
46
|
+
"type": "http",
|
|
47
|
+
"url": "${serverUrl}"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}`;
|
|
51
|
+
// Cursor treats a bare `url` as SSE; n8n's endpoint is streamable HTTP, so the
|
|
52
|
+
// transport must be stated explicitly.
|
|
53
|
+
const cursorSnippet = `{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"n8n": {
|
|
56
|
+
"type": "streamable-http",
|
|
57
|
+
"url": "${serverUrl}"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}`;
|
|
61
|
+
// Codex routes HTTP MCP servers through its Rust client, which older builds only
|
|
62
|
+
// enable behind this feature flag (newer builds ignore it).
|
|
63
|
+
const codexSnippet = `[features]
|
|
64
|
+
experimental_use_rmcp_client = true
|
|
65
|
+
|
|
66
|
+
[mcp_servers.n8n]
|
|
67
|
+
url = "${serverUrl}"`;
|
|
68
|
+
const geminiSnippet = `{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"n8n": {
|
|
71
|
+
"httpUrl": "${serverUrl}"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}`;
|
|
75
|
+
const vscodeSnippet = `{
|
|
76
|
+
"servers": {
|
|
77
|
+
"n8n": {
|
|
78
|
+
"type": "http",
|
|
79
|
+
"url": "${serverUrl}"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}`;
|
|
83
|
+
const windsurfSnippet = `{\n "mcpServers": {\n "n8n": {\n "serverUrl": "${serverUrl}"\n }\n }\n}`;
|
|
84
|
+
|
|
85
|
+
// One-click deep links, exactly as each editor expects: Cursor takes base64 of
|
|
86
|
+
// `{"url": …}` in a `config` query param, VS Code a URL-encoded JSON object.
|
|
87
|
+
const cursorDeepLink = `cursor://anysphere.cursor-deeplink/mcp/install?name=n8n&config=${btoa(
|
|
88
|
+
`{"url":"${serverUrl}"}`,
|
|
89
|
+
)}`;
|
|
90
|
+
const vscodeDeepLink = `vscode:mcp/install?${encodeURIComponent(
|
|
91
|
+
`{"name":"n8n","type":"http","url":"${serverUrl}"}`,
|
|
92
|
+
)}`;
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
id: 'cli',
|
|
97
|
+
clients: [
|
|
98
|
+
{
|
|
99
|
+
id: 'claude-code',
|
|
100
|
+
name: 'Claude Code',
|
|
101
|
+
category: 'cli',
|
|
102
|
+
icon: ClaudeIcon,
|
|
103
|
+
installCommand: `claude mcp add --transport http n8n ${serverUrl}`,
|
|
104
|
+
configSnippet: claudeSnippet,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'codex',
|
|
108
|
+
name: 'Codex',
|
|
109
|
+
category: 'cli',
|
|
110
|
+
icon: CodexIcon,
|
|
111
|
+
installCommand: `codex mcp add n8n --url "${serverUrl}"`,
|
|
112
|
+
configSnippet: codexSnippet,
|
|
113
|
+
authCommand: 'codex mcp login n8n',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'gemini-cli',
|
|
117
|
+
name: 'Gemini CLI',
|
|
118
|
+
category: 'cli',
|
|
119
|
+
icon: GeminiIcon,
|
|
120
|
+
installCommand: `gemini mcp add --transport http n8n ${serverUrl}`,
|
|
121
|
+
configSnippet: geminiSnippet,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'web',
|
|
127
|
+
clients: [
|
|
128
|
+
{
|
|
129
|
+
id: 'claude-ai',
|
|
130
|
+
name: 'Claude.ai',
|
|
131
|
+
category: 'web',
|
|
132
|
+
icon: ClaudeIcon,
|
|
133
|
+
addUrl: 'https://claude.ai/directory/connectors/n8n',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
id: 'chatgpt',
|
|
137
|
+
name: 'ChatGPT',
|
|
138
|
+
category: 'web',
|
|
139
|
+
icon: OpenAiIcon,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'mistral-vibe',
|
|
143
|
+
name: 'Mistral Vibe',
|
|
144
|
+
category: 'web',
|
|
145
|
+
icon: MistralIcon,
|
|
146
|
+
// Opens Vibe's built-in n8n connector template with the server URL prefilled,
|
|
147
|
+
// so the user only has to click Connect.
|
|
148
|
+
addUrl: `https://chat.mistral.ai/connections/marketplace?search=n8n&template=n8n&server_url=${encodeURIComponent(
|
|
149
|
+
serverUrl,
|
|
150
|
+
)}`,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'ide',
|
|
156
|
+
clients: [
|
|
157
|
+
{
|
|
158
|
+
id: 'cursor',
|
|
159
|
+
name: 'Cursor',
|
|
160
|
+
category: 'ide',
|
|
161
|
+
icon: CursorIcon,
|
|
162
|
+
deepLink: cursorDeepLink,
|
|
163
|
+
configSnippet: cursorSnippet,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'vscode',
|
|
167
|
+
name: 'VS Code',
|
|
168
|
+
category: 'ide',
|
|
169
|
+
icon: VsCodeIcon,
|
|
170
|
+
deepLink: vscodeDeepLink,
|
|
171
|
+
configSnippet: vscodeSnippet,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'windsurf',
|
|
175
|
+
name: 'Windsurf',
|
|
176
|
+
category: 'ide',
|
|
177
|
+
icon: null,
|
|
178
|
+
configSnippet: windsurfSnippet,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IconName } from '@n8n/design-system';
|
|
2
|
+
|
|
3
|
+
export const MCP_ENDPOINT = 'mcp-server/http';
|
|
4
|
+
export const MCP_DOCS_PAGE_URL = 'https://docs.n8n.io/connect/connect-to-n8n-mcp-server';
|
|
5
|
+
|
|
6
|
+
// Display groups for the MCP OAuth consent scope picker, keyed by the
|
|
7
|
+
// `resource:` prefix of the scopes in MCP_INSTANCE_SCOPES (@n8n/api-types).
|
|
8
|
+
export const MCP_SCOPE_GROUPS: Array<{ key: string; resources: readonly string[] }> = [
|
|
9
|
+
{ key: 'workflows', resources: ['workflow', 'tag'] },
|
|
10
|
+
{ key: 'executions', resources: ['execution'] },
|
|
11
|
+
{ key: 'agents', resources: ['agent'] },
|
|
12
|
+
{ key: 'credentials', resources: ['credential'] },
|
|
13
|
+
{ key: 'dataTables', resources: ['dataTable'] },
|
|
14
|
+
{ key: 'projectsAndFolders', resources: ['project'] },
|
|
15
|
+
{ key: 'communityNodes', resources: ['communityPackage'] },
|
|
16
|
+
{ key: 'preferences', resources: ['aiPreference'] },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Icons per scope resource prefix, shown in the client details modal. */
|
|
20
|
+
export const MCP_SCOPE_RESOURCE_ICONS: Record<string, IconName> = {
|
|
21
|
+
workflow: 'workflow',
|
|
22
|
+
execution: 'history',
|
|
23
|
+
agent: 'robot',
|
|
24
|
+
credential: 'key-round',
|
|
25
|
+
dataTable: 'table',
|
|
26
|
+
project: 'folder',
|
|
27
|
+
tag: 'tags',
|
|
28
|
+
communityPackage: 'package-open',
|
|
29
|
+
aiPreference: 'sliders-horizontal',
|
|
30
|
+
};
|
|
31
|
+
export const MCP_SETTINGS_VIEW = 'McpSettings';
|
|
32
|
+
export const MCP_WORKFLOWS_VIEW = 'McpSettingsWorkflows';
|
|
33
|
+
export const MCP_AGENTS_VIEW = 'McpSettingsAgents';
|
|
34
|
+
export const MCP_CLIENTS_VIEW = 'McpSettingsClients';
|
|
35
|
+
export const MCP_STORE = 'mcp';
|
|
36
|
+
|
|
37
|
+
export const LOADING_INDICATOR_TIMEOUT = 200;
|
|
38
|
+
export const MCP_TOOLTIP_DELAY = 100;
|
|
39
|
+
|
|
40
|
+
/** How many of the user's own connected clients the settings overview previews inline. */
|
|
41
|
+
export const MCP_CLIENTS_PREVIEW_LIMIT = 3;
|
|
42
|
+
|
|
43
|
+
export const MCP_CONNECT_POPOVER_WIDTH = 460;
|
|
44
|
+
|
|
45
|
+
export const UNKNOWN_COUNT_VALUE = '—';
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { i18nInstance, setLanguage } from '@n8n/i18n';
|
|
2
|
+
import type { Scope } from '@n8n/permissions';
|
|
3
|
+
import { useRBACStore } from '@n8n/stores/rbac.store';
|
|
4
|
+
import { createPinia, setActivePinia } from 'pinia';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
MCP_AGENTS_VIEW,
|
|
8
|
+
MCP_CLIENTS_VIEW,
|
|
9
|
+
MCP_SETTINGS_VIEW,
|
|
10
|
+
MCP_WORKFLOWS_VIEW,
|
|
11
|
+
} from './mcp.constants';
|
|
12
|
+
import { MCPModule } from './mcp.module';
|
|
13
|
+
|
|
14
|
+
describe('MCPModule', () => {
|
|
15
|
+
const settingsPage = () => MCPModule.settingsPages?.find((item) => item.id === 'settings-mcp');
|
|
16
|
+
|
|
17
|
+
const withScopes = (scopes: Scope[]) => {
|
|
18
|
+
useRBACStore().setGlobalScopes(scopes);
|
|
19
|
+
return settingsPage();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
setActivePinia(createPinia());
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('settings sidebar item', () => {
|
|
27
|
+
it('should hide the item from a user without an MCP scope', () => {
|
|
28
|
+
expect(withScopes([])?.available).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should hide the item from a user holding only an unrelated scope', () => {
|
|
32
|
+
expect(withScopes(['workflow:read'])?.available).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each<Scope>(['mcp:manage', 'mcp:oauth', 'mcpApiKey:create', 'mcpApiKey:rotate'])(
|
|
36
|
+
'should show the item to a user with the %s scope',
|
|
37
|
+
(scope) => {
|
|
38
|
+
expect(withScopes([scope])?.available).toBe(true);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
it('should re-evaluate availability when scopes change after registration', () => {
|
|
43
|
+
const item = withScopes([]);
|
|
44
|
+
expect(item?.available).toBe(false);
|
|
45
|
+
|
|
46
|
+
useRBACStore().addGlobalScope('mcp:oauth');
|
|
47
|
+
|
|
48
|
+
expect(item?.available).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('label', () => {
|
|
53
|
+
const MCP_LABEL_KEY = 'settings.mcp';
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
setLanguage('en');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should read the label from i18n', () => {
|
|
60
|
+
expect(settingsPage()?.label).toBe('Instance-level MCP');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should follow a language change after registration', () => {
|
|
64
|
+
const item = settingsPage();
|
|
65
|
+
|
|
66
|
+
// `Object.fromEntries`, because a dotted i18n key is not a lintable
|
|
67
|
+
// object-literal property name.
|
|
68
|
+
i18nInstance.global.mergeLocaleMessage(
|
|
69
|
+
'de',
|
|
70
|
+
Object.fromEntries([[MCP_LABEL_KEY, 'MCP-Zugriff']]),
|
|
71
|
+
);
|
|
72
|
+
setLanguage('de');
|
|
73
|
+
|
|
74
|
+
expect(item?.label).toBe('MCP-Zugriff');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should read i18n lazily, not when the descriptor is imported', () => {
|
|
78
|
+
// The shell imports the descriptor at boot, before `App.vue` sets the language.
|
|
79
|
+
const descriptor = Object.getOwnPropertyDescriptor(settingsPage(), 'label');
|
|
80
|
+
|
|
81
|
+
expect(typeof descriptor?.get).toBe('function');
|
|
82
|
+
expect(descriptor?.value).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('routes', () => {
|
|
87
|
+
it('should keep the route names and paths', () => {
|
|
88
|
+
expect(MCPModule.routes?.map(({ path, name }) => ({ path, name }))).toEqual([
|
|
89
|
+
{ path: 'mcp', name: MCP_SETTINGS_VIEW },
|
|
90
|
+
{ path: 'mcp/workflows', name: MCP_WORKFLOWS_VIEW },
|
|
91
|
+
{ path: 'mcp/agents', name: MCP_AGENTS_VIEW },
|
|
92
|
+
{ path: 'mcp/clients', name: MCP_CLIENTS_VIEW },
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should load every view lazily, so the shell does not pull them in at boot', () => {
|
|
97
|
+
for (const route of MCPModule.routes ?? []) {
|
|
98
|
+
expect(typeof route.component).toBe('function');
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should register no modals: the connect dialogs belong to their views', () => {
|
|
104
|
+
expect(MCPModule).not.toHaveProperty('modals');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useI18n } from '@n8n/i18n';
|
|
2
|
+
import { defineFrontendModule } from '@n8n/frontend-module-sdk';
|
|
3
|
+
import { useRBACStore } from '@n8n/stores/rbac.store';
|
|
4
|
+
import {
|
|
5
|
+
MCP_AGENTS_VIEW,
|
|
6
|
+
MCP_CLIENTS_VIEW,
|
|
7
|
+
MCP_SETTINGS_VIEW,
|
|
8
|
+
MCP_WORKFLOWS_VIEW,
|
|
9
|
+
} from './mcp.constants';
|
|
10
|
+
|
|
11
|
+
const SettingsMCPView = async () => await import('./SettingsMCPView.vue');
|
|
12
|
+
const SettingsMCPWorkflowsView = async () => await import('./SettingsMCPWorkflowsView.vue');
|
|
13
|
+
const SettingsMCPAgentsView = async () => await import('./SettingsMCPAgentsView.vue');
|
|
14
|
+
const SettingsMCPClientsView = async () => await import('./SettingsMCPClientsView.vue');
|
|
15
|
+
|
|
16
|
+
export const MCPModule = defineFrontendModule({
|
|
17
|
+
id: 'mcp',
|
|
18
|
+
name: 'MCP Server',
|
|
19
|
+
description: 'Access your n8n instance through MCP clients',
|
|
20
|
+
icon: 'mcp',
|
|
21
|
+
routes: [
|
|
22
|
+
{
|
|
23
|
+
path: 'mcp',
|
|
24
|
+
name: MCP_SETTINGS_VIEW,
|
|
25
|
+
component: SettingsMCPView,
|
|
26
|
+
meta: {
|
|
27
|
+
layout: 'settings',
|
|
28
|
+
middleware: ['authenticated', 'custom'],
|
|
29
|
+
telemetry: {
|
|
30
|
+
pageCategory: 'settings',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
path: 'mcp/workflows',
|
|
36
|
+
name: MCP_WORKFLOWS_VIEW,
|
|
37
|
+
component: SettingsMCPWorkflowsView,
|
|
38
|
+
meta: {
|
|
39
|
+
layout: 'settings',
|
|
40
|
+
middleware: ['authenticated', 'custom'],
|
|
41
|
+
telemetry: {
|
|
42
|
+
pageCategory: 'settings',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
path: 'mcp/agents',
|
|
48
|
+
name: MCP_AGENTS_VIEW,
|
|
49
|
+
component: SettingsMCPAgentsView,
|
|
50
|
+
meta: {
|
|
51
|
+
layout: 'settings',
|
|
52
|
+
middleware: ['authenticated', 'custom'],
|
|
53
|
+
telemetry: {
|
|
54
|
+
pageCategory: 'settings',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: 'mcp/clients',
|
|
60
|
+
name: MCP_CLIENTS_VIEW,
|
|
61
|
+
component: SettingsMCPClientsView,
|
|
62
|
+
meta: {
|
|
63
|
+
layout: 'settings',
|
|
64
|
+
middleware: ['authenticated', 'custom'],
|
|
65
|
+
telemetry: {
|
|
66
|
+
pageCategory: 'settings',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
settingsPages: [
|
|
72
|
+
{
|
|
73
|
+
id: 'settings-mcp',
|
|
74
|
+
icon: 'mcp',
|
|
75
|
+
get label() {
|
|
76
|
+
return useI18n().baseText('settings.mcp');
|
|
77
|
+
},
|
|
78
|
+
position: 'top',
|
|
79
|
+
route: { to: { name: MCP_SETTINGS_VIEW } },
|
|
80
|
+
get available() {
|
|
81
|
+
return useRBACStore().hasScope([
|
|
82
|
+
'mcp:manage',
|
|
83
|
+
'mcp:oauth',
|
|
84
|
+
'mcpApiKey:create',
|
|
85
|
+
'mcpApiKey:rotate',
|
|
86
|
+
]);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|