import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { McpError, ErrorCode, ElicitRequestSchema, UrlElicitationRequiredError, } from '@modelcontextprotocol/sdk/types.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ClientCredentialsProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js';
import { McpClient } from '../mcp.js';
import { McpTool } from '../tools/mcp-tool.js';
import { JsonBlock } from '../types/messages.js';
import { ImageBlock } from '../types/media.js';
import { context, propagation, trace, TraceFlags } from '@opentelemetry/api';
import { logger } from '../logging/index.js';
/**
 * Helper to create a mock async generator that yields a result message.
 * This simulates the behavior of callToolStream returning a stream that ends with a result.
 */
function createMockCallToolStream(result) {
    return async function* () {
        yield { type: 'result', result };
    };
}
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
    StreamableHTTPClientTransport: vi.fn(function () {
        return { start: vi.fn(), send: vi.fn(), close: vi.fn() };
    }),
}));
vi.mock('@modelcontextprotocol/sdk/client/auth-extensions.js', () => ({
    ClientCredentialsProvider: vi.fn(function () {
        return { redirectUrl: undefined, clientMetadata: { client_id: 'test' } };
    }),
}));
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
    Client: vi.fn(function () {
        return {
            connect: vi.fn(),
            close: vi.fn(),
            listTools: vi.fn(),
            callTool: vi.fn(),
            setRequestHandler: vi.fn(),
            setNotificationHandler: vi.fn(),
            getServerCapabilities: vi.fn(),
            getServerVersion: vi.fn(),
            getInstructions: vi.fn(),
            experimental: {
                tasks: {
                    callToolStream: vi.fn(),
                },
            },
        };
    }),
}));
vi.mock('../tools/tool.js', () => ({
    Tool: class {
    },
    createErrorResult: (err, toolUseId) => ({
        type: 'toolResultBlock',
        status: 'error',
        toolUseId,
        content: [{ type: 'textBlock', text: err instanceof Error ? err.message : String(err) }],
    }),
}));
/**
 * Executes a tool stream to completion and returns the final result.
 */
async function runTool(gen) {
    let result = await gen.next();
    while (!result.done) {
        result = await gen.next();
    }
    return result.value;
}
/**
 * Mock an active span with a valid trace ID via trace.getSpan,
 * and stub propagation.inject to populate the carrier with a traceparent.
 */
