import type { CredentialProvider } from '@n8n/agents';
import type { AgentJsonMcpServerConfig } from '@n8n/api-types';
import type { CustomFetch } from '@n8n/backend-network';
import { mock } from 'vitest-mock-extended';
import { UserError } from 'n8n-workflow';

import type { OauthService } from '@/oauth/oauth.service';

import {
	buildMcpClientForServer,
	listMcpServerTools,
	mapApprovalToSdk,
} from '../mcp-client-factory';

// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------

const mcpClientCtor = vi.fn();
const listToolsMock = vi.fn();
const closeMock = vi.fn();
vi.mock('@n8n/agents', () => ({
	McpClient: vi.fn(function (configs: unknown) {
		mcpClientCtor(configs);
		return { configs, close: closeMock, listTools: listToolsMock };
	}),
}));

// Stands in for the proxy-aware transport fetch the caller injects via deps.
const proxyFetchMock = vi.fn();
const proxyFetch = ((...args: unknown[]) => proxyFetchMock(...args)) as unknown as CustomFetch;

function headersToCaseInsensitiveRecord(headers: HeadersInit | undefined): Record<string, string> {
	const values = Object.fromEntries(new Headers(headers).entries());
	return new Proxy(values, {
		get: (target, property) => {
			if (typeof property !== 'string') return Reflect.get(target, property);
			return target[property] ?? target[property.toLowerCase()];
		},
	});
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeServer(overrides: Partial<AgentJsonMcpServerConfig> = {}): AgentJsonMcpServerConfig {
	return {
		name: 'srv',
		url: 'https://example.test/mcp',
		transport: 'streamableHttp',
		authentication: 'none',
		...overrides,
	} as AgentJsonMcpServerConfig;
}

function makeOk(): Response {
	return new Response('ok', { status: 200 });
}

function make401(): Response {
	return new Response('unauthorized', { status: 401 });
}

// ---------------------------------------------------------------------------
// mapApprovalToSdk
// ---------------------------------------------------------------------------

describe('mapApprovalToSdk', () => {
	it('returns undefined when approval is absent', () => {
		expect(mapApprovalToSdk(undefined)).toBeUndefined();
	});

	it('maps mode "global" to literal true (all tools require approval)', () => {
		expect(mapApprovalToSdk({ mode: 'global' })).toBe(true);
	});

	it('maps mode "selected" to the literal tools list', () => {
		expect(mapApprovalToSdk({ mode: 'selected', tools: ['a', 'b'] })).toEqual(['a', 'b']);
	});
});

// ---------------------------------------------------------------------------
// buildMcpClientForServer — header derivation per auth type
// ---------------------------------------------------------------------------

describe('buildMcpClientForServer — header derivation', () => {
	beforeEach(() => {
		mcpClientCtor.mockReset();
		proxyFetchMock.mockReset();
		proxyFetchMock.mockResolvedValue(makeOk());
	});

	async function captureInitialHeaders(server: AgentJsonMcpServerConfig, resolved: unknown) {
		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue(resolved as never);
		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(server, {
			credentialProvider,
			oauthService,
			projectId: 'proj-1',
			proxyFetch,
		});

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		const fetchFn = configs[0].fetch;
		await fetchFn('https://example.test/mcp');
		const [, init] = proxyFetchMock.mock.calls[0] as [unknown, RequestInit];
		return headersToCaseInsensitiveRecord(init.headers);
	}

	it('sends no auth headers for authentication: "none"', async () => {
		const headers = await captureInitialHeaders(makeServer({ authentication: 'none' }), null);
		expect(headers.Authorization).toBeUndefined();
	});

	it('sends a Bearer header for bearerAuth', async () => {
		const headers = await captureInitialHeaders(
			makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
			{ token: 'tok123' },
		);
		expect(headers.Authorization).toBe('Bearer tok123');
	});

	it('sends the configured name/value pair for headerAuth', async () => {
		const headers = await captureInitialHeaders(
			makeServer({ authentication: 'headerAuth', credential: 'cred-1' }),
			{ name: 'X-Api-Key', value: 'secret' },
		);
		expect(headers['X-Api-Key']).toBe('secret');
	});

	it('flattens the values array for multipleHeadersAuth', async () => {
		const headers = await captureInitialHeaders(
			makeServer({ authentication: 'multipleHeadersAuth', credential: 'cred-1' }),
			{
				headers: {
					values: [
						{ name: 'X-One', value: 'one' },
						{ name: 'X-Two', value: 'two' },
					],
				},
			},
		);
		expect(headers['X-One']).toBe('one');
		expect(headers['X-Two']).toBe('two');
	});

	it('uses oauthTokenData.access_token for mcpOAuth2Api', async () => {
		const headers = await captureInitialHeaders(
			makeServer({ authentication: 'mcpOAuth2Api', credential: 'cred-1' }),
			{ oauthTokenData: { access_token: 'oauth-token' } },
		);
		expect(headers.Authorization).toBe('Bearer oauth-token');
	});
});

// ---------------------------------------------------------------------------
// buildMcpClientForServer — OAuth2 refresh path
// ---------------------------------------------------------------------------

describe('buildMcpClientForServer — OAuth2 refresh on 401', () => {
	beforeEach(() => {
		mcpClientCtor.mockReset();
		proxyFetchMock.mockReset();
	});

	it('invokes oauthService.refreshOAuth2CredentialById once on 401 and retries with the refreshed header', async () => {
		proxyFetchMock.mockResolvedValueOnce(make401()).mockResolvedValueOnce(makeOk());

		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({
			oauthTokenData: { access_token: 'stale-token' },
		} as never);

		const oauthService = mock<OauthService>();
		oauthService.refreshOAuth2CredentialById.mockResolvedValue({
			Authorization: 'Bearer fresh-token',
		});

		await buildMcpClientForServer(
			makeServer({ authentication: 'mcpOAuth2Api', credential: 'cred-1' }),
			{ credentialProvider, oauthService, projectId: 'proj-1', proxyFetch },
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		const fetchFn = configs[0].fetch;
		const res = await fetchFn('https://example.test/mcp');

		expect(res.status).toBe(200);
		expect(oauthService.refreshOAuth2CredentialById).toHaveBeenCalledWith('cred-1', 'proj-1');
		// First call uses the stale header, second uses the refreshed one.
		const [, firstInit] = proxyFetchMock.mock.calls[0] as [unknown, RequestInit];
		const [, secondInit] = proxyFetchMock.mock.calls[1] as [unknown, RequestInit];
		expect(new Headers(firstInit.headers).get('authorization')).toBe('Bearer stale-token');
		expect(new Headers(secondInit.headers).get('authorization')).toBe('Bearer fresh-token');
	});

	it('does NOT call refreshOAuth2CredentialById for non-OAuth2 auth schemes', async () => {
		proxyFetchMock.mockResolvedValueOnce(make401());

		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({ token: 'static' } as never);

		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(
			makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
			{ credentialProvider, oauthService, projectId: 'proj-1', proxyFetch },
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		await configs[0].fetch('https://example.test/mcp');

		expect(oauthService.refreshOAuth2CredentialById).not.toHaveBeenCalled();
	});
});

// ---------------------------------------------------------------------------
// buildMcpClientForServer — SDK config mapping
// ---------------------------------------------------------------------------

describe('buildMcpClientForServer — SDK config mapping', () => {
	beforeEach(() => {
		mcpClientCtor.mockReset();
		proxyFetchMock.mockReset();
	});

	it('forwards tool and lifecycle options to the SDK config', async () => {
		const credentialProvider = mock<CredentialProvider>();
		const oauthService = mock<OauthService>();
		const onToolCallSettled = vi.fn();

		await buildMcpClientForServer(
			makeServer({
				transport: 'sse',
				toolFilter: { mode: 'allow', tools: ['echo'] },
				approval: { mode: 'selected', tools: ['create'] },
				connectionTimeoutMs: 5_000,
			}),
			{
				credentialProvider,
				oauthService,
				projectId: 'proj-1',
				proxyFetch,
				onToolCallSettled,
			},
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<Record<string, unknown>>];
		expect(configs).toHaveLength(1);
		expect(configs[0]).toMatchObject({
			name: 'srv',
			url: 'https://example.test/mcp',
			transport: 'sse',
			toolFilter: { mode: 'allow', tools: ['echo'] },
			requireApproval: ['create'],
			connectionTimeoutMs: 5_000,
			onToolCallSettled,
		});
		expect(typeof configs[0].fetch).toBe('function');
	});

	it('omits connectionTimeoutMs from the SDK config when not provided', async () => {
		const credentialProvider = mock<CredentialProvider>();
		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(makeServer(), {
			credentialProvider,
			oauthService,
			projectId: 'proj-1',
			proxyFetch,
		});

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<Record<string, unknown>>];
		expect(configs[0]).not.toHaveProperty('connectionTimeoutMs');
	});

	it('forwards onConnectionFailed to the SDK config when provided', async () => {
		const credentialProvider = mock<CredentialProvider>();
		const oauthService = mock<OauthService>();
		const onConnectionFailed = vi.fn();

		await buildMcpClientForServer(makeServer(), {
			credentialProvider,
			oauthService,
			projectId: 'proj-1',
			proxyFetch,
			onConnectionFailed,
		});

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<Record<string, unknown>>];
		expect(typeof configs[0].onConnectionFailed).toBe('function');
		// Invoking the wired callback forwards to the observer.
		(configs[0].onConnectionFailed as (event: { server: string; error: string }) => void)({
			server: 'srv',
			error: 'boom',
		});
		expect(onConnectionFailed).toHaveBeenCalledWith({ server: 'srv', error: 'boom' });
	});

	it('omits onConnectionFailed from the SDK config when not provided', async () => {
		const credentialProvider = mock<CredentialProvider>();
		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(makeServer(), {
			credentialProvider,
			oauthService,
			projectId: 'proj-1',
			proxyFetch,
		});

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<Record<string, unknown>>];
		expect(configs[0]).not.toHaveProperty('onConnectionFailed');
	});
});

