import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { proxyFetch } from '@n8n/ai-utilities';
import { createResultError, createResultOk } from '@n8n/utils/result';
import type {
	IExecuteFunctions,
	INode,
	LiteralMcpRegistryConnection,
	NodeEgressFilter,
	PrepareMcpRegistryConnectionInput,
} from 'n8n-workflow';
import type { Mock, MockedClass, MockedFunction } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import { expect } from 'vitest';

import type { McpAuthenticationOption } from '../types';
import {
	connectMcpClient,
	connectMcpClientForCredential,
	getAuthHeaders,
	mapToNodeOperationError,
	tryRefreshOAuth2Token,
} from '../utils';

vi.mock('@modelcontextprotocol/sdk/client/index.js');
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js');
vi.mock('@modelcontextprotocol/sdk/client/sse.js');
vi.mock('@n8n/ai-utilities', async () => {
	const actual = await vi.importActual('@n8n/ai-utilities');
	return {
		...(actual as Record<string, unknown>),
		proxyFetch: vi.fn(),
	};
});

const MockedClient = Client as MockedClass<typeof Client>;

const createTestEgressFilter = (): NodeEgressFilter => ({
	validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
	createSecureLookup: vi.fn(),
	validateRedirectSync: vi.fn(),
});

describe('utils', () => {
	afterEach(() => {
		vi.restoreAllMocks();
	});

	describe('tryRefreshOAuth2Token', () => {
		it('should refresh an OAuth2 token without headers', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			ctx.helpers.refreshOAuth2Token.mockResolvedValue({
				access_token: 'new-access-token',
			});

			const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');

			expect(headers).toEqual({ Authorization: 'Bearer new-access-token' });
		});

		it('should refresh an OAuth2 token with headers', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			ctx.helpers.refreshOAuth2Token.mockResolvedValue({
				access_token: 'new-access-token',
			});

			const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api', {
				Foo: 'bar',
				authorization: 'Bearer old-access-token',
			});

			expect(headers).toEqual({
				Foo: 'bar',
				Authorization: 'Bearer new-access-token',
			});
		});

		it('should return null if the authentication method is not oAuth2Api', async () => {
			const ctx = mockDeep<IExecuteFunctions>();

			const headers = await tryRefreshOAuth2Token(ctx, 'headerAuth');

			expect(headers).toBeNull();
		});

		it('should return null if the refreshOAuth2Token returns no access_token', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			ctx.helpers.refreshOAuth2Token.mockResolvedValue({
				access_token: null,
			});

			const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');

			expect(headers).toBeNull();
		});

		it('should return null if the refreshOAuth2Token throws an error', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			ctx.helpers.refreshOAuth2Token.mockRejectedValue(new Error('Failed to refresh OAuth2 token'));

			const headers = await tryRefreshOAuth2Token(ctx, 'mcpOAuth2Api');

			expect(headers).toBeNull();
		});
	});

	describe('getAuthHeaders', () => {
		it('should return the headers and credentials for mcpOAuth2Api', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				oauthTokenData: {
					access_token: 'access-token',
				},
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');

			expect(result).toEqual({
				headers: { Authorization: 'Bearer access-token' },
				credentials,
			});
			expect(ctx.helpers.refreshOAuth2Token).not.toHaveBeenCalled();
		});

		it('should not send an undefined bearer token when mcpOAuth2Api token data is empty', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				clientId: 'client-id',
				clientSecret: 'client-secret',
				accessTokenUrl: 'https://auth.example.com/token',
				grantType: 'clientCredentials',
				authentication: 'header',
				useDynamicClientRegistration: false,
				resourceUrl: 'https://mcp.example.com/',
				oauthTokenData: {},
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');

			expect(ctx.helpers.refreshOAuth2Token).not.toHaveBeenCalled();
			expect(result).toEqual({ credentials });
			expect(result.headers?.Authorization).not.toBe('Bearer undefined');
		});

		it('should not set headers when mcpOAuth2Api token data is undefined', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				clientId: 'client-id',
				clientSecret: 'client-secret',
				accessTokenUrl: 'https://auth.example.com/token',
				grantType: 'clientCredentials',
				authentication: 'header',
				useDynamicClientRegistration: false,
				resourceUrl: 'https://mcp.example.com/',
				oauthTokenData: undefined,
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');

			expect(ctx.helpers.refreshOAuth2Token).not.toHaveBeenCalled();
			expect(result).toEqual({ credentials });
			expect(result.headers).toBeUndefined();
		});

		it('should ignore a provider expiry field when the internal expiry is unknown', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				oauthTokenData: {
					access_token: 'access-token',
					refresh_token: 'refresh-token',
					expires_at: '1700000060',
				},
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');

			expect(result).toEqual({
				headers: { Authorization: 'Bearer access-token' },
				credentials,
			});
			expect(ctx.helpers.refreshOAuth2Token).not.toHaveBeenCalled();
		});

		it('should not proactively refresh legacy credentials without an absolute expiry', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				oauthTokenData: {
					access_token: 'access-token',
					refresh_token: 'refresh-token',
					expires_in: '3600',
				},
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');

			expect(result.headers).toEqual({ Authorization: 'Bearer access-token' });
			expect(ctx.helpers.refreshOAuth2Token).not.toHaveBeenCalled();
		});

		it('should refresh mcpOAuth2Api credentials before the access token expires', async () => {
			const now = 1_700_000_000_000;
			vi.spyOn(Date, 'now').mockReturnValue(now);
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				oauthTokenData: {
					access_token: 'access-token',
					refresh_token: 'refresh-token',
					n8n_expires_at: String(now + 60_000),
				},
			};
			ctx.getCredentials.mockResolvedValue(credentials);
			ctx.helpers.refreshOAuth2Token.mockResolvedValue({
				access_token: 'new-access-token',
			});

			const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');

			expect(result.headers).toEqual({ Authorization: 'Bearer new-access-token' });
			expect(ctx.helpers.refreshOAuth2Token).toHaveBeenCalledWith('mcpOAuth2Api');
		});

		it('should not refresh mcpOAuth2Api credentials when the access token is still valid', async () => {
			const now = 1_700_000_000_000;
			vi.spyOn(Date, 'now').mockReturnValue(now);
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				oauthTokenData: {
					access_token: 'access-token',
					refresh_token: 'refresh-token',
					n8n_expires_at: String(now + 10 * 60_000),
				},
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');

			expect(result.headers).toEqual({ Authorization: 'Bearer access-token' });
			expect(ctx.helpers.refreshOAuth2Token).not.toHaveBeenCalled();
		});

		it('should not immediately refresh a short-lived token', async () => {
			const now = 1_700_000_000_000;
			vi.spyOn(Date, 'now').mockReturnValue(now);
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				oauthTokenData: {
					access_token: 'access-token',
					refresh_token: 'refresh-token',
					expires_in: '60',
					n8n_expires_at: String(now + 60_000),
				},
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'mcpOAuth2Api');

			expect(result.headers).toEqual({ Authorization: 'Bearer access-token' });
			expect(ctx.helpers.refreshOAuth2Token).not.toHaveBeenCalled();
		});

		it('should return the headers and credentials for headerAuth', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				name: 'Foo',
				value: 'bar',
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'headerAuth');

			expect(result).toEqual({ headers: { Foo: 'bar' }, credentials });
		});

		it('should return the headers and credentials for bearerAuth', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				token: 'access-token',
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'bearerAuth');

			expect(result).toEqual({
				headers: { Authorization: 'Bearer access-token' },
				credentials,
			});
		});

		it('should return the headers and credentials for multipleHeadersAuth', async () => {
			const ctx = mockDeep<IExecuteFunctions>();
			const credentials = {
				headers: {
					values: [
						{ name: 'Foo', value: 'bar' },
						{ name: 'Test', value: '123' },
					],
				},
			};
			ctx.getCredentials.mockResolvedValue(credentials);

			const result = await getAuthHeaders(ctx, 'multipleHeadersAuth');

			expect(result).toEqual({
				headers: { Foo: 'bar', Test: '123' },
				credentials,
			});
		});

		it('should return an empty object for none', async () => {
			const ctx = mockDeep<IExecuteFunctions>();

			const result = await getAuthHeaders(ctx, 'none');

			expect(result).toEqual({});
		});

		it('should return an empty object for an unknown authentication method', async () => {
			const ctx = mockDeep<IExecuteFunctions>();

			const result = await getAuthHeaders(ctx, 'unknown' as McpAuthenticationOption);

			expect(result).toEqual({});
		});

		it.each([
			'headerAuth',
			'bearerAuth',
			'mcpOAuth2Api',
			'multipleHeadersAuth',
		] as McpAuthenticationOption[])(
			'should return an empty object for %s when it fails',
			async (authentication) => {
				const ctx = mockDeep<IExecuteFunctions>();
				ctx.getCredentials.mockRejectedValue(new Error('Failed to get credentials'));

				const result = await getAuthHeaders(ctx, authentication);

				expect(result).toEqual({});
			},
		);
	});
	describe('connectMcpClient', () => {
		const mockClient = {
			connect: vi.fn(),
			close: vi.fn(),
		};

		const mockedProxyFetch = proxyFetch as MockedFunction<typeof proxyFetch>;

		beforeEach(() => {
			vi.clearAllMocks();
			vi.restoreAllMocks();
			mockClient.close = vi.fn();
			mockClient.close.mockResolvedValue(undefined);

			MockedClient.mockImplementation(function () {
				return mockClient as unknown as Client;
			});
		});

		describe.each([
			['httpStreamable', StreamableHTTPClientTransport],
			['sse', SSEClientTransport],
		] as const)('%s transport', (transport, TransportClass) => {
			it('should return cancelled without creating a transport when signal is already aborted', async () => {
				const abort = new AbortController();
				abort.abort();
				const addEventListener = vi.spyOn(abort.signal, 'addEventListener');

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
					signal: abort.signal,
				});

				expect(result.ok).toBe(false);
				if (!result.ok) {
					expect(result.error.type).toBe('cancelled');
					expect(result.error.error.message).toBe('Execution was cancelled');
				}
				expect(TransportClass).not.toHaveBeenCalled();
				expect(mockClient.connect).not.toHaveBeenCalled();
				expect(addEventListener).toHaveBeenCalledWith('abort', expect.any(Function), {
					once: true,
				});
			});

			it('should connect successfully and pass a custom fetch', async () => {
				mockClient.connect.mockResolvedValue(undefined);

				await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer token' },
					name: 'test-client',
					version: 1,
				});

				expect(TransportClass).toHaveBeenCalledTimes(1);

				const [, opts] = (TransportClass as Mock).mock.calls[0];
				expect(opts.fetch).toBeTypeOf('function');
			});

			it('should connect successfully without a signal and not pass requestInit', async () => {
				mockClient.connect.mockResolvedValue(undefined);

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
				});

				expect(result.ok).toBe(true);
				const [, opts] = (TransportClass as Mock).mock.calls[0];
				expect(opts).not.toHaveProperty('requestInit');
			});

			it('should pass the abort signal in requestInit when a signal is provided', async () => {
				mockClient.connect.mockResolvedValue(undefined);
				const abort = new AbortController();

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
					signal: abort.signal,
				});

				expect(result.ok).toBe(true);
				const [, opts] = (TransportClass as Mock).mock.calls[0];
				expect(opts.requestInit).toEqual({ signal: abort.signal });
			});

			it('should attach a once abort listener that closes the client and swallows close rejection', async () => {
				mockClient.connect.mockResolvedValue(undefined);
				mockClient.close.mockRejectedValueOnce(new Error('close failed'));
				const abort = new AbortController();
				const addEventListener = vi.spyOn(abort.signal, 'addEventListener');

				// Save a reference to the original close mock before it gets wrapped
				const closeSpy = mockClient.close;

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
					signal: abort.signal,
				});

				expect(result.ok).toBe(true);
				expect(addEventListener).toHaveBeenCalledWith('abort', expect.any(Function), {
					once: true,
				});

				// Trigger the abort; the listener will call client.close (the wrapper)
				expect(() => abort.abort()).not.toThrow();
				await Promise.resolve();

				// The original close function should have been called exactly once
				expect(closeSpy).toHaveBeenCalledTimes(1);
			});

			it('should remove the abort listener on normal close, preventing double-close on later abort', async () => {
				mockClient.connect.mockResolvedValue(undefined);
				const abort = new AbortController();

				// Save a reference to the original close mock before it gets wrapped
				const closeSpy = mockClient.close;

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
					signal: abort.signal,
				});

				expect(result.ok).toBe(true);
				if (result.ok) {
					// Normal close – triggers the wrapper, which removes the listener and calls originalClose
					await result.result.close();
				}
				abort.abort(); // listener already removed, should not call close again
				await Promise.resolve();

				// Original close called exactly once (not twice)
				expect(closeSpy).toHaveBeenCalledTimes(1);
			});

			it('should return auth error on 401 during connect', async () => {
				mockClient.connect.mockRejectedValueOnce(new Error('Request failed with status 401'));

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer token' },
					name: 'test-client',
					version: 1,
				});

				expect(result.ok).toBe(false);
				if (!result.ok) {
					expect(result.error.type).toBe('auth');
				}
			});

			it('should return connection error on non-auth failure', async () => {
				mockClient.connect.mockRejectedValueOnce(new Error('Connection refused'));

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
				});

				expect(result.ok).toBe(false);
				if (!result.ok) {
					expect(result.error.type).toBe('connection');
				}
			});

			it('should remove the abort listener when connect fails with an auth error', async () => {
				mockClient.connect.mockRejectedValueOnce(new Error('Request failed with status 401'));
				const abort = new AbortController();
				const removeEventListener = vi.spyOn(abort.signal, 'removeEventListener');

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
					signal: abort.signal,
				});

				expect(result.ok).toBe(false);
				if (!result.ok) {
					expect(result.error.type).toBe('auth');
				}
				expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function));
			});

			it('should remove the abort listener when connect fails with a connection error', async () => {
				mockClient.connect.mockRejectedValueOnce(new Error('ECONNREFUSED'));
				const abort = new AbortController();
				const removeEventListener = vi.spyOn(abort.signal, 'removeEventListener');

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
					signal: abort.signal,
				});

				expect(result.ok).toBe(false);
				if (!result.ok) {
					expect(result.error.type).toBe('connection');
				}
				expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function));
			});

			it('should return cancelled when connect throws AbortError with a signal', async () => {
				const abortError = new Error('The operation was aborted');
				abortError.name = 'AbortError';
				mockClient.connect.mockRejectedValueOnce(abortError);
				const abort = new AbortController();

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
					signal: abort.signal,
				});

				expect(result.ok).toBe(false);
				if (!result.ok) {
					expect(result.error.type).toBe('cancelled');
					expect(result.error.error).toBe(abortError);
				}
			});

			it('should return cancelled when the signal is aborted while connect fails', async () => {
				const abort = new AbortController();
				mockClient.connect.mockImplementationOnce(async () => {
					abort.abort();
					throw new Error('connect failed after cancellation');
				});

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
					signal: abort.signal,
				});

				expect(result.ok).toBe(false);
				if (!result.ok) {
					expect(result.error.type).toBe('cancelled');
				}
			});

			it('should return connection when connect throws AbortError without a signal', async () => {
				const abortError = new Error('The operation was aborted');
				abortError.name = 'AbortError';
				mockClient.connect.mockRejectedValueOnce(abortError);

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					name: 'test-client',
					version: 1,
				});

				expect(result.ok).toBe(false);
				if (!result.ok) {
					expect(result.error.type).toBe('connection');
				}
			});

			it('should inject auth headers into fetch requests', async () => {
				mockClient.connect.mockResolvedValue(undefined);
				mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));

				await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer my-token' },
					name: 'test-client',
					version: 1,
				});

				// The authFetch function is passed to the transport constructor but never called
				// by the mocked transport. Extract it and invoke directly to test its behavior.
				const [, opts] = (TransportClass as Mock).mock.calls[0];
				await opts.fetch('https://example.com/mcp', {});

				expect(mockedProxyFetch).toHaveBeenCalled();

				const [options] = mockedProxyFetch.mock.calls[0];

				expect(options.input).toBe('https://example.com/mcp');
				expect(new Headers(options.init?.headers).get('authorization')).toBe('Bearer my-token');
			});

			it('should not send auth headers to a cross-origin redirect target', async () => {
				mockClient.connect.mockResolvedValue(undefined);
				mockedProxyFetch
					.mockResolvedValueOnce(
						new Response(null, {
							status: 307,
							headers: { location: 'https://other.example.com/moved' },
						}),
					)
					.mockResolvedValueOnce(new Response('ok', { status: 200 }));

				await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer my-token' },
					name: 'test-client',
					version: 1,
				});

				const [, opts] = (TransportClass as Mock).mock.calls[0];
				await opts.fetch('https://example.com/mcp', {});

				expect(mockedProxyFetch).toHaveBeenCalledTimes(2);
				const [secondCallOptions] = mockedProxyFetch.mock.calls[1];
				expect(secondCallOptions.input.toString()).toBe('https://other.example.com/moved');
				expect(new Headers(secondCallOptions.init?.headers).get('authorization')).toBeNull();
			});

			it('should preserve SDK headers passed as Headers instance', async () => {
				mockClient.connect.mockResolvedValue(undefined);

				const sdkHeaders = new Headers({
					Accept: 'text/event-stream',
					'mcp-protocol-version': '2025-03-26',
				});

				mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));

				await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer my-token' },
					name: 'test-client',
					version: 1,
				});

				// Simulate the transport calling authFetch with a Headers instance as init.headers.
				// Headers entries() normalises keys to lowercase, so Accept becomes 'accept'.
				const [, opts] = (TransportClass as Mock).mock.calls[0];
				await opts.fetch('https://example.com', { headers: sdkHeaders });

				const [{ init: callOpts }] = mockedProxyFetch.mock.calls[0];

				expect(new Headers(callOpts?.headers).get('accept')).toBe('text/event-stream');
			});

			it('should retry on 401 response with refreshed headers', async () => {
				mockClient.connect.mockResolvedValue(undefined);

				const onUnauthorized = vi.fn().mockResolvedValue({
					Authorization: 'Bearer refreshed-token',
				});

				mockedProxyFetch
					.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }))
					.mockResolvedValueOnce(new Response('ok', { status: 200 }));

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer old-token' },
					name: 'test-client',
					version: 1,
					onUnauthorized,
				});

				const [, opts] = (TransportClass as Mock).mock.calls[0];
				await opts.fetch('https://example.com', {});

				expect(result.ok).toBe(true);
				expect(onUnauthorized).toHaveBeenCalledTimes(1);
				expect(mockedProxyFetch).toHaveBeenCalledTimes(2);
			});

			it('should use refreshed headers for subsequent requests', async () => {
				mockClient.connect.mockResolvedValue(undefined);

				const onUnauthorized = vi.fn().mockResolvedValue({
					Authorization: 'Bearer refreshed-token',
				});

				mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));

				await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer old-token' },
					name: 'test-client',
					version: 1,
					onUnauthorized,
				});

				const [, opts] = (TransportClass as Mock).mock.calls[0];
				await opts.fetch('https://example.com', {});

				expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
			});

			it('should not retry on 401 when onUnauthorized returns null', async () => {
				mockClient.connect.mockResolvedValue(undefined);

				const onUnauthorized = vi.fn().mockResolvedValue(null);

				mockedProxyFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer token' },
					name: 'test-client',
					version: 1,
					onUnauthorized,
				});

				const [, opts] = (TransportClass as Mock).mock.calls[0];
				await opts.fetch('https://example.com', {});

				expect(result.ok).toBe(true);
				expect(onUnauthorized).toHaveBeenCalledTimes(1);
				expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
			});

			it('should not retry on 401 when onUnauthorized is not provided', async () => {
				mockClient.connect.mockResolvedValue(undefined);

				mockedProxyFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));

				const result = await connectMcpClient({
					serverTransport: transport,
					secureEgressFilter: createTestEgressFilter(),
					endpointUrl: 'https://example.com',
					headers: { Authorization: 'Bearer token' },
					name: 'test-client',
					version: 1,
				});

				const [, opts] = (TransportClass as Mock).mock.calls[0];
				await opts.fetch('https://example.com', {});

				expect(result.ok).toBe(true);
				expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
			});

			it('should prepare registry connections with proactively refreshed headers', async () => {
				mockClient.connect.mockResolvedValue(undefined);
				const ctx = mockDeep<IExecuteFunctions>();
				ctx.getNode.mockReturnValue({ type: 'test-client', typeVersion: 1 } as unknown as INode);
				ctx.getCredentials.mockResolvedValue({
					oauthTokenData: {
						access_token: 'old-token',
						refresh_token: 'refresh-token',
						n8n_expires_at: '0',
					},
				});
				ctx.helpers.refreshOAuth2Token.mockResolvedValue({
					access_token: 'refreshed-token',
				});
				ctx.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter());
				const connection: LiteralMcpRegistryConnection = {
					nodeTypeName: '@n8n/mcp-registry.test',
					credentialType: 'testMcpOAuth2Api',
					endpointUrl: 'https://example.com/mcp',
					endpointHostname: 'example.com',
					transport: 'httpStreamable',
					isTemplated: false,
				};
				const prepareConnection = vi.fn((input: PrepareMcpRegistryConnectionInput) => ({
					ok: true as const,
					value: {
						...connection,
						headers: input.headers ?? {},
						allowedDomains: connection.endpointHostname,
					},
				}));

				await connectMcpClientForCredential(ctx, {
					authentication: connection.credentialType,
					serverTransport: transport,
					endpointUrl: connection.endpointUrl,
					registryCredential: { connection, prepareConnection },
					surface: 'MCP Client Tool',
				});

				expect(prepareConnection).toHaveBeenCalledWith(
					expect.objectContaining({
						headers: { Authorization: 'Bearer refreshed-token' },
					}),
				);
			});

			it('should block requests to a target rejected by the instance egress filter', async () => {
				mockClient.connect.mockResolvedValue(undefined);
				mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));

				const secureLookup = vi.fn();
				const egressFilter: NodeEgressFilter = {
					validateUrl: vi.fn().mockResolvedValue(createResultError(new Error('Egress blocked'))),
					validateRedirectSync: vi.fn(),
					createSecureLookup: vi.fn().mockReturnValue(secureLookup),
				};

				const ctx = mockDeep<IExecuteFunctions>();
				ctx.getNode.mockReturnValue({ type: 'test-client', typeVersion: 1 } as unknown as INode);
				ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);

				const result = await connectMcpClientForCredential(ctx, {
					authentication: 'none',
					serverTransport: transport,
					endpointUrl: 'https://blocked.example.com/',
					surface: 'MCP Client Tool',
				});

				expect(result.ok).toBe(true);

				const [, opts] = (TransportClass as Mock).mock.calls[0];

				// The egress filter must reject the target before any request is sent.
				await expect(opts.fetch('https://blocked.example.com/', {})).rejects.toThrow(
					'Egress blocked',
				);
				expect(egressFilter.validateUrl).toHaveBeenCalledWith('https://blocked.example.com/');
				expect(mockedProxyFetch).not.toHaveBeenCalled();
			});

			it('should allow requests to a target accepted by the instance egress filter', async () => {
				mockClient.connect.mockResolvedValue(undefined);
				mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));

				const secureLookup = vi.fn();
				const egressFilter: NodeEgressFilter = {
					validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
					validateRedirectSync: vi.fn(),
					createSecureLookup: vi.fn().mockReturnValue(secureLookup),
				};

				const ctx = mockDeep<IExecuteFunctions>();
				ctx.getNode.mockReturnValue({ type: 'test-client', typeVersion: 1 } as unknown as INode);
				ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);

				const result = await connectMcpClientForCredential(ctx, {
					authentication: 'none',
					serverTransport: transport,
					endpointUrl: 'https://mcp.example.com/',
					surface: 'MCP Client Tool',
				});

				expect(result.ok).toBe(true);

				const [, opts] = (TransportClass as Mock).mock.calls[0];
				await opts.fetch('https://mcp.example.com/', {});

				expect(egressFilter.validateUrl).toHaveBeenCalledWith('https://mcp.example.com/');
				expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
			});
		});
	});

	describe('mapToNodeOperationError', () => {
		it('should map cancelled connection errors to an execution cancellation message', () => {
			const node = mockDeep<INode>();
			const error = new Error('abort');

			const result = mapToNodeOperationError(node, { type: 'cancelled', error });

			expect(result.message).toBe('Execution was cancelled');
		});
	});
});
