import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const connectMock = vi.fn(); const listToolsMock = vi.fn(); const callToolMock = vi.fn(); const listResourcesMock = vi.fn(); const clientInstances: unknown[] = []; const streamableInstances: unknown[] = []; const stdioInstances: unknown[] = []; class MockClient { constructor() { clientInstances.push(this); } async connect(transport: { start?: () => Promise }) { connectMock(transport); if (typeof transport.start === 'function') { await transport.start(); } } async close() {} async listTools(params: unknown) { return listToolsMock(params); } async callTool(params: unknown) { return callToolMock(params); } async listResources(params: unknown) { return listResourcesMock(params); } } class MockStreamableHTTPClientTransport { public start = vi.fn(async () => {}); public close = vi.fn(async () => {}); constructor( public url: URL, public options?: unknown ) { streamableInstances.push(this); } } class MockSSEClientTransport { public start = vi.fn(async () => {}); public close = vi.fn(async () => {}); constructor( public url: URL, public options?: unknown ) {} } class MockStdioClientTransport { public start = vi.fn(async () => {}); public close = vi.fn(async () => {}); constructor(public options: unknown) { stdioInstances.push(this); } } class MockUnauthorizedError extends Error {} return { connectMock, listToolsMock, callToolMock, listResourcesMock, clientInstances, streamableInstances, stdioInstances, MockClient, MockStreamableHTTPClientTransport, MockSSEClientTransport, MockStdioClientTransport, MockUnauthorizedError, }; }); vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ Client: mocks.MockClient, })); vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ StreamableHTTPClientTransport: mocks.MockStreamableHTTPClientTransport, })); vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({ SSEClientTransport: mocks.MockSSEClientTransport, })); vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ StdioClientTransport: mocks.MockStdioClientTransport, })); vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ UnauthorizedError: mocks.MockUnauthorizedError, })); import { createRuntime } from '../src/runtime.js'; describe('mcporter composability', () => { beforeEach(() => { mocks.connectMock.mockClear(); mocks.listToolsMock.mockReset(); mocks.callToolMock.mockReset(); mocks.listResourcesMock.mockReset(); mocks.clientInstances.length = 0; mocks.streamableInstances.length = 0; mocks.stdioInstances.length = 0; mocks.listToolsMock.mockResolvedValue({ tools: [] }); mocks.callToolMock.mockResolvedValue({ ok: true }); mocks.listResourcesMock.mockResolvedValue({ resources: [] }); }); afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); }); it('reuses a single client connection for sequential calls', async () => { mocks.listToolsMock.mockResolvedValueOnce({ tools: [{ name: 'echo', description: 'Echo tool' }], }); mocks.callToolMock.mockResolvedValueOnce({ ok: 'first' }).mockResolvedValueOnce({ ok: 'second' }); const previousToken = process.env.INLINE_TOKEN; process.env.INLINE_TOKEN = 'inline-test'; const runtime = await createRuntime({ servers: [ { name: 'fake', description: 'Inline fake server', command: { kind: 'http' as const, url: new URL('https://example.com'), headers: { Authorization: `Bearer \${INLINE_TOKEN}` }, }, }, ], }); try { const tools = await runtime.listTools('fake'); expect(tools).toEqual([ { name: 'echo', description: 'Echo tool', inputSchema: undefined, outputSchema: undefined, }, ]); expect(mocks.connectMock).toHaveBeenCalledTimes(1); expect(mocks.clientInstances).toHaveLength(1); const streamableTransport = mocks.streamableInstances[0] as { options?: { requestInit?: { headers?: Record }; authProvider?: unknown; }; close: ReturnType; }; expect(streamableTransport.options?.requestInit?.headers).toEqual({ Authorization: 'Bearer inline-test', }); const first = await runtime.callTool('fake', 'echo', { args: { text: 'hi' }, }); const second = await runtime.callTool('fake', 'echoSecond', { args: { count: 2 }, }); expect(first).toEqual({ ok: 'first' }); expect(second).toEqual({ ok: 'second' }); expect(mocks.callToolMock).toHaveBeenNthCalledWith(1, { name: 'echo', arguments: { text: 'hi' }, }); expect(mocks.callToolMock).toHaveBeenNthCalledWith(2, { name: 'echoSecond', arguments: { count: 2 }, }); expect(mocks.connectMock).toHaveBeenCalledTimes(1); expect(mocks.clientInstances).toHaveLength(1); } finally { await runtime.close(); const streamableTransport = mocks.streamableInstances[0] as { close: ReturnType; }; expect(mocks.streamableInstances).toHaveLength(1); expect(streamableTransport.close).toHaveBeenCalledTimes(1); if (previousToken === undefined) { delete process.env.INLINE_TOKEN; } else { process.env.INLINE_TOKEN = previousToken; } } }); it('passes the current process env to stdio transports', async () => { vi.stubEnv('MCPORTER_STDIO_TEST', 'from-parent'); const runtime = await createRuntime({ servers: [ { name: 'local', command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() }, source: { kind: 'local', path: '' }, }, ], }); await runtime.callTool('local', 'echo', {}); const instance = mocks.stdioInstances.at(-1) as { options?: { env?: Record } }; expect(instance?.options?.env?.MCPORTER_STDIO_TEST).toBe('from-parent'); }); it('overrides inherited env vars with server-specific values', async () => { vi.stubEnv('MCPORTER_STDIO_TEST', 'parent'); const runtime = await createRuntime({ servers: [ { name: 'local', command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() }, env: { MCPORTER_STDIO_TEST: 'from-config', EXTRA: '42' }, source: { kind: 'local', path: '' }, }, ], }); await runtime.callTool('local', 'echo', {}); const instance = mocks.stdioInstances.at(-1) as { options?: { env?: Record } }; expect(instance?.options?.env?.MCPORTER_STDIO_TEST).toBe('from-config'); expect(instance?.options?.env?.EXTRA).toBe('42'); }); }); describe('stdio transport environment', () => { const previousEnv = { ...process.env }; beforeEach(() => { process.env = { ...previousEnv }; mocks.listToolsMock.mockReset(); mocks.listToolsMock.mockResolvedValue({ tools: [] }); mocks.clientInstances.length = 0; mocks.stdioInstances.length = 0; }); afterEach(() => { process.env = { ...previousEnv }; vi.clearAllMocks(); }); it('resolves env overrides before spawning stdio transport', async () => { process.env.OBSIDIAN_API_KEY = 'secret'; delete process.env.OBSIDIAN_BASE_URL; const runtime = await createRuntime({ servers: [ { name: 'obsidian', description: 'Local Obsidian bridge', command: { kind: 'stdio' as const, command: 'node', args: ['--version'], cwd: '/repo', }, env: { // biome-ignore lint/suspicious/noTemplateCurlyInString: placeholders resolve against process env at runtime OBSIDIAN_API_KEY: '${OBSIDIAN_API_KEY}', // biome-ignore lint/suspicious/noTemplateCurlyInString: placeholders resolve against process env at runtime OBSIDIAN_BASE_URL: '${OBSIDIAN_BASE_URL:-https://127.0.0.1:27124}', EMPTY_VAR: '', }, }, ], }); try { await runtime.listTools('obsidian'); expect(mocks.stdioInstances).toHaveLength(1); const transport = mocks.stdioInstances[0] as { options: { env?: Record } }; expect(transport.options.env).toEqual( expect.objectContaining({ OBSIDIAN_API_KEY: 'secret', OBSIDIAN_BASE_URL: 'https://127.0.0.1:27124', }) ); } finally { await runtime.close().catch(() => {}); } }); });