// ---------------------------------------------------------------------------
// buildMcpClientForServer — auth header edge cases
// ---------------------------------------------------------------------------

describe('buildMcpClientForServer — auth header edge cases', () => {
	beforeEach(() => {
		mcpClientCtor.mockReset();
		proxyFetchMock.mockReset();
		proxyFetchMock.mockResolvedValue(new Response('ok', { status: 200 }));
	});

	async function captureInitialHeaders(server: AgentJsonMcpServerConfig, resolved: unknown) {
		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue(resolved as never);
		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(server, {
			credentialProvider,
			oauthService,
			projectId: 'proj-1',
			proxyFetch,
		});

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		await configs[0].fetch('https://example.test/mcp');
		const [, init] = proxyFetchMock.mock.calls[0] as [unknown, RequestInit];
		return headersToCaseInsensitiveRecord(init.headers);
	}

	it('sends no Authorization header for bearerAuth when the resolved token is an empty string', async () => {
		const headers = await captureInitialHeaders(
			makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
			{ token: '' },
		);
		expect(headers.Authorization).toBeUndefined();
	});

	it('sends no header for headerAuth when the name is missing from the resolved credential', async () => {
		const headers = await captureInitialHeaders(
			makeServer({ authentication: 'headerAuth', credential: 'cred-1' }),
			{ name: '', value: 'secret' },
		);
		expect(Object.keys(headers)).not.toContain('');
		expect(headers.Authorization).toBeUndefined();
	});

	it('skips entries with non-string values in multipleHeadersAuth', async () => {
		const headers = await captureInitialHeaders(
			makeServer({ authentication: 'multipleHeadersAuth', credential: 'cred-1' }),
			{
				headers: {
					values: [
						{ name: 'X-Valid', value: 'yes' },
						{ name: 42, value: 'ignored' },
						{ name: 'X-Also-Ignored', value: null },
					],
				},
			},
		);
		expect(headers['X-Valid']).toBe('yes');
		expect(Object.keys(headers)).not.toContain('42');
		expect(headers['X-Also-Ignored']).toBeUndefined();
	});

	it('sends no headers for multipleHeadersAuth when the values array is absent', async () => {
		const headers = await captureInitialHeaders(
			makeServer({ authentication: 'multipleHeadersAuth', credential: 'cred-1' }),
			{ headers: { values: undefined } },
		);
		expect(headers).toEqual({});
	});

	it('requires registry metadata for service-specific McpOAuth2Api credentials', async () => {
		await expect(
			captureInitialHeaders(
				makeServer({ authentication: 'notionMcpOAuth2Api' as never, credential: 'cred-1' }),
				{ oauthTokenData: { access_token: 'notion-oauth-token' } },
			),
		).rejects.toThrow('requires an MCP registry node');
	});
});

