import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleConfigCli } from '../src/cli/config-command.js'; import type { LoadConfigOptions } from '../src/config.js'; import { MCPORTER_VERSION } from '../src/runtime.js'; describe('mcporter config CLI', () => { let tempDir: string; let configPath: string; let originalXdg: string | undefined; let originalXdgData: string | undefined; beforeEach(async () => { originalXdg = process.env.XDG_CONFIG_HOME; originalXdgData = process.env.XDG_DATA_HOME; tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-config-')); configPath = path.join(tempDir, 'config', 'mcporter.json'); process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data'); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); if (originalXdg === undefined) { delete process.env.XDG_CONFIG_HOME; } else { process.env.XDG_CONFIG_HOME = originalXdg; } if (originalXdgData === undefined) { delete process.env.XDG_DATA_HOME; } else { process.env.XDG_DATA_HOME = originalXdgData; } vi.restoreAllMocks(); }); it('adds an HTTP server via positional target', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'linear', 'https://linear.app/mcp']); const buffer = await fs.readFile(configPath, 'utf8'); const parsed = JSON.parse(buffer) as { mcpServers: Record }; const linear = parsed.mcpServers.linear; expect(linear).toBeDefined(); expect(linear?.baseUrl).toBe('https://linear.app/mcp'); }); it('lists servers in JSON format', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'linear', 'https://linear.app/mcp']); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['list', '--json']); spy.mockRestore(); const jsonLine = logs.find((entry) => entry.trimStart().startsWith('{')) ?? '{}'; const payload = JSON.parse(jsonLine.trim()) as { servers: Array<{ name: string }> }; expect(payload.servers).toHaveLength(1); expect(payload.servers[0]?.name).toBe('linear'); }); it('prints config summary for text list output', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'linear', 'https://linear.app/mcp']); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['list']); spy.mockRestore(); expect(logs.some((entry) => entry.includes('Project config:'))).toBe(true); expect(logs.some((entry) => entry.includes('System config:'))).toBe(true); }); it('removes an existing server', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'linear', 'https://linear.app/mcp']); await handleConfigCli(buildOptions({ configPath }), ['remove', 'linear']); const buffer = await fs.readFile(configPath, 'utf8'); const parsed = JSON.parse(buffer) as { mcpServers: Record }; expect(parsed.mcpServers.linear).toBeUndefined(); }); it('copies imported entries when requested', async () => { const cursorDir = path.join(tempDir, '.cursor'); await fs.mkdir(cursorDir, { recursive: true }); const importPath = path.join(cursorDir, 'mcp.json'); await fs.writeFile( importPath, JSON.stringify({ mcpServers: { 'cursor-only': { description: 'from cursor', baseUrl: 'https://cursor.example/mcp', }, }, }), 'utf8' ); await handleConfigCli(buildOptions({ configPath, rootDir: tempDir }), [ 'import', 'cursor', '--copy', '--filter', 'cursor-only', ]); const buffer = await fs.readFile(configPath, 'utf8'); const parsed = JSON.parse(buffer) as { mcpServers: Record }; const cursorOnly = parsed.mcpServers['cursor-only']; expect(cursorOnly).toBeDefined(); expect(cursorOnly?.baseUrl).toBe('https://cursor.example/mcp'); }); it('rejects stdio args without a command target', async () => { await expect(handleConfigCli(buildOptions({ configPath }), ['add', 'broken', '--arg', 'foo'])).rejects.toThrow( '--arg/--args requires a stdio command' ); }); it('keeps project-scoped imports ahead of user-scoped ones when copying', async () => { const cursorProjectDir = path.join(tempDir, '.cursor'); await fs.mkdir(cursorProjectDir, { recursive: true }); const projectConfigPath = path.join(cursorProjectDir, 'mcp.json'); await fs.writeFile( projectConfigPath, JSON.stringify({ mcpServers: { shared: { baseUrl: 'https://project.example/mcp' }, }, }), 'utf8' ); const xdgRoot = path.join(tempDir, 'xdg'); process.env.XDG_CONFIG_HOME = xdgRoot; const cursorUserPath = path.join(xdgRoot, 'Cursor'); await fs.mkdir(cursorUserPath, { recursive: true }); const userConfigPath = path.join(cursorUserPath, 'mcp.json'); await fs.writeFile( userConfigPath, JSON.stringify({ mcpServers: { shared: { baseUrl: 'https://user.example/mcp' }, }, }), 'utf8' ); await handleConfigCli(buildOptions({ configPath, rootDir: tempDir }), [ 'import', 'cursor', '--copy', '--filter', 'shared', ]); const buffer = await fs.readFile(configPath, 'utf8'); const parsed = JSON.parse(buffer) as { mcpServers: Record }; const shared = parsed.mcpServers.shared; expect(shared).toBeDefined(); expect(shared?.baseUrl).toBe('https://project.example/mcp'); }); it('delegates login to the auth handler', async () => { const invokeAuth = vi.fn().mockResolvedValue(undefined); await handleConfigCli(buildOptions({ configPath }, { invokeAuth }), ['login', 'linear']); expect(invokeAuth).toHaveBeenCalledWith(['linear']); }); it('delegates login browser-suppression flags to the auth handler', async () => { const invokeAuth = vi.fn().mockResolvedValue(undefined); await handleConfigCli(buildOptions({ configPath }, { invokeAuth }), ['login', 'linear', '--no-browser']); expect(invokeAuth).toHaveBeenCalledWith(['linear', '--no-browser']); }); it('clears cached credentials on logout', async () => { const tokenDir = path.join(tempDir, 'token-cache'); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile( configPath, JSON.stringify({ mcpServers: { linear: { baseUrl: 'https://linear.app/mcp', auth: 'oauth', tokenCacheDir: tokenDir, }, }, }), 'utf8' ); await fs.mkdir(tokenDir, { recursive: true }); await fs.writeFile(path.join(tokenDir, 'token.json'), '{}', 'utf8'); await handleConfigCli(buildOptions({ configPath }), ['logout', 'linear']); await expect(fs.access(tokenDir)).rejects.toThrow(); }); it('reports a clean config via doctor', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'linear', 'https://linear.app/mcp']); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['doctor']); spy.mockRestore(); expect(logs[0]).toBe(`MCPorter ${MCPORTER_VERSION}`); expect(logs[1]).toMatch(/^Project config:/); expect(logs[2]).toMatch(/^System config:/); expect(logs[3]).toBe(''); expect(logs[4]).toBe('Config looks good.'); }); it('prints config locations before doctor issues', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'stdio-server', '--command', 'node --version']); const absoluteSpy = vi.spyOn(path, 'isAbsolute').mockReturnValue(false); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['doctor']); absoluteSpy.mockRestore(); spy.mockRestore(); expect(logs[0]).toBe(`MCPorter ${MCPORTER_VERSION}`); expect(logs[1]).toMatch(/^Project config:/); expect(logs[2]).toMatch(/^System config:/); expect(logs[4]).toBe('Config issues detected:'); expect(logs[5]).toMatch(/non-absolute working directory/); }); it('prints inline help for subcommands via --help', async () => { const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['add', '--help']); spy.mockRestore(); const output = logs.join('\n'); expect(output).toContain('mcporter config add'); expect(output).toContain('Usage'); expect(output).toContain('--url '); }); it('prints help for named subcommands via config help', async () => { const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['help', 'list']); spy.mockRestore(); const output = logs.join('\n'); expect(output).toContain('mcporter config list'); expect(output).toContain('--source '); }); it('prints browser-suppression flags in config login help', async () => { const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['help', 'login']); spy.mockRestore(); const output = logs.join('\n'); expect(output).toContain('mcporter config login'); expect(output).toContain('--no-browser'); expect(output).toContain('--browser none'); expect(output).toContain('MCPORTER_OAUTH_NO_BROWSER'); }); it('warns when requesting help for unknown subcommands', async () => { const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['help', 'bogus']); spy.mockRestore(); expect(logs.join('\n')).toContain("Unknown config subcommand 'bogus'"); }); it('lists only local entries by default and summarizes imports', async () => { process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-home'); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, JSON.stringify({ mcpServers: {}, imports: ['cursor'] }), 'utf8'); const cursorDir = path.join(tempDir, '.cursor'); await fs.mkdir(cursorDir, { recursive: true }); const importPath = path.join(cursorDir, 'mcp.json'); await fs.writeFile( importPath, JSON.stringify({ mcpServers: { 'cursor-only': { baseUrl: 'https://cursor.example/mcp' }, }, }), 'utf8' ); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath, rootDir: tempDir }), ['list']); spy.mockRestore(); const output = logs.join('\n'); expect(output).toContain('No local servers match'); expect(output).toContain('Other sources available via --source import'); const cursorPathPattern = /\.cursor[\\/]mcp\.json/; expect(output).toMatch(cursorPathPattern); expect(output).toContain('cursor-only'); }); it('lists import entries when --source import is provided', async () => { process.env.XDG_CONFIG_HOME = path.join(tempDir, 'xdg-home'); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile(configPath, JSON.stringify({ mcpServers: {}, imports: ['cursor'] }), 'utf8'); const cursorDir = path.join(tempDir, '.cursor'); await fs.mkdir(cursorDir, { recursive: true }); const importPath = path.join(cursorDir, 'mcp.json'); await fs.writeFile( importPath, JSON.stringify({ mcpServers: { 'cursor-only': { baseUrl: 'https://cursor.example/mcp' }, }, }), 'utf8' ); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath, rootDir: tempDir }), ['list', '--json', '--source', 'import']); spy.mockRestore(); const jsonLine = logs.find((entry) => entry.trimStart().startsWith('{')) ?? '{}'; const payload = JSON.parse(jsonLine.trim()) as { servers: Array<{ name: string }> }; expect(payload.servers.some((server) => server.name === 'cursor-only')).toBe(true); }); it('filters list output by source', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'linear', 'https://linear.app/mcp']); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['list', '--json', '--source', 'local']); spy.mockRestore(); const jsonLine = logs.find((entry) => entry.trimStart().startsWith('{')) ?? '{}'; const payload = JSON.parse(jsonLine.trim()) as { servers: Array<{ name: string }> }; expect(payload.servers).toHaveLength(1); expect(payload.servers[0]?.name).toBe('linear'); }); it('prints server details via get --json', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'linear', 'https://linear.app/mcp']); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['get', 'linear', '--json']); spy.mockRestore(); const payload = JSON.parse(logs.join('\n')) as { name: string; baseUrl: string }; expect(payload.name).toBe('linear'); expect(payload.baseUrl).toBe('https://linear.app/mcp'); }); it('auto-corrects server names when using get', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'shadcn', 'https://shadcn.io/api/mcp']); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await handleConfigCli(buildOptions({ configPath }), ['get', 'sshadcn', '--json']); spy.mockRestore(); const output = logs.join('\n'); expect(output).toContain('Auto-corrected server name to shadcn'); }); it('suggests close matches when get cannot auto-correct', async () => { await handleConfigCli(buildOptions({ configPath }), ['add', 'shadcn', 'https://shadcn.io/api/mcp']); const logs: string[] = []; const spy = vi.spyOn(console, 'log').mockImplementation(captureLog(logs)); await expect(handleConfigCli(buildOptions({ configPath }), ['get', 'shadowverse'])).rejects.toThrow( "[mcporter] Unknown server 'shadowverse'." ); spy.mockRestore(); expect(logs.join('\n')).toContain('Did you mean shadcn'); }); }); function captureLog(target: string[]): (message?: unknown) => void { return (message?: unknown) => { if (typeof message === 'string') { target.push(message); return; } if (message == null) { target.push(''); return; } if (typeof message === 'object') { try { target.push(JSON.stringify(message)); return; } catch { target.push('[object]'); return; } } if (typeof message === 'number' || typeof message === 'boolean' || typeof message === 'bigint') { target.push(String(message)); return; } if (typeof message === 'symbol') { target.push(String(message)); return; } if (typeof message === 'function') { target.push('[function]'); return; } target.push(''); }; } function buildOptions( loadOptions: LoadConfigOptions, overrides?: Partial<{ invokeAuth: (args: string[]) => Promise }> ): { loadOptions: LoadConfigOptions; invokeAuth: (args: string[]) => Promise } { return { loadOptions, invokeAuth: overrides?.invokeAuth ?? (async () => {}), }; }