@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,124 @@
|
|
|
1
|
+
import type { Component } from 'vue';
|
|
2
|
+
|
|
3
|
+
import { MCP_CLIENT_BRAND_MATCHERS, MCP_INSTANCE_SCOPES } from '@n8n/api-types';
|
|
4
|
+
import type {
|
|
5
|
+
McpClientBrandName,
|
|
6
|
+
McpClientConnectedPeriod,
|
|
7
|
+
McpClientType,
|
|
8
|
+
McpClientTypeFilter,
|
|
9
|
+
OAuthClientResponseDto,
|
|
10
|
+
} from '@n8n/api-types';
|
|
11
|
+
import type { BaseTextKey, I18nClass } from '@n8n/i18n';
|
|
12
|
+
|
|
13
|
+
import ClaudeIcon from './assets/client-icons/claude.svg?component';
|
|
14
|
+
import CursorIcon from './assets/client-icons/cursor.svg?component';
|
|
15
|
+
import MistralIcon from './assets/client-icons/mistral.svg?component';
|
|
16
|
+
import OpenAiIcon from './assets/client-icons/openai.svg?component';
|
|
17
|
+
import VsCodeIcon from './assets/client-icons/vscode.svg?component';
|
|
18
|
+
|
|
19
|
+
export interface McpClientBrand {
|
|
20
|
+
icon: Component | null;
|
|
21
|
+
type: McpClientType | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Logos for the brands recognized by the shared name-pattern matchers. */
|
|
25
|
+
const BRAND_ICONS: Record<McpClientBrandName, Component> = {
|
|
26
|
+
claude: ClaudeIcon,
|
|
27
|
+
cursor: CursorIcon,
|
|
28
|
+
vscode: VsCodeIcon,
|
|
29
|
+
openai: OpenAiIcon,
|
|
30
|
+
mistral: MistralIcon,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Client names are bounded (a user's own registered clients), so memoizing the
|
|
34
|
+
// regex scan by name is safe; the map would need eviction only if that set ever
|
|
35
|
+
// became unbounded.
|
|
36
|
+
const brandCache = new Map<string, McpClientBrand>();
|
|
37
|
+
|
|
38
|
+
export function getClientBrand(clientName: string): McpClientBrand {
|
|
39
|
+
let brand = brandCache.get(clientName);
|
|
40
|
+
if (!brand) {
|
|
41
|
+
const match = MCP_CLIENT_BRAND_MATCHERS.find(({ pattern }) => pattern.test(clientName));
|
|
42
|
+
brand = match
|
|
43
|
+
? { icon: BRAND_ICONS[match.brand], type: match.type }
|
|
44
|
+
: { icon: null, type: null };
|
|
45
|
+
brandCache.set(clientName, brand);
|
|
46
|
+
}
|
|
47
|
+
return brand;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* i18n key suffix for a granted scope's human label, e.g. `workflow:read` →
|
|
52
|
+
* `workflow.read`. Unknown scopes have no label and are rendered verbatim.
|
|
53
|
+
*/
|
|
54
|
+
export function scopeLabelKeySuffix(scope: string): string {
|
|
55
|
+
return scope.replace(':', '.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Human label for a granted scope. Unknown scopes have no i18n entry and render
|
|
60
|
+
* verbatim.
|
|
61
|
+
*/
|
|
62
|
+
export function scopeLabel(
|
|
63
|
+
i18n: { baseText: (key: BaseTextKey) => string },
|
|
64
|
+
scope: string,
|
|
65
|
+
): string {
|
|
66
|
+
const key = `settings.mcp.oAuthClients.scope.${scopeLabelKeySuffix(scope)}` as BaseTextKey;
|
|
67
|
+
const label = i18n.baseText(key);
|
|
68
|
+
return label === key ? scope : label;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a grant covers every scope the instance currently offers.
|
|
73
|
+
* Older grants need fresh consent for scopes introduced after they were made.
|
|
74
|
+
*
|
|
75
|
+
* `offeredScopes` comes from the backend, which drops scopes this instance
|
|
76
|
+
* cannot serve (agent scopes when the agents module is off). Without it a
|
|
77
|
+
* full grant on such an instance would never count as full access.
|
|
78
|
+
*/
|
|
79
|
+
export function isFullAccessGrant(scopes: string[], offeredScopes?: string[]): boolean {
|
|
80
|
+
const required = offeredScopes?.length ? offeredScopes : MCP_INSTANCE_SCOPES;
|
|
81
|
+
return scopes.length > 0 && required.every((scope) => scopes.includes(scope));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Scopes spelled out in a one-line grant summary before the rest collapse into "+N". */
|
|
85
|
+
const ACCESS_SUMMARY_VISIBLE_SCOPES = 2;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One-line summary of a client's grant for list rows: "Full access", "No access",
|
|
89
|
+
* or the first two scope labels followed by a "+N" overflow.
|
|
90
|
+
*/
|
|
91
|
+
export function getAccessSummary(
|
|
92
|
+
i18n: Pick<I18nClass, 'baseText'>,
|
|
93
|
+
client: Pick<OAuthClientResponseDto, 'scopes'>,
|
|
94
|
+
offeredScopes?: string[],
|
|
95
|
+
): string {
|
|
96
|
+
if (client.scopes.length === 0) return i18n.baseText('settings.mcp.oAuthClients.access.none');
|
|
97
|
+
if (isFullAccessGrant(client.scopes, offeredScopes)) {
|
|
98
|
+
return i18n.baseText('settings.mcp.oAuthClients.access.full');
|
|
99
|
+
}
|
|
100
|
+
const visible = client.scopes
|
|
101
|
+
.slice(0, ACCESS_SUMMARY_VISIBLE_SCOPES)
|
|
102
|
+
.map((scope) => scopeLabel(i18n, scope))
|
|
103
|
+
.join(', ');
|
|
104
|
+
const remaining = client.scopes.length - ACCESS_SUMMARY_VISIBLE_SCOPES;
|
|
105
|
+
if (remaining <= 0) return visible;
|
|
106
|
+
return `${visible} ${i18n.baseText('settings.mcp.oAuthClients.scope.more', {
|
|
107
|
+
interpolate: { count: remaining },
|
|
108
|
+
})}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** UI state of the connected-clients search + filter popover; applied server-side. */
|
|
112
|
+
export interface OAuthClientFilters {
|
|
113
|
+
search: string;
|
|
114
|
+
type: McpClientTypeFilter | null;
|
|
115
|
+
ownerId: string | null;
|
|
116
|
+
connected: McpClientConnectedPeriod | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const EMPTY_OAUTH_CLIENT_FILTERS: OAuthClientFilters = {
|
|
120
|
+
search: '',
|
|
121
|
+
type: null,
|
|
122
|
+
ownerId: null,
|
|
123
|
+
connected: null,
|
|
124
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useI18n } from '@n8n/i18n';
|
|
3
|
+
import { useClipboard } from '@n8n/composables/useClipboard';
|
|
4
|
+
import { N8nButton, N8nTooltip, N8nInfoTip, N8nInput, N8nLoading } from '@n8n/design-system';
|
|
5
|
+
import { MCP_TOOLTIP_DELAY } from '../mcp.constants';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
value: string;
|
|
11
|
+
valueLoading?: boolean;
|
|
12
|
+
infoTip?: string;
|
|
13
|
+
allowCopy?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const { copy, copied, isSupported } = useClipboard();
|
|
17
|
+
const i18n = useI18n();
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
20
|
+
allowCopy: true,
|
|
21
|
+
maxWidth: undefined,
|
|
22
|
+
infoTip: undefined,
|
|
23
|
+
valueLoading: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
copy: [value: string];
|
|
28
|
+
}>();
|
|
29
|
+
|
|
30
|
+
const handleCopy = async (value: string) => {
|
|
31
|
+
await copy(value);
|
|
32
|
+
emit('copy', value);
|
|
33
|
+
};
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div :class="$style.container">
|
|
38
|
+
<div :class="$style['label-wrapper']">
|
|
39
|
+
<label :class="$style.label" :for="`connection-parameter-${props.id}`">
|
|
40
|
+
{{ props.label }}
|
|
41
|
+
</label>
|
|
42
|
+
<div v-if="props.infoTip" :class="$style['info-tip']">
|
|
43
|
+
<N8nInfoTip type="tooltip" size="small" :enterable="false">
|
|
44
|
+
{{ props.infoTip }}
|
|
45
|
+
</N8nInfoTip>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div
|
|
49
|
+
:id="`connection-parameter-${props.id}`"
|
|
50
|
+
:class="{
|
|
51
|
+
[$style['parameter-value']]: true,
|
|
52
|
+
[$style['parameter-value--loading']]: props.valueLoading,
|
|
53
|
+
}"
|
|
54
|
+
data-test-id="connection-parameter-value"
|
|
55
|
+
>
|
|
56
|
+
<div :class="$style['input-wrapper']">
|
|
57
|
+
<N8nLoading
|
|
58
|
+
v-if="props.valueLoading"
|
|
59
|
+
:loading="props.valueLoading"
|
|
60
|
+
variant="h1"
|
|
61
|
+
:class="$style['parameter-skeleton']"
|
|
62
|
+
/>
|
|
63
|
+
<N8nInput v-else v-model="props.value" type="text" :readonly="true" />
|
|
64
|
+
</div>
|
|
65
|
+
<div :class="$style['copy-button-wrapper']">
|
|
66
|
+
<slot name="customActions" />
|
|
67
|
+
<N8nTooltip
|
|
68
|
+
:disabled="!isSupported"
|
|
69
|
+
:content="copied ? i18n.baseText('generic.copied') : i18n.baseText('generic.copy')"
|
|
70
|
+
:show-after="MCP_TOOLTIP_DELAY"
|
|
71
|
+
placement="bottom"
|
|
72
|
+
>
|
|
73
|
+
<N8nButton
|
|
74
|
+
v-if="props.allowCopy && isSupported"
|
|
75
|
+
variant="subtle"
|
|
76
|
+
icon-only
|
|
77
|
+
:icon="copied ? 'check' : 'copy'"
|
|
78
|
+
:class="$style['copy-button']"
|
|
79
|
+
:disabled="props.valueLoading"
|
|
80
|
+
@click="handleCopy(props.value)"
|
|
81
|
+
/>
|
|
82
|
+
</N8nTooltip>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<style module lang="scss">
|
|
89
|
+
.container {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
gap: var(--spacing--4xs);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.label-wrapper {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
gap: var(--spacing--4xs);
|
|
99
|
+
|
|
100
|
+
.info-tip {
|
|
101
|
+
visibility: hidden;
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
padding-top: 1px;
|
|
104
|
+
|
|
105
|
+
:global(.n8n-info-tip) {
|
|
106
|
+
display: flex;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&:hover .info-tip span {
|
|
111
|
+
visibility: visible;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.label {
|
|
116
|
+
font-size: var(--font-size--sm);
|
|
117
|
+
color: var(--color--text--shade-1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.parameter-value {
|
|
121
|
+
display: flex;
|
|
122
|
+
align-items: stretch;
|
|
123
|
+
justify-content: space-between;
|
|
124
|
+
font-size: var(--font-size--2xs);
|
|
125
|
+
color: var(--color--text);
|
|
126
|
+
gap: var(--spacing--2xs);
|
|
127
|
+
border: var(--border);
|
|
128
|
+
border-radius: var(--radius);
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
|
|
131
|
+
&--loading {
|
|
132
|
+
gap: 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.input-wrapper {
|
|
136
|
+
flex: 1;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
|
|
141
|
+
:global(.n8n-input__wrapper) {
|
|
142
|
+
--input--border--shadow: 0 0 0 0 transparent;
|
|
143
|
+
--input--border--shadow--hover: 0 0 0 0 transparent;
|
|
144
|
+
--input--border--shadow--focus: 0 0 0 0 transparent;
|
|
145
|
+
|
|
146
|
+
&:focus-within {
|
|
147
|
+
outline: none;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
:global(.n8n-input) {
|
|
153
|
+
flex: 1;
|
|
154
|
+
align-items: center;
|
|
155
|
+
|
|
156
|
+
input {
|
|
157
|
+
font-family: monospace;
|
|
158
|
+
font-size: var(--font-size--2xs);
|
|
159
|
+
border: none;
|
|
160
|
+
color: var(--color--text);
|
|
161
|
+
min-height: var(--spacing--md);
|
|
162
|
+
height: var(--spacing--md);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.parameter-skeleton div {
|
|
167
|
+
margin: 0;
|
|
168
|
+
height: calc(var(--spacing--xl) - 2 * var(--border-width));
|
|
169
|
+
border-radius: 0 0 var(--radius) var(--radius);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
button {
|
|
173
|
+
height: auto;
|
|
174
|
+
border: none;
|
|
175
|
+
border-radius: 0;
|
|
176
|
+
box-shadow: none;
|
|
177
|
+
|
|
178
|
+
&:hover {
|
|
179
|
+
border-color: inherit;
|
|
180
|
+
box-shadow: none;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
&:active {
|
|
184
|
+
box-shadow: none;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
button + button {
|
|
189
|
+
border-left: var(--border);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@media screen and (max-width: 820px) {
|
|
193
|
+
word-wrap: break-word;
|
|
194
|
+
margin-top: var(--spacing--2xs);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.copy-button-wrapper {
|
|
199
|
+
display: flex;
|
|
200
|
+
align-items: stretch;
|
|
201
|
+
border-left: var(--border);
|
|
202
|
+
|
|
203
|
+
// Tooltip triggers wrap each button in a span, which must stretch it too.
|
|
204
|
+
> span {
|
|
205
|
+
display: flex;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.copy-button {
|
|
210
|
+
border: none;
|
|
211
|
+
border-radius: 0;
|
|
212
|
+
}
|
|
213
|
+
</style>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useMCPStore } from '../mcp.store';
|
|
3
|
+
import { LOADING_INDICATOR_TIMEOUT } from '../mcp.constants';
|
|
4
|
+
import { N8nSelect, N8nOption, N8nText } from '@n8n/design-system';
|
|
5
|
+
import { computed, onMounted, ref, useCssModule } from 'vue';
|
|
6
|
+
import type { McpAgent } from '../mcp.types';
|
|
7
|
+
import { useI18n } from '@n8n/i18n';
|
|
8
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
9
|
+
import { sleep } from '@n8n/utils/sleep';
|
|
10
|
+
|
|
11
|
+
defineProps<{
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const i18n = useI18n();
|
|
17
|
+
const toast = useToast();
|
|
18
|
+
|
|
19
|
+
const modelValue = defineModel<string[]>({ default: () => [] });
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
ready: [];
|
|
23
|
+
confirm: [];
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const mcpStore = useMCPStore();
|
|
27
|
+
|
|
28
|
+
const isLoading = ref(false);
|
|
29
|
+
const hasFetched = ref(false);
|
|
30
|
+
const isDropdownVisible = ref(false);
|
|
31
|
+
const selectRef = ref<InstanceType<typeof N8nSelect>>();
|
|
32
|
+
const agentOptions = ref<McpAgent[]>([]);
|
|
33
|
+
let loadingTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
+
|
|
35
|
+
const showEmptyState = computed(() => {
|
|
36
|
+
return !isLoading.value && hasFetched.value && agentOptions.value.length === 0;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const $style = useCssModule();
|
|
40
|
+
|
|
41
|
+
// `popper-class` is a string prop, so the classes are joined here rather than
|
|
42
|
+
// passed as an object binding.
|
|
43
|
+
const popperClass = computed(() =>
|
|
44
|
+
[
|
|
45
|
+
isLoading.value ? $style['mcp-agents-select-loading'] : '',
|
|
46
|
+
showEmptyState.value ? $style['mcp-agents-select-empty'] : '',
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(' '),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const projectName = (agent: McpAgent) =>
|
|
53
|
+
agent.project?.type === 'personal'
|
|
54
|
+
? i18n.baseText('projects.menu.personal')
|
|
55
|
+
: (agent.project?.name ?? '');
|
|
56
|
+
|
|
57
|
+
async function searchAgents(query?: string) {
|
|
58
|
+
if (loadingTimeoutId) {
|
|
59
|
+
clearTimeout(loadingTimeoutId);
|
|
60
|
+
loadingTimeoutId = null;
|
|
61
|
+
}
|
|
62
|
+
isLoading.value = true;
|
|
63
|
+
hasFetched.value = false;
|
|
64
|
+
try {
|
|
65
|
+
const response = await mcpStore.getMcpEligibleAgents({
|
|
66
|
+
take: 10,
|
|
67
|
+
query: query ?? undefined,
|
|
68
|
+
});
|
|
69
|
+
agentOptions.value = response?.data ?? [];
|
|
70
|
+
} catch (e) {
|
|
71
|
+
toast.showError(e, i18n.baseText('settings.mcp.connectAgents.error'));
|
|
72
|
+
} finally {
|
|
73
|
+
await sleep(LOADING_INDICATOR_TIMEOUT);
|
|
74
|
+
isLoading.value = false;
|
|
75
|
+
hasFetched.value = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function focusOnInput() {
|
|
80
|
+
selectRef.value?.focusOnInput();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function onVisibleChange(visible: boolean) {
|
|
84
|
+
isDropdownVisible.value = visible;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onKeydownCapture(event: KeyboardEvent) {
|
|
88
|
+
if (event.key === 'Enter' && !isDropdownVisible.value && modelValue.value.length > 0) {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
event.stopPropagation();
|
|
91
|
+
emit('confirm');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
onMounted(async () => {
|
|
96
|
+
await searchAgents();
|
|
97
|
+
emit('ready');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
defineExpose({
|
|
101
|
+
focusOnInput,
|
|
102
|
+
});
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<div @keydown.enter.capture="onKeydownCapture">
|
|
107
|
+
<N8nSelect
|
|
108
|
+
ref="selectRef"
|
|
109
|
+
v-model="modelValue"
|
|
110
|
+
data-test-id="mcp-agents-select"
|
|
111
|
+
:placeholder="placeholder"
|
|
112
|
+
:disabled="disabled"
|
|
113
|
+
:loading="isLoading"
|
|
114
|
+
:multiple="true"
|
|
115
|
+
:filterable="true"
|
|
116
|
+
:remote="true"
|
|
117
|
+
:remote-method="searchAgents"
|
|
118
|
+
size="medium"
|
|
119
|
+
:popper-class="popperClass"
|
|
120
|
+
:teleported="false"
|
|
121
|
+
@visible-change="onVisibleChange"
|
|
122
|
+
>
|
|
123
|
+
<N8nOption v-if="showEmptyState" value="" disabled :class="$style['empty-option']">
|
|
124
|
+
{{ i18n.baseText('settings.mcp.connectAgents.emptyState') }}
|
|
125
|
+
</N8nOption>
|
|
126
|
+
<N8nOption
|
|
127
|
+
v-for="agent in agentOptions"
|
|
128
|
+
:key="agent.id"
|
|
129
|
+
:value="agent.id"
|
|
130
|
+
:label="agent.name"
|
|
131
|
+
>
|
|
132
|
+
<div :class="$style.option">
|
|
133
|
+
<N8nText :class="$style.truncate">{{ projectName(agent) }}</N8nText>
|
|
134
|
+
<span :class="$style.separator">/</span>
|
|
135
|
+
<N8nText :class="$style.truncate" color="text-dark">{{ agent.name }}</N8nText>
|
|
136
|
+
</div>
|
|
137
|
+
</N8nOption>
|
|
138
|
+
</N8nSelect>
|
|
139
|
+
</div>
|
|
140
|
+
</template>
|
|
141
|
+
|
|
142
|
+
<style module lang="scss">
|
|
143
|
+
.mcp-agents-select-loading,
|
|
144
|
+
.mcp-agents-select-empty {
|
|
145
|
+
display: flex;
|
|
146
|
+
min-height: var(--spacing--5xl);
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: space-around;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.empty-option {
|
|
152
|
+
cursor: default !important;
|
|
153
|
+
color: var(--color--text) !important;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.option {
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
gap: var(--spacing--4xs);
|
|
160
|
+
min-width: 0;
|
|
161
|
+
overflow: hidden;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.separator {
|
|
165
|
+
user-select: none;
|
|
166
|
+
color: var(--color--text--tint-1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.truncate {
|
|
170
|
+
display: block;
|
|
171
|
+
overflow: hidden;
|
|
172
|
+
text-overflow: ellipsis;
|
|
173
|
+
white-space: nowrap;
|
|
174
|
+
min-width: 0;
|
|
175
|
+
}
|
|
176
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
|
|
4
|
+
import { MCP_DOCS_PAGE_URL } from '../mcp.constants';
|
|
5
|
+
import { useI18n } from '@n8n/i18n';
|
|
6
|
+
import McpEmptyStateCard from './McpEmptyStateCard.vue';
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
loading?: boolean;
|
|
11
|
+
managedByEnv?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
15
|
+
disabled: false,
|
|
16
|
+
loading: false,
|
|
17
|
+
managedByEnv: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
turnOnMcp: [];
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const i18n = useI18n();
|
|
25
|
+
|
|
26
|
+
const buttonDisabled = computed(() => props.disabled || props.loading);
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<McpEmptyStateCard
|
|
31
|
+
surface
|
|
32
|
+
:class="$style.card"
|
|
33
|
+
data-test-id="mcp-empty-state-container"
|
|
34
|
+
:title="i18n.baseText('settings.mcp.actionBox.heading')"
|
|
35
|
+
:description="i18n.baseText('settings.mcp.emptyState.description')"
|
|
36
|
+
>
|
|
37
|
+
<template #actions>
|
|
38
|
+
<N8nButton
|
|
39
|
+
variant="ghost"
|
|
40
|
+
size="medium"
|
|
41
|
+
:href="MCP_DOCS_PAGE_URL"
|
|
42
|
+
target="_blank"
|
|
43
|
+
data-test-id="mcp-empty-state-learn-more"
|
|
44
|
+
>
|
|
45
|
+
{{ i18n.baseText('generic.learnMore') }} <N8nIcon icon="arrow-up-right" size="small" />
|
|
46
|
+
</N8nButton>
|
|
47
|
+
<N8nTooltip :disabled="!buttonDisabled">
|
|
48
|
+
<template #content>
|
|
49
|
+
<span v-if="props.loading">{{ i18n.baseText('generic.loading') }}...</span>
|
|
50
|
+
<span v-else-if="props.managedByEnv">
|
|
51
|
+
{{ i18n.baseText('settings.mcp.managedByEnv.tooltip') }}
|
|
52
|
+
</span>
|
|
53
|
+
<span v-else>
|
|
54
|
+
{{ i18n.baseText('settings.mcp.toggle.disabled.tooltip') }}
|
|
55
|
+
</span>
|
|
56
|
+
</template>
|
|
57
|
+
<N8nButton
|
|
58
|
+
variant="solid"
|
|
59
|
+
size="medium"
|
|
60
|
+
:disabled="buttonDisabled"
|
|
61
|
+
data-test-id="enable-mcp-access-button"
|
|
62
|
+
@click="emit('turnOnMcp')"
|
|
63
|
+
>
|
|
64
|
+
{{ i18n.baseText('settings.mcp.actionBox.button.label') }}
|
|
65
|
+
</N8nButton>
|
|
66
|
+
</N8nTooltip>
|
|
67
|
+
</template>
|
|
68
|
+
</McpEmptyStateCard>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<style lang="scss" module>
|
|
72
|
+
.card {
|
|
73
|
+
margin-top: var(--spacing--xl);
|
|
74
|
+
}
|
|
75
|
+
</style>
|