// ---------------------------------------------------------------------------
// buildMcpClientForServer — OAuth2 variant wires refresh handler
// ---------------------------------------------------------------------------

describe('buildMcpClientForServer — service-specific McpOAuth2Api refresh', () => {
	beforeEach(() => {
		mcpClientCtor.mockReset();
		proxyFetchMock.mockReset();
	});

	it('wires the onUnauthorized refresh handler for non-canonical McpOAuth2Api variants', async () => {
		proxyFetchMock
			.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }))
			.mockResolvedValueOnce(new Response('ok', { status: 200 }));

		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({
			oauthTokenData: { access_token: 'stale' },
		} as never);

		const oauthService = mock<OauthService>();
		oauthService.refreshOAuth2CredentialById.mockResolvedValue({
			Authorization: 'Bearer fresh',
		});

		await buildMcpClientForServer(
			makeServer({
				authentication: 'notionMcpOAuth2Api' as never,
				credential: 'cred-1',
				metadata: { nodeTypeName: '@n8n/mcp-registry.notion' },
			}),
			{
				credentialProvider,
				oauthService,
				projectId: 'proj-1',
				proxyFetch,
				resolveRegistryConnection: async () => ({
					nodeTypeName: '@n8n/mcp-registry.notion',
					credentialType: 'notionMcpOAuth2Api',
					endpointUrl: 'https://example.test/mcp',
					endpointHostname: 'example.test',
					transport: 'httpStreamable',
					isTemplated: false,
				}),
			},
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		const res = await configs[0].fetch('https://example.test/mcp');

		expect(res.status).toBe(200);
		expect(oauthService.refreshOAuth2CredentialById).toHaveBeenCalledWith('cred-1', 'proj-1');
	});

	it('does NOT wire an onUnauthorized handler when credential is absent for mcpOAuth2Api', async () => {
		proxyFetchMock.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }));

		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({} as never);

		const oauthService = mock<OauthService>();

		// credential field intentionally absent
		await buildMcpClientForServer(makeServer({ authentication: 'mcpOAuth2Api' }), {
			credentialProvider,
			oauthService,
			projectId: 'proj-1',
			proxyFetch,
		});

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		await configs[0].fetch('https://example.test/mcp');

		// No refresh should have been attempted
		expect(oauthService.refreshOAuth2CredentialById).not.toHaveBeenCalled();
	});
});

