feat(core): Add more details to MCP errors and telemetry (no-changelog) (#22118)

This commit is contained in:
Milorad FIlipović 2025-11-21 14:48:54 +01:00 committed by GitHub
parent 6bec15a114
commit 648f1749e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 270 additions and 74 deletions

View File

@ -213,7 +213,7 @@ describe('McpOAuthTokenService', () => {
const invalidToken = 'invalid.jwt.token';
await expect(service.verifyAccessToken(invalidToken)).rejects.toThrow(
'Invalid access token: JWT verification failed',
'JWT Verification Failed',
);
});
@ -225,7 +225,7 @@ describe('McpOAuthTokenService', () => {
});
await expect(service.verifyAccessToken(wrongAudienceToken)).rejects.toThrow(
'Invalid access token: JWT verification failed',
'JWT Verification Failed',
);
});
@ -237,7 +237,7 @@ describe('McpOAuthTokenService', () => {
accessTokenRepository.findOne.mockResolvedValue(null);
await expect(service.verifyAccessToken(accessToken)).rejects.toThrow(
'Invalid access token: not found in database',
'Access Token Not Found in Database',
);
});
});
@ -261,7 +261,7 @@ describe('McpOAuthTokenService', () => {
const result = await service.verifyOAuthAccessToken(accessToken);
expect(result).toEqual(user);
expect(result).toEqual({ user });
expect(userRepository.findOne).toHaveBeenCalledWith({
where: { id: userId },
relations: ['role'],
@ -273,7 +273,7 @@ describe('McpOAuthTokenService', () => {
const result = await service.verifyOAuthAccessToken(invalidToken);
expect(result).toBeNull();
expect(result).toMatchObject({ user: null });
});
it('should return null when user not found', async () => {
@ -292,7 +292,7 @@ describe('McpOAuthTokenService', () => {
const result = await service.verifyOAuthAccessToken(accessToken);
expect(result).toBeNull();
expect(result).toMatchObject({ user: null });
});
});

View File

@ -58,11 +58,11 @@ describe('McpServerMiddlewareService', () => {
meta: { isOAuth: true },
});
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user);
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue({ user });
const result = await service.getUserForToken(oauthToken);
expect(result).toEqual(user);
expect(result).toEqual({ user });
expect(oauthTokenService.verifyOAuthAccessToken).toHaveBeenCalledWith(oauthToken);
expect(mcpServerApiKeyService.verifyApiKey).not.toHaveBeenCalled();
});
@ -74,11 +74,11 @@ describe('McpServerMiddlewareService', () => {
aud: 'mcp-server-api',
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user });
const result = await service.getUserForToken(apiKeyToken);
expect(result).toEqual(user);
expect(result).toEqual({ user });
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
@ -91,11 +91,11 @@ describe('McpServerMiddlewareService', () => {
meta: { isOAuth: false },
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user });
const result = await service.getUserForToken(apiKeyToken);
expect(result).toEqual(user);
expect(result).toEqual({ user });
expect(mcpServerApiKeyService.verifyApiKey).toHaveBeenCalledWith(apiKeyToken);
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
@ -103,10 +103,10 @@ describe('McpServerMiddlewareService', () => {
it('should return null for invalid JWT format', async () => {
const invalidToken = 'not-a-jwt-token';
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
const result = await service.getUserForToken(invalidToken);
expect(result).toBeNull();
expect(result).toMatchObject({ user: null });
expect(oauthTokenService.verifyOAuthAccessToken).not.toHaveBeenCalled();
});
@ -117,11 +117,11 @@ describe('McpServerMiddlewareService', () => {
meta: { isOAuth: true },
});
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null);
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue({ user: null });
const result = await service.getUserForToken(oauthToken);
expect(result).toBeNull();
expect(result).toMatchObject({ user: null });
});
it('should return null when API key verification fails', async () => {
@ -130,11 +130,11 @@ describe('McpServerMiddlewareService', () => {
aud: 'mcp-server-api',
});
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
const result = await service.getUserForToken(apiKeyToken);
expect(result).toBeNull();
expect(result).toMatchObject({ user: null });
});
});
@ -153,17 +153,22 @@ describe('McpServerMiddlewareService', () => {
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({ message: 'Unauthorized' });
expect(res.send).toHaveBeenCalledWith({
message: 'Unauthorized: Authorization header not sent',
});
expect(next).not.toHaveBeenCalled();
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
mcp_connection_status: 'error',
error: 'Unauthorized',
client_name: undefined,
client_version: undefined,
auth_type: 'unknown',
error_details: 'Authorization header not sent',
reason: 'missing_authorization_header',
});
});
it('should throw error when authorization header does not start with Bearer', async () => {
it('should return 401 with WWW-Authenticate header when authorization header does not start with Bearer', async () => {
const req = mockReqWith('Basic sometoken');
const res = mockDeep<Response>();
res.status.mockReturnThis();
@ -171,13 +176,26 @@ describe('McpServerMiddlewareService', () => {
const middleware = service.getAuthMiddleware();
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
await middleware(req, res, next);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({
message: 'Unauthorized: Invalid authorization header format - Missing Bearer prefix',
});
expect(next).not.toHaveBeenCalled();
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
mcp_connection_status: 'error',
error: 'Unauthorized',
client_name: undefined,
client_version: undefined,
auth_type: 'unknown',
error_details: 'Invalid authorization header format - Missing Bearer prefix',
reason: 'invalid_bearer_format',
});
});
it('should throw error when Bearer token is malformed', async () => {
it('should return 401 with WWW-Authenticate header when Bearer token is malformed', async () => {
const req = mockReqWith('Bearer');
const res = mockDeep<Response>();
res.status.mockReturnThis();
@ -185,10 +203,23 @@ describe('McpServerMiddlewareService', () => {
const middleware = service.getAuthMiddleware();
await expect(middleware(req, res, next)).rejects.toThrow(
'Invalid authorization header format',
);
await middleware(req, res, next);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({
message: 'Unauthorized: Invalid authorization header format - Malformed Bearer token',
});
expect(next).not.toHaveBeenCalled();
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
mcp_connection_status: 'error',
error: 'Unauthorized',
client_name: undefined,
client_version: undefined,
auth_type: 'unknown',
error_details: 'Invalid authorization header format - Malformed Bearer token',
reason: 'invalid_bearer_format',
});
});
it('should authenticate with valid OAuth token and call next', async () => {
@ -203,7 +234,7 @@ describe('McpServerMiddlewareService', () => {
const res = mockDeep<Response>();
const next = jest.fn() as NextFunction;
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(user);
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue({ user });
const middleware = service.getAuthMiddleware();
@ -225,7 +256,7 @@ describe('McpServerMiddlewareService', () => {
const res = mockDeep<Response>();
const next = jest.fn() as NextFunction;
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(user);
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user });
const middleware = service.getAuthMiddleware();
@ -250,7 +281,7 @@ describe('McpServerMiddlewareService', () => {
res.header.mockReturnThis();
const next = jest.fn() as NextFunction;
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue(null);
oauthTokenService.verifyOAuthAccessToken.mockResolvedValue({ user: null });
const middleware = service.getAuthMiddleware();
@ -282,11 +313,18 @@ describe('McpServerMiddlewareService', () => {
await middleware(req, res, next);
expect(res.header).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith({
message: 'Unauthorized: Authorization header not sent',
});
expect(telemetry.track).toHaveBeenCalledWith('User connected to MCP server', {
mcp_connection_status: 'error',
error: 'Unauthorized',
client_name: 'test-client',
client_version: '1.0.0',
auth_type: 'unknown',
error_details: 'Authorization header not sent',
reason: 'missing_authorization_header',
});
});
@ -298,7 +336,7 @@ describe('McpServerMiddlewareService', () => {
res.header.mockReturnThis();
const next = jest.fn() as NextFunction;
mcpServerApiKeyService.verifyApiKey.mockResolvedValue(null);
mcpServerApiKeyService.verifyApiKey.mockResolvedValue({ user: null });
const middleware = service.getAuthMiddleware();

View File

@ -2,11 +2,12 @@ import { ApiKey, ApiKeyRepository, User, UserRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { EntityManager } from '@n8n/typeorm';
import { randomUUID } from 'crypto';
import { ApiKeyAudience } from 'n8n-workflow';
import { JwtService } from '@/services/jwt.service';
import { ApiKeyAudience, ensureError } from 'n8n-workflow';
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
import { UserWithContext } from './mcp.types';
import { JwtService } from '@/services/jwt.service';
const API_KEY_AUDIENCE: ApiKeyAudience = 'mcp-server-api';
const API_KEY_ISSUER = 'n8n';
@ -77,16 +78,34 @@ export class McpServerApiKeyService {
});
}
async verifyApiKey(apiKey: string): Promise<User | null> {
async verifyApiKey(apiKey: string): Promise<UserWithContext> {
try {
this.jwtService.verify(apiKey, {
issuer: API_KEY_ISSUER,
audience: API_KEY_AUDIENCE,
});
return await this.getUserForApiKey(apiKey);
const user = await this.getUserForApiKey(apiKey);
if (!user) {
return {
user: null,
context: {
reason: 'user_not_found',
auth_type: 'api_key',
},
};
}
return { user };
} catch (error) {
return null;
const errorForSure = ensureError(error);
return {
user: null,
context: {
reason: errorForSure.name === 'JsonWebTokenError' ? 'invalid_token' : 'unknown_error',
auth_type: 'api_key',
error_details: errorForSure.message,
},
};
}
}

View File

@ -2,17 +2,20 @@ import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import { User, UserRepository, withTransaction } from '@n8n/db';
import { UserRepository, withTransaction } from '@n8n/db';
import { Service } from '@n8n/di';
import { MoreThanOrEqual } from '@n8n/typeorm';
import { ensureError } from 'n8n-workflow';
import { randomBytes, randomUUID } from 'node:crypto';
import { JwtService } from '@/services/jwt.service';
import { AccessToken } from './database/entities/oauth-access-token.entity';
import { RefreshToken } from './database/entities/oauth-refresh-token.entity';
import { AccessTokenRepository } from './database/repositories/oauth-access-token.repository';
import { RefreshTokenRepository } from './database/repositories/oauth-refresh-token.repository';
import { AccessTokenNotFoundError, JWTVerificationError } from './mcp.errors';
import { UserWithContext } from './mcp.types';
import { JwtService } from '@/services/jwt.service';
/**
* Manages OAuth 2.1 token lifecycle for MCP server
@ -142,7 +145,7 @@ export class McpOAuthTokenService {
try {
decoded = this.jwtService.verify(token, { audience: this.MCP_AUDIENCE });
} catch (error) {
throw new Error('Invalid access token: JWT verification failed');
throw new JWTVerificationError();
}
const accessTokenRecord = await this.accessTokenRepository.findOne({
@ -150,7 +153,7 @@ export class McpOAuthTokenService {
});
if (!accessTokenRecord) {
throw new Error('Invalid access token: not found in database');
throw new AccessTokenNotFoundError();
}
return {
@ -163,13 +166,13 @@ export class McpOAuthTokenService {
};
}
async verifyOAuthAccessToken(token: string): Promise<User | null> {
async verifyOAuthAccessToken(token: string): Promise<UserWithContext> {
try {
const authInfo = await this.verifyAccessToken(token);
const userId = authInfo.extra?.userId as string;
if (!userId) {
return null;
return { user: null, context: { reason: 'user_id_not_in_auth_info', auth_type: 'oauth' } };
}
const user = await this.userRepository.findOne({
@ -177,9 +180,27 @@ export class McpOAuthTokenService {
relations: ['role'],
});
return user;
if (!user) {
return { user: null, context: { reason: 'user_not_found', auth_type: 'oauth' } };
}
return { user };
} catch (error) {
return null;
const errorForSure = ensureError(error);
const reason =
errorForSure instanceof JWTVerificationError
? 'invalid_token'
: errorForSure instanceof AccessTokenNotFoundError
? 'token_not_found_in_db'
: 'unknown_error';
return {
user: null,
context: {
reason,
auth_type: 'oauth',
error_details: errorForSure.message,
},
};
}
}

View File

@ -1,16 +1,18 @@
import { AuthenticatedRequest, User } from '@n8n/db';
import { AuthenticatedRequest } from '@n8n/db';
import { Service } from '@n8n/di';
import { NextFunction, Response, Request } from 'express';
import { AuthError } from '@/errors/response-errors/auth.error';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
import { ensureError } from 'n8n-workflow';
import { McpServerApiKeyService } from './mcp-api-key.service';
import { McpOAuthTokenService } from './mcp-oauth-token.service';
import { USER_CONNECTED_TO_MCP_EVENT, UNAUTHORIZED_ERROR_MESSAGE } from './mcp.constants';
import type { TelemetryAuthContext, UserWithContext } from './mcp.types';
import { getClientInfo } from './mcp.utils';
import { AuthError } from '@/errors/response-errors/auth.error';
import { JwtService } from '@/services/jwt.service';
import { Telemetry } from '@/telemetry';
/**
* MCP Server Middleware Service
* Centralizes authentication for MCP server endpoints
@ -29,12 +31,19 @@ export class McpServerMiddlewareService {
* Get user for a given token (API key or OAuth access token)
* Uses JWT metadata to determine token type and route to correct validation
*/
async getUserForToken(token: string): Promise<User | null> {
async getUserForToken(token: string): Promise<UserWithContext> {
let decoded: { meta?: { isOAuth?: boolean } };
try {
decoded = this.jwtService.decode<{ meta?: { isOAuth?: boolean } }>(token);
} catch (error) {
return null;
return {
user: null,
context: {
reason: 'jwt_decode_failed',
auth_type: 'unknown',
error_details: ensureError(error).message,
},
};
}
if (decoded?.meta?.isOAuth === true) {
@ -53,21 +62,32 @@ export class McpServerMiddlewareService {
const authorizationHeader = req.header('authorization');
if (!authorizationHeader) {
this.responseWithUnauthorized(res, req);
this.responseWithUnauthorized(res, req, {
reason: 'missing_authorization_header',
auth_type: 'unknown',
error_details: 'Authorization header not sent',
});
return;
}
const token = this.extractBearerToken(authorizationHeader);
if (!token) {
this.responseWithUnauthorized(res, req);
let token: string;
try {
token = this.extractBearerToken(authorizationHeader);
} catch (er) {
const error = ensureError(er);
this.responseWithUnauthorized(res, req, {
reason: 'invalid_bearer_format',
auth_type: 'unknown',
error_details: error.message,
});
return;
}
const user = await this.getUserForToken(token);
const result = await this.getUserForToken(token);
const user = result.user;
if (!user) {
this.responseWithUnauthorized(res, req);
this.responseWithUnauthorized(res, req, result.context);
return;
}
@ -77,9 +97,9 @@ export class McpServerMiddlewareService {
};
}
private extractBearerToken(headerValue: string): string | null {
private extractBearerToken(headerValue: string): string {
if (!headerValue.startsWith('Bearer')) {
throw new AuthError('Invalid authorization header format');
throw new AuthError('Invalid authorization header format - Missing Bearer prefix');
}
const tokenMatch = headerValue.match(/^Bearer\s+(.+)$/i);
@ -87,23 +107,27 @@ export class McpServerMiddlewareService {
return tokenMatch[1];
}
throw new AuthError('Invalid authorization header format');
throw new AuthError('Invalid authorization header format - Malformed Bearer token');
}
private responseWithUnauthorized(res: Response, req: Request) {
this.trackUnauthorizedEvent(req);
private responseWithUnauthorized(res: Response, req: Request, context?: TelemetryAuthContext) {
this.trackUnauthorizedEvent(req, context);
// RFC 6750 Section 3: Include WWW-Authenticate header for 401 responses
res.header('WWW-Authenticate', 'Bearer realm="n8n MCP Server"');
res.status(401).send({ message: UNAUTHORIZED_ERROR_MESSAGE });
res.status(401).send({
message: `${UNAUTHORIZED_ERROR_MESSAGE}${context?.error_details ? ': ' + context.error_details : ''}`,
});
}
private trackUnauthorizedEvent(req: Request) {
private trackUnauthorizedEvent(req: Request, context?: TelemetryAuthContext) {
const clientInfo = getClientInfo(req);
this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, {
const payload = {
mcp_connection_status: 'error',
error: UNAUTHORIZED_ERROR_MESSAGE,
client_name: clientInfo?.name,
client_version: clientInfo?.version,
});
...context,
};
this.telemetry.track(USER_CONNECTED_TO_MCP_EVENT, payload);
}
}

View File

@ -1,6 +1,8 @@
import { Time } from '@n8n/constants';
import { UserError } from 'n8n-workflow';
import { AuthError } from '@/errors/response-errors/auth.error';
/**
* Error thrown when MCP workflow execution times out
*/
@ -17,3 +19,17 @@ export class McpExecutionTimeoutError extends UserError {
this.timeoutMs = timeoutMs;
}
}
export class JWTVerificationError extends AuthError {
constructor() {
super('JWT Verification Failed');
this.name = 'JWTVerificationError';
}
}
export class AccessTokenNotFoundError extends AuthError {
constructor() {
super('Access Token Not Found in Database');
this.name = 'AccessTokenNotFoundError';
}
}

View File

@ -1,4 +1,5 @@
import { type ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { User } from '@n8n/db';
import type { INode } from 'n8n-workflow';
import type z from 'zod';
@ -92,3 +93,26 @@ type SupportedTriggerNodeTypes = keyof typeof SUPPORTED_MCP_TRIGGERS;
export type MCPTriggersMap = {
[K in SupportedTriggerNodeTypes]: INode[];
};
export type AuthFailureReason =
| 'missing_authorization_header'
| 'invalid_bearer_format'
| 'jwt_decode_failed'
| 'invalid_token'
| 'token_not_found_in_db'
| 'user_not_found'
| 'user_id_not_in_auth_info'
| 'unknown_error';
export type Mcpauth_type = 'oauth' | 'api_key' | 'unknown';
export type TelemetryAuthContext = {
reason: AuthFailureReason;
auth_type: Mcpauth_type;
error_details?: string;
};
export type UserWithContext = {
user: User | null;
context?: TelemetryAuthContext;
};

View File

@ -8,6 +8,7 @@ import type { OAuthClientResponseDto } from '@n8n/api-types';
import OAuthConnectionInstructions from '@/features/ai/mcpAccess/components/connectionInstructions/OAuthConnectionInstructions.vue';
import AccessTokenConnectionInstructions from '@/features/ai/mcpAccess/components/connectionInstructions/AccessTokenConnectionInstructions.vue';
import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants';
import { useTelemetry } from '@/app/composables/useTelemetry';
const MCP_ENDPOINT = 'mcp-server/http';
@ -30,6 +31,7 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const telemetry = useTelemetry();
const selectedTab = ref<ConnectionTabType>('oauth');
@ -47,6 +49,23 @@ const tabs = ref<Array<TabOptions<ConnectionTabType>>>([
const onTabSelected = (tab: ConnectionTabType) => {
selectedTab.value = tab;
};
const onClientRevoked = (client: OAuthClientResponseDto) => {
emit('revokeClient', client);
telemetry.track('User revoked access for MCP OAuth client', {
client_name: client.name,
});
};
const trackCopyEvent = (payload: {
item: 'server-url' | 'access-token' | 'mcp-json';
source: 'oauth-tab' | 'token-tab';
}) => {
telemetry.track('User copied MCP connection parameter', {
parameter: payload.item,
source: payload.source,
});
};
</script>
<template>
@ -61,8 +80,9 @@ const onTabSelected = (tab: ConnectionTabType) => {
:server-url="`${props.baseUrl}${MCP_ENDPOINT}`"
:clients="props.oAuthClients"
:clients-loading="props.loadingOAuthClients"
@revoke-client="emit('revokeClient', $event)"
@revoke-client="onClientRevoked"
@refresh="emit('refreshClientList')"
@url-copied="trackCopyEvent({ item: 'server-url', source: 'oauth-tab' })"
/>
<AccessTokenConnectionInstructions
v-show="selectedTab === 'token'"
@ -70,6 +90,9 @@ const onTabSelected = (tab: ConnectionTabType) => {
:api-key="props.apiKey"
:loading-api-key="props.loadingApiKey"
@rotate-key="emit('rotateKey')"
@connection-string-copied="trackCopyEvent({ item: 'mcp-json', source: 'token-tab' })"
@access-token-copied="trackCopyEvent({ item: 'access-token', source: 'token-tab' })"
@url-copied="trackCopyEvent({ item: 'server-url', source: 'token-tab' })"
/>
<N8nText size="small" data-test-id="mcp-connection-instructions-docs-text">
{{ i18n.baseText('settings.mcp.instructions.docs.part1') }}

View File

@ -24,6 +24,9 @@ const props = defineProps<Props>();
const emit = defineEmits<{
rotateKey: [];
urlCopied: [url: string];
accessTokenCopied: [];
connectionStringCopied: [];
}>();
const { copy, copied, isSupported } = useClipboard();
@ -65,6 +68,19 @@ const apiKeyText = computed(() => {
}
return isKeyRedacted.value ? '<YOUR_ACCESS_TOKEN_HERE>' : props.apiKey.apiKey;
});
const handleConnectionStringCopy = async () => {
await copy(connectionString.value);
emit('connectionStringCopied');
};
const handleUrlCopy = (url: string) => {
emit('urlCopied', url);
};
const handleAccessTokenCopy = () => {
emit('accessTokenCopied');
};
</script>
<template>
@ -83,7 +99,7 @@ const apiKeyText = computed(() => {
<span :class="$style.label">
{{ i18n.baseText('settings.mcp.instructions.serverUrl') }}:
</span>
<ConnectionParameter :value="props.serverUrl" />
<ConnectionParameter :value="props.serverUrl" @copy="handleUrlCopy" />
</div>
</li>
<li>
@ -101,6 +117,7 @@ const apiKeyText = computed(() => {
:value="props.apiKey.apiKey"
:max-width="400"
:allow-copy="!isKeyRedacted"
@copy="handleAccessTokenCopy"
>
<template #customActions>
<N8nTooltip :content="i18n.baseText('settings.mcp.instructions.rotateKey.tooltip')">
@ -140,7 +157,7 @@ const apiKeyText = computed(() => {
:icon="copied ? 'clipboard-check' : 'clipboard'"
:square="true"
:class="$style['copy-json-button']"
@click="copy(connectionString)"
@click="handleConnectionStringCopy"
/>
</N8nTooltip>
</template>

View File

@ -16,6 +16,15 @@ const props = withDefaults(defineProps<Props>(), {
allowCopy: true,
maxWidth: undefined,
});
const emit = defineEmits<{
copy: [value: string];
}>();
const handleCopy = async (value: string) => {
await copy(value);
emit('copy', value);
};
</script>
<template>
@ -37,7 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
:icon="copied ? 'clipboard-check' : 'clipboard'"
:square="true"
:class="$style['copy-button']"
@click="copy(props.value)"
@click="handleCopy(props.value)"
/>
</N8nTooltip>
</div>

View File

@ -15,6 +15,7 @@ const props = defineProps<Props>();
const emit = defineEmits<{
revokeClient: [client: OAuthClientResponseDto];
refresh: [];
urlCopied: [url: string];
}>();
const i18n = useI18n();
@ -26,6 +27,10 @@ const onRefreshOAuthClients = () => {
const onRevokeClientAccess = (client: OAuthClientResponseDto) => {
emit('revokeClient', client);
};
const onUrlCopied = (url: string) => {
emit('urlCopied', url);
};
</script>
<template>
@ -43,7 +48,7 @@ const onRevokeClientAccess = (client: OAuthClientResponseDto) => {
<span :class="$style.label">
{{ i18n.baseText('settings.mcp.instructions.serverUrl') }}:
</span>
<ConnectionParameter :value="props.serverUrl" />
<ConnectionParameter :value="props.serverUrl" @copy="onUrlCopied" />
</div>
</li>
</ol>