function mockActiveSpan(traceId = '1234567890abcdef1234567890abcdef', traceFlags = TraceFlags.SAMPLED) {
    const mockSpan = {
        spanContext: () => ({
            traceId,
            spanId: '1234567890abcdef',
            traceFlags,
        }),
    };
    vi.spyOn(trace, 'getSpan').mockReturnValue(mockSpan);
    vi.spyOn(propagation, 'inject').mockImplementation((_context, carrier) => {
        if (carrier && typeof carrier === 'object') {
            ;
            carrier.traceparent = `00-${traceId}-1234567890abcdef-01`;
        }
    });
}
const mockTransport = {
    connect: vi.fn(),
    close: vi.fn(),
    send: vi.fn(),
};
describe('MCP Integration', () => {
    beforeEach(() => {
        vi.clearAllMocks();
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });
    function createElicitationClient(callback) {
        const resultsLengthBefore = vi.mocked(Client).mock.results.length;
        const elicitClient = new McpClient({
            applicationName: 'TestApp',
            transport: mockTransport,
            elicitationCallback: callback,
        });
        const elicitSdkClientMock = vi.mocked(Client).mock.results[resultsLengthBefore].value;
        return { elicitClient, elicitSdkClientMock };
    }
    async function connectAndGetElicitationHandler(callback) {
        const { elicitClient, elicitSdkClientMock } = createElicitationClient(callback);
        await elicitClient.connect();
        const handler = elicitSdkClientMock.setRequestHandler.mock.calls[0][1];
        return { handler, elicitSdkClientMock };
    }
    describe('McpClient', () => {
        let client;
        let sdkClientMock;
        beforeEach(() => {
            client = new McpClient({
                applicationName: 'TestApp',
                transport: mockTransport,
            });
            sdkClientMock = vi.mocked(Client).mock.results[0].value;
        });
        it('initializes SDK client with correct configuration', () => {
            expect(Client).toHaveBeenCalledWith({ name: 'TestApp', version: '0.0.1' }, expect.objectContaining({
                listChanged: expect.objectContaining({
                    tools: expect.objectContaining({ autoRefresh: false, debounceMs: 300 }),
                }),
            }));
        });
        it('injects trace context into tool arguments when active span exists', async () => {
            mockActiveSpan();
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client });
            sdkClientMock.callTool.mockResolvedValue({ content: [] });
            await client.callTool(tool, { op: 'add' });
            const callArgs = sdkClientMock.callTool.mock.calls[0][0];
            expect(callArgs.arguments).toStrictEqual({
                op: 'add',
                _meta: { traceparent: '00-1234567890abcdef1234567890abcdef-1234567890abcdef-01' },
            });
        });
        it('merges trace context with existing _meta field', async () => {
            mockActiveSpan();
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client });
            sdkClientMock.callTool.mockResolvedValue({ content: [] });
            await client.callTool(tool, { op: 'add', _meta: { progressToken: 'tok-1' } });
            const callArgs = sdkClientMock.callTool.mock.calls[0][0];
            expect(callArgs.arguments).toStrictEqual({
                op: 'add',
                _meta: {
                    progressToken: 'tok-1',
                    traceparent: '00-1234567890abcdef1234567890abcdef-1234567890abcdef-01',
                },
            });
        });
        it('passes args unchanged when no active span exists', async () => {
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client });
            sdkClientMock.callTool.mockResolvedValue({ content: [] });
            await client.callTool(tool, { op: 'add' });
            const callArgs = sdkClientMock.callTool.mock.calls[0][0];
            expect(callArgs.arguments).toStrictEqual({ op: 'add' });
        });
        it('passes args unchanged when span has empty trace ID', async () => {
            mockActiveSpan('', TraceFlags.NONE);
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client });
            sdkClientMock.callTool.mockResolvedValue({ content: [] });
            await client.callTool(tool, { op: 'add' });
            const callArgs = sdkClientMock.callTool.mock.calls[0][0];
            expect(callArgs.arguments).toStrictEqual({ op: 'add' });
        });
        it('passes args unchanged when context injection fails', async () => {
            vi.spyOn(context, 'active').mockImplementation(() => {
                throw new Error('Context error');
            });
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client });
            sdkClientMock.callTool.mockResolvedValue({ content: [] });
            await client.callTool(tool, { op: 'add' });
            const callArgs = sdkClientMock.callTool.mock.calls[0][0];
            expect(callArgs.arguments).toStrictEqual({ op: 'add' });
        });
        it('skips trace context injection when disableMcpInstrumentation is true', async () => {
            mockActiveSpan();
            const noInstrClient = new McpClient({
                applicationName: 'TestApp',
                transport: mockTransport,
                disableMcpInstrumentation: true,
            });
            const noInstrSdkMock = vi.mocked(Client).mock.results.at(-1).value;
            noInstrSdkMock.callTool.mockResolvedValue({ content: [] });
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client: noInstrClient });
            await noInstrClient.callTool(tool, { op: 'add' });
            const callArgs = noInstrSdkMock.callTool.mock.calls[0][0];
            expect(callArgs.arguments).toStrictEqual({ op: 'add' });
        });
        it('manages connection state lazily', async () => {
            await client.connect();
            expect(sdkClientMock.connect).toHaveBeenCalledTimes(1);
            await client.connect();
            expect(sdkClientMock.connect).toHaveBeenCalledTimes(1);
        });
        it('supports forced reconnection', async () => {
            await client.connect();
            await client.connect(true);
            expect(sdkClientMock.close).toHaveBeenCalled();
            expect(sdkClientMock.connect).toHaveBeenCalledTimes(2);
        });
        it('converts SDK tool specs to McpTool instances', async () => {
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'weather', description: 'Get weather', inputSchema: {} }],
            });
            const tools = await client.listTools();
            expect(sdkClientMock.connect).toHaveBeenCalled();
            expect(tools).toHaveLength(1);
            expect(tools[0]).toBeInstanceOf(McpTool);
            expect(tools[0].name).toBe('weather');
        });
        it('paginates through all pages of tools', async () => {
            sdkClientMock.listTools
                .mockResolvedValueOnce({
                tools: [{ name: 'tool_a', description: 'A', inputSchema: {} }],
                nextCursor: 'page2',
            })
                .mockResolvedValueOnce({
                tools: [{ name: 'tool_b', description: 'B', inputSchema: {} }],
                nextCursor: 'page3',
            })
                .mockResolvedValueOnce({
                tools: [{ name: 'tool_c', description: 'C', inputSchema: {} }],
            });
            const tools = await client.listTools();
            expect(tools).toHaveLength(3);
            expect(tools.map((t) => t.name)).toEqual(['tool_a', 'tool_b', 'tool_c']);
            expect(sdkClientMock.listTools).toHaveBeenCalledTimes(3);
            expect(sdkClientMock.listTools).toHaveBeenNthCalledWith(1, undefined);
            expect(sdkClientMock.listTools).toHaveBeenNthCalledWith(2, { cursor: 'page2' });
            expect(sdkClientMock.listTools).toHaveBeenNthCalledWith(3, { cursor: 'page3' });
        });
        it('generates description fallback when description is missing', async () => {
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'my_tool', inputSchema: {} }],
            });
            const tools = await client.listTools();
            expect(tools[0].description).toBe('Tool which performs my_tool');
        });
        it('generates description fallback when description is empty string', async () => {
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'my_tool', description: '', inputSchema: {} }],
            });
            const tools = await client.listTools();
            expect(tools[0].description).toBe('Tool which performs my_tool');
        });
        it('uses callTool when tasksConfig is undefined (default)', async () => {
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client });
            sdkClientMock.callTool.mockResolvedValue({ content: [] });
            await client.callTool(tool, { op: 'add' });
            expect(sdkClientMock.connect).toHaveBeenCalled();
            expect(sdkClientMock.callTool).toHaveBeenCalledWith({ name: 'calc', arguments: { op: 'add' } }, undefined, undefined);
            expect(sdkClientMock.experimental.tasks.callToolStream).not.toHaveBeenCalled();
        });
        it('forwards abort signal to SDK callTool', async () => {
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client });
            sdkClientMock.callTool.mockResolvedValue({ content: [] });
            const controller = new AbortController();
            await client.callTool(tool, { op: 'add' }, { signal: controller.signal });
            expect(sdkClientMock.callTool).toHaveBeenCalledWith({ name: 'calc', arguments: { op: 'add' } }, undefined, {
                signal: controller.signal,
            });
        });
        it('forwards abort signal to callToolStream when tasksConfig is provided', async () => {
            const resultsLengthBefore = vi.mocked(Client).mock.results.length;
            const taskClient = new McpClient({
                applicationName: 'TestApp',
                transport: mockTransport,
                tasksConfig: {},
            });
            const taskSdkClientMock = vi.mocked(Client).mock.results[resultsLengthBefore].value;
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client: taskClient });
            taskSdkClientMock.experimental.tasks.callToolStream.mockReturnValue(createMockCallToolStream({ content: [] })());
            const controller = new AbortController();
            await taskClient.callTool(tool, { op: 'add' }, { signal: controller.signal });
            expect(taskSdkClientMock.experimental.tasks.callToolStream).toHaveBeenCalledWith({ name: 'calc', arguments: { op: 'add' } }, undefined, { timeout: 60000, maxTotalTimeout: 300000, resetTimeoutOnProgress: true, signal: controller.signal });
        });
        it('uses callToolStream when tasksConfig is provided (empty object)', async () => {
            const resultsLengthBefore = vi.mocked(Client).mock.results.length;
            const taskClient = new McpClient({
                applicationName: 'TestApp',
                transport: mockTransport,
                tasksConfig: {},
            });
            const taskSdkClientMock = vi.mocked(Client).mock.results[resultsLengthBefore].value;
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client: taskClient });
            taskSdkClientMock.experimental.tasks.callToolStream.mockReturnValue(createMockCallToolStream({ content: [] })());
            await taskClient.callTool(tool, { op: 'add' });
            expect(taskSdkClientMock.connect).toHaveBeenCalled();
            expect(taskSdkClientMock.experimental.tasks.callToolStream).toHaveBeenCalledWith({ name: 'calc', arguments: { op: 'add' } }, undefined, { timeout: 60000, maxTotalTimeout: 300000, resetTimeoutOnProgress: true });
            expect(taskSdkClientMock.callTool).not.toHaveBeenCalled();
        });
        it('passes custom TTL and pollTimeout to callToolStream', async () => {
            const resultsLengthBefore = vi.mocked(Client).mock.results.length;
            const taskClient = new McpClient({
                applicationName: 'TestApp',
                transport: mockTransport,
                tasksConfig: { ttl: 30000, pollTimeout: 120000 },
            });
            const taskSdkClientMock = vi.mocked(Client).mock.results[resultsLengthBefore].value;
            const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client: taskClient });
            taskSdkClientMock.experimental.tasks.callToolStream.mockReturnValue(createMockCallToolStream({ content: [] })());
            await taskClient.callTool(tool, { op: 'add' });
            expect(taskSdkClientMock.experimental.tasks.callToolStream).toHaveBeenCalledWith({ name: 'calc', arguments: { op: 'add' } }, undefined, { timeout: 30000, maxTotalTimeout: 120000, resetTimeoutOnProgress: true });
        });
        it('validates tool arguments', async () => {
            const tool = new McpTool({ name: 't', description: '', inputSchema: {}, client });
            await expect(client.callTool(tool, ['invalid-array'])).rejects.toThrow(/JSON Object/);
        });
        it('cleans up resources', async () => {
            await client.disconnect();
            expect(sdkClientMock.close).toHaveBeenCalled();
            expect(mockTransport.close).toHaveBeenCalled();
        });
        it('supports Symbol.asyncDispose for await using pattern', async () => {
            await client[Symbol.asyncDispose]();
            expect(sdkClientMock.close).toHaveBeenCalled();
            expect(mockTransport.close).toHaveBeenCalled();
        });
        it('registers elicitation handler before connecting when callback is provided', async () => {
            const resultsLengthBefore = vi.mocked(Client).mock.results.length;
            const callback = vi.fn();
            const elicitClient = new McpClient({
                applicationName: 'TestApp',
                transport: mockTransport,
                elicitationCallback: callback,
            });
            const elicitSdkClientMock = vi.mocked(Client).mock.results[resultsLengthBefore].value;
            await elicitClient.connect();
            expect(elicitSdkClientMock.setRequestHandler).toHaveBeenCalledWith(ElicitRequestSchema, expect.any(Function));
            const setHandlerOrder = elicitSdkClientMock.setRequestHandler.mock.invocationCallOrder[0];
            const connectOrder = elicitSdkClientMock.connect.mock.invocationCallOrder[0];
            expect(setHandlerOrder).toBeLessThan(connectOrder);
        });
        it('does not register elicitation handler when no callback is provided', async () => {
            await client.connect();
            expect(sdkClientMock.setRequestHandler).not.toHaveBeenCalled();
        });
        it('passes elicitation capabilities to Client when callback is provided', () => {
            const callback = vi.fn();
            new McpClient({
                applicationName: 'TestApp',
                transport: mockTransport,
                elicitationCallback: callback,
            });
            const lastCall = vi.mocked(Client).mock.calls.at(-1);
            expect(lastCall[1]).toEqual(expect.objectContaining({ capabilities: { elicitation: { form: {}, url: {} } } }));
        });
        it('elicitation handler returns accepted result with content', async () => {
            const callbackResult = { action: 'accept', content: { username: 'alice' } };
            const callback = vi.fn().mockResolvedValue(callbackResult);
            const { handler } = await connectAndGetElicitationHandler(callback);
            const request = {
                method: 'elicitation/create',
                params: { message: 'Enter username', requestedSchema: { type: 'object' } },
            };
            const extra = { signal: new AbortController().signal };
            const result = await handler(request, extra);
            expect(callback).toHaveBeenCalledWith(extra, request.params);
            expect(result).toEqual({ action: 'accept', content: { username: 'alice' } });
        });
        it.each([{ action: 'decline' }, { action: 'cancel' }])('elicitation handler returns $action result', async (callbackResult) => {
            const callback = vi.fn().mockResolvedValue(callbackResult);
            const { handler } = await connectAndGetElicitationHandler(callback);
            const request = {
                method: 'elicitation/create',
                params: { message: 'Enter username', requestedSchema: { type: 'object' } },
            };
            const extra = { signal: new AbortController().signal };
            const result = await handler(request, extra);
            expect(callback).toHaveBeenCalledWith(extra, request.params);
            expect(result).toEqual({ action: callbackResult.action });
        });
        it('elicitation handler works for URL mode params', async () => {
            const callbackResult = { action: 'accept' };
            const callback = vi.fn().mockResolvedValue(callbackResult);
            const { handler } = await connectAndGetElicitationHandler(callback);
            const request = {
                method: 'elicitation/create',
                params: {
                    mode: 'url',
                    message: 'Please authenticate',
                    url: 'https://example.com/auth',
                    elicitationId: 'elicit-123',
                },
            };
            const extra = { signal: new AbortController().signal };
            const result = await handler(request, extra);
            expect(callback).toHaveBeenCalledWith(extra, request.params);
            expect(result).toEqual({ action: 'accept' });
        });
        it('elicitation callback errors propagate', async () => {
            const callback = vi.fn().mockRejectedValue(new Error('User cancelled'));
            const { handler } = await connectAndGetElicitationHandler(callback);
            const request = {
                method: 'elicitation/create',
                params: { message: 'Confirm?' },
            };
            const extra = { signal: new AbortController().signal };
            await expect(handler(request, extra)).rejects.toThrow('User cancelled');
        });
    });
    describe('tools list changed', () => {
        let client;
        let sdkClientMock;
        beforeEach(() => {
            client = new McpClient({ applicationName: 'TestApp', transport: mockTransport });
            sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
            sdkClientMock.connect.mockResolvedValue(undefined);
        });
        function triggerToolsChanged() {
            const ctorCall = vi.mocked(Client).mock.calls.at(-1);
            ctorCall[1].listChanged.tools.onChanged(null, null);
        }
        it('calls onToolsChanged with old names and new tools when list changes', async () => {
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'tool_a', description: 'A', inputSchema: {} }],
            });
            await client.listTools();
            const onToolsChanged = vi.fn();
            client.onToolsChanged = onToolsChanged;
            sdkClientMock.listTools.mockResolvedValue({
                tools: [
                    { name: 'tool_a', description: 'A', inputSchema: {} },
                    { name: 'tool_b', description: 'B', inputSchema: {} },
                ],
            });
            triggerToolsChanged();
            await vi.waitFor(() => expect(onToolsChanged).toHaveBeenCalled());
            expect(onToolsChanged).toHaveBeenCalledWith(['tool_a'], expect.any(Array));
            const newTools = onToolsChanged.mock.calls[0][1];
            expect(newTools.map((t) => t.name)).toEqual(['tool_a', 'tool_b']);
        });
        it('updates registered tool names after each listTools call', async () => {
            sdkClientMock.listTools.mockResolvedValue({
                tools: [
                    { name: 'x', description: 'X', inputSchema: {} },
                    { name: 'y', description: 'Y', inputSchema: {} },
                ],
            });
            await client.listTools();
            const onToolsChanged = vi.fn();
            client.onToolsChanged = onToolsChanged;
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'z', description: 'Z', inputSchema: {} }],
            });
            triggerToolsChanged();
            await vi.waitFor(() => expect(onToolsChanged).toHaveBeenCalled());
            expect(onToolsChanged).toHaveBeenCalledWith(['x', 'y'], expect.any(Array));
            const newTools = onToolsChanged.mock.calls[0][1];
            expect(newTools.map((t) => t.name)).toEqual(['z']);
        });
        it('does not throw when onToolsChanged is not set', async () => {
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'tool_a', description: 'A', inputSchema: {} }],
            });
            await client.listTools();
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'tool_b', description: 'B', inputSchema: {} }],
            });
            triggerToolsChanged();
            await new Promise((r) => setTimeout(r, 0));
        });
        it('logs warning and preserves registry when listTools fails during refresh', async () => {
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'tool_a', description: 'A', inputSchema: {} }],
            });
            await client.listTools();
            const onToolsChanged = vi.fn();
            client.onToolsChanged = onToolsChanged;
            sdkClientMock.listTools.mockRejectedValue(new Error('server disconnected'));
            const warnSpy = vi.spyOn(logger, 'warn');
            triggerToolsChanged();
            await vi.waitFor(() => expect(warnSpy).toHaveBeenCalled());
            expect(onToolsChanged).not.toHaveBeenCalled();
            expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('failed to refresh tools'));
        });
        it('coalesces notifications received during an in-flight refresh into one extra refresh', async () => {
            sdkClientMock.listTools.mockResolvedValue({
                tools: [{ name: 'tool_a', description: 'A', inputSchema: {} }],
            });
            await client.listTools();
            const onToolsChanged = vi.fn();
            client.onToolsChanged = onToolsChanged;
            let resolveListTools;
            sdkClientMock.listTools.mockReturnValue(new Promise((r) => (resolveListTools = r)));
            triggerToolsChanged();
            triggerToolsChanged();
            triggerToolsChanged();
            resolveListTools({ tools: [{ name: 'tool_b', description: 'B', inputSchema: {} }] });
            await vi.waitFor(() => expect(onToolsChanged).toHaveBeenCalledTimes(2));
            expect(sdkClientMock.listTools).toHaveBeenCalledTimes(3);
        });
    });
    describe('McpTool', () => {
        const mockClientWrapper = { callTool: vi.fn() };
        const tool = new McpTool({
            name: 'weather',
            description: 'Get weather',
            inputSchema: {},
            client: mockClientWrapper,
        });
        const toolContext = {
            toolUse: { toolUseId: 'id-123', name: 'weather', input: { city: 'NYC' } },
            agent: { cancelSignal: new AbortController().signal },
            invocationState: {},
            interrupt: () => {
                throw new Error('interrupt not available in mock context');
            },
        };
        it('forwards agent cancelSignal to callTool', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [{ type: 'text', text: 'ok' }],
            });
            await runTool(tool.stream(toolContext));
            expect(mockClientWrapper.callTool).toHaveBeenCalledWith(tool, { city: 'NYC' }, {
                signal: toolContext.agent.cancelSignal,
            });
        });
        it('returns text results on success', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [{ type: 'text', text: 'Sunny' }],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result).toBeDefined();
            expect(result.status).toBe('success');
            expect(result.content[0].text).toBe('Sunny');
        });
        it('returns structured data results on success', async () => {
            const data = { temperature: 72 };
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [{ type: 'data', value: data }],
            });
            const result = await runTool(tool.stream(toolContext));
            const content = result.content[0];
            expect(content).toBeInstanceOf(JsonBlock);
            expect(content.json).toEqual(expect.objectContaining({ value: data }));
        });
        it('provides default message for empty output', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({ content: [] });
            const result = await runTool(tool.stream(toolContext));
            expect(result.content[0].text).toContain('completed successfully');
        });
        it('handles protocol-level errors', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                isError: true,
                content: [{ type: 'text', text: 'Service Unavailable' }],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe('Service Unavailable');
        });
        it('catches and wraps client exceptions', async () => {
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(new Error('Network Error'));
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe('Network Error');
        });
        it('validates SDK response format', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({ content: null });
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toContain('missing content array');
        });
        it('maps MCP image content to ImageBlock', async () => {
            // "iVBOR..." is a minimal base64 PNG prefix
            const base64Data = 'iVBORw0KGgoAAAANSUhEUg==';
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [{ type: 'image', data: base64Data, mimeType: 'image/png' }],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('success');
            expect(result.content).toHaveLength(1);
            const imageBlock = result.content[0];
            expect(imageBlock).toBeInstanceOf(ImageBlock);
            expect(imageBlock.format).toBe('png');
            expect(imageBlock.source.type).toBe('imageSourceBytes');
        });
        it('falls back to JsonBlock for unsupported image mime type', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [{ type: 'image', data: 'abc123', mimeType: 'image/bmp' }],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.content[0]).toBeInstanceOf(JsonBlock);
        });
        it('falls back to JsonBlock for image content missing data', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [{ type: 'image', mimeType: 'image/png' }],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.content[0]).toBeInstanceOf(JsonBlock);
        });
        it('maps MCP text resource to TextBlock', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [
                    { type: 'resource', resource: { uri: 'file:///doc.txt', text: 'hello world', mimeType: 'text/plain' } },
                ],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('success');
            expect(result.content[0].text).toBe('hello world');
        });
        it('maps MCP blob resource with image mime type to ImageBlock', async () => {
            const base64Data = 'iVBORw0KGgoAAAANSUhEUg==';
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [{ type: 'resource', resource: { uri: 'file:///img.png', blob: base64Data, mimeType: 'image/jpeg' } }],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.content[0]).toBeInstanceOf(ImageBlock);
            expect(result.content[0].format).toBe('jpeg');
        });
        it('falls back to JsonBlock for blob resource with non-image mime type', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [
                    { type: 'resource', resource: { uri: 'file:///doc.pdf', blob: 'abc123', mimeType: 'application/pdf' } },
                ],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.content[0]).toBeInstanceOf(JsonBlock);
        });
        it('falls back to JsonBlock for resource with neither text nor blob', async () => {
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [{ type: 'resource', resource: { uri: 'file:///unknown' } }],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.content[0]).toBeInstanceOf(JsonBlock);
        });
        it('handles mixed content types in a single result', async () => {
            const base64Data = 'iVBORw0KGgoAAAANSUhEUg==';
            vi.mocked(mockClientWrapper.callTool).mockResolvedValue({
                content: [
                    { type: 'text', text: 'Here is the image:' },
                    { type: 'image', data: base64Data, mimeType: 'image/png' },
                    { type: 'resource', resource: { uri: 'file:///notes.txt', text: 'Some notes' } },
                ],
            });
            const result = await runTool(tool.stream(toolContext));
            expect(result.content).toHaveLength(3);
            expect(result.content[0].text).toBe('Here is the image:');
            expect(result.content[1]).toBeInstanceOf(ImageBlock);
            expect(result.content[2].text).toBe('Some notes');
        });
        it('surfaces elicitation data for McpError with code -32042', async () => {
            const elicitations = [
                {
                    mode: 'url',
                    message: 'Please authorize with GitHub',
                    elicitationId: 'e-123',
                    url: 'https://github.com/login/oauth/authorize?client_id=abc',
                },
            ];
            const mcpError = new McpError(ErrorCode.UrlElicitationRequired, 'Authorization required', { elicitations });
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(mcpError);
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe(`MCP Elicitation required: [${String(mcpError)}] with data ${JSON.stringify(elicitations)}`);
        });
        it('surfaces multiple elicitations for McpError with code -32042', async () => {
            const elicitations = [
                {
                    mode: 'url',
                    message: 'Authorize with GitHub',
                    elicitationId: 'e-1',
                    url: 'https://github.com/login/oauth/authorize',
                },
                {
                    mode: 'url',
                    message: 'Authorize with Google',
                    elicitationId: 'e-2',
                    url: 'https://accounts.google.com/o/oauth2/auth',
                },
            ];
            const mcpError = new McpError(ErrorCode.UrlElicitationRequired, 'Authorization required', { elicitations });
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(mcpError);
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe(`MCP Elicitation required: [${String(mcpError)}] with data ${JSON.stringify(elicitations)}`);
        });
        it('falls through to generic error for McpError -32042 with malformed data', async () => {
            const mcpError = new McpError(ErrorCode.UrlElicitationRequired, 'Authorization required', {
                unexpected: 'shape',
            });
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(mcpError);
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe('MCP error -32042: Authorization required');
        });
        it('surfaces elicitation data for UrlElicitationRequiredError', async () => {
            const elicitations = [
                {
                    mode: 'url',
                    message: 'Please authorize',
                    elicitationId: 'e-1',
                    url: 'https://example.com/auth',
                },
            ];
            const error = new UrlElicitationRequiredError(elicitations, 'Auth required');
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(error);
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toContain('MCP Elicitation required');
            expect(result.content[0].text).toContain('https://example.com/auth');
        });
        it('falls through to generic error for McpError -32042 with undefined data', async () => {
            const mcpError = new McpError(ErrorCode.UrlElicitationRequired, 'Auth required');
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(mcpError);
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe('MCP error -32042: Auth required');
        });
        it('falls through to generic error for McpError -32042 with non-array elicitations', async () => {
            const mcpError = new McpError(ErrorCode.UrlElicitationRequired, 'Auth required', {
                elicitations: 'not-an-array',
            });
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(mcpError);
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe('MCP error -32042: Auth required');
        });
        it('falls through to generic error for McpError -32042 with empty elicitations', async () => {
            const mcpError = new McpError(ErrorCode.UrlElicitationRequired, 'Auth required', {
                elicitations: [],
            });
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(mcpError);
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe('MCP error -32042: Auth required');
        });
        it('falls through to generic error for McpError with a different code', async () => {
            const mcpError = new McpError(ErrorCode.InvalidRequest, 'Bad request');
            vi.mocked(mockClientWrapper.callTool).mockRejectedValue(mcpError);
            const result = await runTool(tool.stream(toolContext));
            expect(result.status).toBe('error');
            expect(result.content[0].text).toBe('MCP error -32600: Bad request');
        });
    });
});
describe('server metadata getters', () => {
    let client;
    let sdkClientMock;
    beforeEach(() => {
        vi.clearAllMocks();
        client = new McpClient({ applicationName: 'TestApp', transport: mockTransport });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });
    it('returns undefined for all getters before connect', () => {
        sdkClientMock.getServerCapabilities.mockReturnValue(undefined);
        sdkClientMock.getServerVersion.mockReturnValue(undefined);
        sdkClientMock.getInstructions.mockReturnValue(undefined);
        expect(client.serverCapabilities).toBeUndefined();
        expect(client.serverVersion).toBeUndefined();
        expect(client.serverInstructions).toBeUndefined();
    });
    it('returns serverCapabilities after connect', async () => {
        const caps = { tools: {} };
        sdkClientMock.getServerCapabilities.mockReturnValue(caps);
        await client.connect();
        expect(client.serverCapabilities).toBe(caps);
    });
    it('returns serverVersion after connect', async () => {
        const version = { name: 'my-server', version: '1.2.3' };
        sdkClientMock.getServerVersion.mockReturnValue(version);
        await client.connect();
        expect(client.serverVersion).toBe(version);
    });
    it('returns serverInstructions after connect', async () => {
        sdkClientMock.getInstructions.mockReturnValue('Use this server for X.');
        await client.connect();
        expect(client.serverInstructions).toBe('Use this server for X.');
    });
    it('connectionState is disconnected before connect', () => {
        expect(client.connectionState).toBe('disconnected');
    });
    it('connectionState is connected after successful connect', async () => {
        await client.connect();
        expect(client.connectionState).toBe('connected');
    });
});
describe('continueOnError', () => {
    let sdkClientMock;
    beforeEach(() => {
        vi.clearAllMocks();
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });
    it('throws on connection failure by default', async () => {
        const client = new McpClient({ applicationName: 'TestApp', transport: mockTransport });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
        sdkClientMock.connect.mockRejectedValue(new Error('connection refused'));
        await expect(client.connect()).rejects.toThrow('connection refused');
    });
    it('swallows connection failure when continueOnError is true', async () => {
        const client = new McpClient({ applicationName: 'TestApp', transport: mockTransport, continueOnError: true });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
        sdkClientMock.connect.mockRejectedValue(new Error('connection refused'));
        await expect(client.connect()).resolves.toBeUndefined();
    });
    it('logs a warning when continueOnError swallows a connection failure', async () => {
        const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => { });
        const client = new McpClient({ applicationName: 'TestApp', transport: mockTransport, continueOnError: true });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
        sdkClientMock.connect.mockRejectedValue(new Error('connection refused'));
        await client.connect();
        expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('MCP server failed to connect'));
    });
    it('listTools returns empty array when continueOnError and connection failed', async () => {
        const client = new McpClient({ applicationName: 'TestApp', transport: mockTransport, continueOnError: true });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
        sdkClientMock.connect.mockRejectedValue(new Error('connection refused'));
        const tools = await client.listTools();
        expect(tools).toEqual([]);
    });
    it('callTool throws when continueOnError and connection failed', async () => {
        const client = new McpClient({ applicationName: 'TestApp', transport: mockTransport, continueOnError: true });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
        sdkClientMock.connect.mockRejectedValue(new Error('connection refused'));
        const tool = new McpTool({ name: 'my_tool', description: '', inputSchema: {}, client });
        await expect(client.callTool(tool, {})).rejects.toThrow('MCP server failed to connect. Call connect(true) to retry.');
    });
    it('does not retry connection on subsequent calls after continueOnError failure', async () => {
        const client = new McpClient({ applicationName: 'TestApp', transport: mockTransport, continueOnError: true });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
        sdkClientMock.connect.mockRejectedValue(new Error('connection refused'));
        await client.listTools();
        await client.listTools();
        expect(sdkClientMock.connect).toHaveBeenCalledTimes(1);
    });
    it('recovers after explicit connect(true) when server comes back', async () => {
        const client = new McpClient({ applicationName: 'TestApp', transport: mockTransport, continueOnError: true });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
        sdkClientMock.connect.mockRejectedValueOnce(new Error('connection refused'));
        sdkClientMock.listTools.mockResolvedValue({ tools: [] });
        const firstTools = await client.listTools();
        expect(firstTools).toEqual([]);
        expect(client.connectionState).toBe('failed');
        await client.connect(true);
        const secondTools = await client.listTools();
        expect(secondTools).toEqual([]);
        expect(client.connectionState).toBe('connected');
        expect(sdkClientMock.connect).toHaveBeenCalledTimes(2);
    });
});
describe('log routing', () => {
    let notificationHandler;
    let sdkClientMock;
    beforeEach(() => {
        vi.clearAllMocks();
        new McpClient({ applicationName: 'TestApp', transport: mockTransport });
        sdkClientMock = vi.mocked(Client).mock.results.at(-1).value;
        // Handler is registered in the constructor — read it from the first setNotificationHandler call
        notificationHandler = sdkClientMock.setNotificationHandler.mock.calls[0][1];
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });
    it('routes debug level to logger.debug', () => {
        const spy = vi.spyOn(logger, 'debug').mockImplementation(() => { });
        notificationHandler({ params: { level: 'debug', data: 'hello' } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello'));
    });
    it('routes info level to logger.info', () => {
        const spy = vi.spyOn(logger, 'info').mockImplementation(() => { });
        notificationHandler({ params: { level: 'info', data: 'hello' } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello'));
    });
    it('routes notice level to logger.info', () => {
        const spy = vi.spyOn(logger, 'info').mockImplementation(() => { });
        notificationHandler({ params: { level: 'notice', data: 'hello' } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello'));
    });
    it('routes warning level to logger.warn', () => {
        const spy = vi.spyOn(logger, 'warn').mockImplementation(() => { });
        notificationHandler({ params: { level: 'warning', data: 'hello' } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello'));
    });
    it('routes error level to logger.error', () => {
        const spy = vi.spyOn(logger, 'error').mockImplementation(() => { });
        notificationHandler({ params: { level: 'error', data: 'hello' } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello'));
    });
    it('routes critical level to logger.error', () => {
        const spy = vi.spyOn(logger, 'error').mockImplementation(() => { });
        notificationHandler({ params: { level: 'critical', data: 'hello' } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello'));
    });
    it('routes alert level to logger.error', () => {
        const spy = vi.spyOn(logger, 'error').mockImplementation(() => { });
        notificationHandler({ params: { level: 'alert', data: 'hello' } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello'));
    });
    it('routes emergency level to logger.error', () => {
        const spy = vi.spyOn(logger, 'error').mockImplementation(() => { });
        notificationHandler({ params: { level: 'emergency', data: 'hello' } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('hello'));
    });
    it('includes logger name and data in the message', () => {
        const spy = vi.spyOn(logger, 'info').mockImplementation(() => { });
        notificationHandler({ params: { level: 'info', logger: 'my-server', data: { key: 'val' } } });
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('my-server'));
        expect(spy).toHaveBeenCalledWith(expect.stringContaining('key'));
    });
    it('calls custom logHandler when provided', () => {
        const customHandler = vi.fn();
        new McpClient({ applicationName: 'TestApp', transport: mockTransport, logHandler: customHandler });
        const customSdkMock = vi.mocked(Client).mock.results.at(-1).value;
        const capturedHandler = customSdkMock.setNotificationHandler.mock.calls[0][1];
        const params = { level: 'info', data: 'test' };
        capturedHandler({ params });
        expect(customHandler).toHaveBeenCalledWith(params);
    });
});
describe('McpClient transport resolution', () => {
    beforeEach(() => {
        vi.clearAllMocks();
    });
    it('constructs StreamableHTTPClientTransport when url is provided', () => {
        new McpClient({ url: 'https://mcp.example.com' });
        expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(new URL('https://mcp.example.com'), {});
    });
    it('constructs ClientCredentialsProvider when auth is provided', () => {
        new McpClient({ url: 'https://mcp.example.com', auth: { clientId: 'id', clientSecret: 'secret' } });
        expect(ClientCredentialsProvider).toHaveBeenCalledWith({ clientId: 'id', clientSecret: 'secret' });
        expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(new URL('https://mcp.example.com'), {
            authProvider: expect.anything(),
        });
    });
    it('passes scopes as space-separated string', () => {
        new McpClient({
            url: 'https://mcp.example.com',
            auth: { clientId: 'id', clientSecret: 'secret', scopes: ['read', 'write'] },
        });
        expect(ClientCredentialsProvider).toHaveBeenCalledWith({
            clientId: 'id',
            clientSecret: 'secret',
            scope: 'read write',
        });
    });
    it('passes custom authProvider to transport', () => {
        const customProvider = { redirectUrl: undefined, clientMetadata: {} };
        new McpClient({ url: 'https://mcp.example.com', authProvider: customProvider });
        expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(new URL('https://mcp.example.com'), {
            authProvider: customProvider,
        });
    });
    it('throws when both transport and url are provided', () => {
        expect(() => new McpClient({ transport: mockTransport, url: 'https://mcp.example.com' })).toThrow('provide either "transport" or "url", not both');
    });
    it('throws when neither transport nor url is provided', () => {
        expect(() => new McpClient({})).toThrow('either "transport" or "url" must be provided');
    });
    it('throws when auth is provided with transport', () => {
        expect(() => new McpClient({ transport: mockTransport, auth: { clientId: 'x', clientSecret: 'y' } })).toThrow('"auth", "authProvider", and "headers" require "url"');
    });
    it('throws when both auth and authProvider are provided', () => {
        const customProvider = {};
        expect(() => new McpClient({
            url: 'https://mcp.example.com',
            auth: { clientId: 'x', clientSecret: 'y' },
            authProvider: customProvider,
        })).toThrow('provide either "auth" or "authProvider", not both');
    });
    it('accepts URL instance for url field', () => {
        const url = new URL('https://mcp.example.com/path');
        new McpClient({ url });
        expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(url, {});
    });
    it('passes headers as requestInit to transport', () => {
        new McpClient({ url: 'https://mcp.example.com', headers: { 'X-Api-Key': 'abc' } });
        expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(new URL('https://mcp.example.com'), {
            requestInit: { headers: { 'X-Api-Key': 'abc' } },
        });
    });
    it('passes both auth and headers to transport', () => {
        new McpClient({
            url: 'https://mcp.example.com',
            auth: { clientId: 'id', clientSecret: 'secret' },
            headers: { 'X-Trace': '123' },
        });
        expect(ClientCredentialsProvider).toHaveBeenCalledWith({ clientId: 'id', clientSecret: 'secret' });
        expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(new URL('https://mcp.example.com'), {
            authProvider: expect.anything(),
            requestInit: { headers: { 'X-Trace': '123' } },
        });
    });
    it('throws when headers is provided with transport', () => {
        expect(() => new McpClient({ transport: mockTransport, headers: { 'X-Foo': 'bar' } })).toThrow('"auth", "authProvider", and "headers" require "url"');
    });
});
//# sourceMappingURL=mcp.test.js.map