// ---------------------------------------------------------------------------
// buildMcpClientForServer — credential domain restrictions
// ---------------------------------------------------------------------------

describe('buildMcpClientForServer — credential domain restrictions', () => {
	beforeEach(() => {
		mcpClientCtor.mockReset();
		proxyFetchMock.mockReset();
		proxyFetchMock.mockResolvedValue(makeOk());
	});

	it('blocks requests when credential mode is "none" (block all)', async () => {
		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({
			token: 'tok',
			allowedHttpRequestDomains: 'none',
		} as never);
		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(
			makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
			{ credentialProvider, oauthService, projectId: 'proj-1', proxyFetch },
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		await expect(configs[0].fetch('https://example.test/mcp')).rejects.toThrow(UserError);
		expect(proxyFetchMock).not.toHaveBeenCalled();
	});

	it('blocks requests when the server URL is not in the credential allowlist', async () => {
		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({
			token: 'tok',
			allowedHttpRequestDomains: 'domains',
			allowedDomains: 'other-host.test',
		} as never);
		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(
			makeServer({
				authentication: 'bearerAuth',
				credential: 'cred-1',
				url: 'https://example.test/mcp',
			}),
			{ credentialProvider, oauthService, projectId: 'proj-1', proxyFetch },
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		await expect(configs[0].fetch('https://example.test/mcp')).rejects.toThrow(UserError);
		expect(proxyFetchMock).not.toHaveBeenCalled();
	});

	it('allows connection when the server URL matches the credential allowlist', async () => {
		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({
			token: 'tok',
			allowedHttpRequestDomains: 'domains',
			allowedDomains: 'example.test',
		} as never);
		const oauthService = mock<OauthService>();

		await expect(
			buildMcpClientForServer(makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }), {
				credentialProvider,
				oauthService,
				projectId: 'proj-1',
				proxyFetch,
			}),
		).resolves.toBeDefined();
	});

	it('passes allowedDomains to the auth fetch so redirect hops are checked', async () => {
		proxyFetchMock.mockResolvedValueOnce(
			new Response(null, { status: 302, headers: { location: 'https://evil.test/mcp' } }),
		);
		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({
			token: 'tok',
			allowedHttpRequestDomains: 'domains',
			allowedDomains: 'example.test',
		} as never);
		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(
			makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
			{ credentialProvider, oauthService, projectId: 'proj-1', proxyFetch },
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		await expect(configs[0].fetch('https://example.test/mcp')).rejects.toThrow(UserError);
	});

	it('allows connection when credential mode is "all"', async () => {
		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockResolvedValue({
			token: 'tok',
			allowedHttpRequestDomains: 'all',
		} as never);
		const oauthService = mock<OauthService>();

		await expect(
			buildMcpClientForServer(makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }), {
				credentialProvider,
				oauthService,
				projectId: 'proj-1',
				proxyFetch,
			}),
		).resolves.toBeDefined();
	});

	it('skips domain validation entirely when no credential is used', async () => {
		const credentialProvider = mock<CredentialProvider>();
		const oauthService = mock<OauthService>();

		await expect(
			buildMcpClientForServer(makeServer({ authentication: 'none' }), {
				credentialProvider,
				oauthService,
				projectId: 'proj-1',
				proxyFetch,
			}),
		).resolves.toBeDefined();
		expect(credentialProvider.resolve).not.toHaveBeenCalled();
	});
});

// ---------------------------------------------------------------------------
// buildMcpClientForServer — unresolvable credential
// ---------------------------------------------------------------------------

describe('buildMcpClientForServer — unresolvable credential', () => {
	beforeEach(() => {
		mcpClientCtor.mockReset();
		proxyFetchMock.mockReset();
		proxyFetchMock.mockResolvedValue(makeOk());
	});

	it('fails the connection with the resolution error instead of connecting unauthenticated', async () => {
		const credentialProvider = mock<CredentialProvider>();
		credentialProvider.resolve.mockRejectedValue(
			new Error('Could not load secrets [Error resolving credentials]'),
		);
		const oauthService = mock<OauthService>();

		await buildMcpClientForServer(
			makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
			{ credentialProvider, oauthService, projectId: 'proj-1', proxyFetch },
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
		await expect(configs[0].fetch('https://example.test/mcp')).rejects.toThrow(
			'Could not resolve the credential for MCP server "srv": Could not load secrets [Error resolving credentials]',
		);
		expect(proxyFetchMock).not.toHaveBeenCalled();
	});

	it('keeps a templated URL parseable so the credential error is what surfaces', async () => {
		const credentialProvider = mock<CredentialProvider>();
		// No `oauthTokenData`, so `prepareMcpRegistryConnection` rejects before it
		// substitutes the URL, leaving the raw `$self`-expression behind.
		credentialProvider.resolve.mockResolvedValue({ host: 'https://tenant.test' } as never);
		const oauthService = mock<OauthService>();

		const templatedUrl = '={{$self["host"]}}/api/2.0/mcp/genie';

		await buildMcpClientForServer(
			makeServer({
				url: templatedUrl,
				authentication: 'databricksGenieMcpOAuth2Api' as never,
				credential: 'cred-1',
				metadata: { nodeTypeName: '@n8n/mcp-registry.databricksGenie' },
			}),
			{
				credentialProvider,
				oauthService,
				projectId: 'proj-1',
				proxyFetch,
				resolveRegistryConnection: async () => ({
					nodeTypeName: '@n8n/mcp-registry.databricksGenie',
					credentialType: 'databricksGenieMcpOAuth2Api',
					urlTemplate: templatedUrl,
					transport: 'httpStreamable',
					isTemplated: true,
				}),
			},
		);

		const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ url: string; fetch: typeof fetch }>];
		expect(configs[0].url).not.toBe(templatedUrl);
		expect(() => new URL(configs[0].url)).not.toThrow();
		await expect(configs[0].fetch(configs[0].url)).rejects.toThrow(
			'does not contain an OAuth2 access token',
		);
		expect(proxyFetchMock).not.toHaveBeenCalled();
	});
});

// ---------------------------------------------------------------------------
// listMcpServerTools
// ---------------------------------------------------------------------------

describe('listMcpServerTools', () => {
	const deps = () => ({
		credentialProvider: mock<CredentialProvider>(),
		oauthService: mock<OauthService>(),
		projectId: 'proj-1',
		proxyFetch,
	});

	beforeEach(() => {
		mcpClientCtor.mockReset();
		listToolsMock.mockReset();
		closeMock.mockReset();
		closeMock.mockResolvedValue(undefined);
	});

	it('returns name/description pairs (empty description fallback) and closes the client', async () => {
		listToolsMock.mockResolvedValue([
			{ name: 'echo', description: 'Echoes input' },
			{ name: 'bare', description: undefined },
		]);

		const tools = await listMcpServerTools(makeServer(), deps());

		expect(tools).toEqual([
			{ name: 'echo', description: 'Echoes input' },
			{ name: 'bare', description: '' },
		]);
		expect(closeMock).toHaveBeenCalledTimes(1);
	});

	it('still closes the client and propagates the error when listing fails', async () => {
		listToolsMock.mockRejectedValue(new Error('connection refused'));

		await expect(listMcpServerTools(makeServer(), deps())).rejects.toThrow('connection refused');
		expect(closeMock).toHaveBeenCalledTimes(1);
	});

	it('swallows close errors', async () => {
		listToolsMock.mockResolvedValue([{ name: 'echo', description: 'd' }]);
		closeMock.mockRejectedValue(new Error('already closed'));

		await expect(listMcpServerTools(makeServer(), deps())).resolves.toEqual([
			{ name: 'echo', description: 'd' },
		]);
	});
});
