362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import type { ServerDefinition } from '../src/config.js';
|
|
import { cliModulePromise, linearDefinition } from './fixtures/cli-list-fixtures.js';
|
|
|
|
describe('CLI list classification and routing', () => {
|
|
it('identifies auth and offline failures and suggests remediation', async () => {
|
|
const originalCI = process.env.CI;
|
|
process.env.CI = '1';
|
|
|
|
const { handleList } = await cliModulePromise;
|
|
const definitions: ServerDefinition[] = [
|
|
{
|
|
name: 'healthy',
|
|
command: { kind: 'stdio', command: 'noop', args: [], cwd: process.cwd() },
|
|
source: { kind: 'local', path: '/tmp/config.json' },
|
|
},
|
|
{
|
|
name: 'vercel',
|
|
description: 'Vercel MCP',
|
|
command: { kind: 'http', url: new URL('https://example.com') },
|
|
},
|
|
{
|
|
name: 'github',
|
|
command: { kind: 'http', url: new URL('https://example.com') },
|
|
source: { kind: 'import', path: '/tmp/import.json' },
|
|
},
|
|
{
|
|
name: 'next-devtools',
|
|
command: { kind: 'http', url: new URL('https://localhost') },
|
|
},
|
|
{
|
|
name: 'obsidian',
|
|
command: { kind: 'http', url: new URL('https://localhost') },
|
|
},
|
|
];
|
|
|
|
const runtime = {
|
|
getDefinitions: () => definitions,
|
|
listTools: (name: string) => {
|
|
switch (name) {
|
|
case 'healthy':
|
|
return Promise.resolve([{ name: 'ok' }]);
|
|
case 'vercel':
|
|
return Promise.reject(new Error('SSE error: Non-200 status code (401)'));
|
|
case 'github':
|
|
return Promise.reject(new Error('SSE error: Non-200 status code (405)'));
|
|
case 'next-devtools':
|
|
return Promise.reject(new Error('SSE error: fetch failed: connect ECONNREFUSED 127.0.0.1:3000'));
|
|
case 'obsidian':
|
|
return Promise.reject(new Error('MCP error -32000: Connection closed'));
|
|
default:
|
|
return Promise.resolve([]);
|
|
}
|
|
},
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
await handleList(runtime, []);
|
|
|
|
const logLines = logSpy.mock.calls.map((call) => call.join(' '));
|
|
expect(
|
|
logLines.some((line) => line.includes("vercel — Vercel MCP (auth required — run 'mcporter auth vercel'"))
|
|
).toBe(true);
|
|
expect(logLines.some((line) => line.includes('github') && line.includes('HTTP 405'))).toBe(true);
|
|
const nextDevtoolsLineFound = logLines.some(
|
|
(line) => line.startsWith('- next-devtools') && line.includes('offline — unable to reach server')
|
|
);
|
|
expect(nextDevtoolsLineFound).toBe(true);
|
|
expect(
|
|
logLines.some((line) => line.includes('obsidian') && line.includes('offline — unable to reach server'))
|
|
).toBe(true);
|
|
|
|
const summaryLine = logLines.find((line) => line.startsWith('✔ Listed'));
|
|
expect(summaryLine).toBeDefined();
|
|
expect(summaryLine).toContain('auth required');
|
|
expect(summaryLine).toContain('http errors');
|
|
expect(summaryLine).toContain('offline');
|
|
|
|
logSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
process.env.CI = originalCI;
|
|
});
|
|
|
|
it('suggests URL-based auth for ad-hoc HTTP servers', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const definitions = new Map<string, ServerDefinition>();
|
|
const runtime = {
|
|
registerDefinition: vi.fn((definition: ServerDefinition) => {
|
|
definitions.set(definition.name, definition);
|
|
}),
|
|
getDefinition: vi.fn((name: string) => {
|
|
const entry = definitions.get(name);
|
|
if (!entry) {
|
|
throw new Error(`Unknown MCP server '${name}'.`);
|
|
}
|
|
return entry;
|
|
}),
|
|
getDefinitions: () => Array.from(definitions.values()),
|
|
listTools: vi.fn().mockRejectedValue(new Error('SSE error: Non-200 status code (401)')),
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
await handleList(runtime, ['https://mcp.supabase.com/mcp']);
|
|
|
|
const hinted = warnSpy.mock.calls.some((call) =>
|
|
(call[0]?.toString() ?? '').includes("Next: run 'mcporter auth https://mcp.supabase.com/mcp'")
|
|
);
|
|
expect(hinted).toBe(true);
|
|
expect(warnSpy.mock.calls.map((call) => call.join(' '))).toContain(' Tools: <unavailable>');
|
|
|
|
warnSpy.mockRestore();
|
|
logSpy.mockRestore();
|
|
});
|
|
|
|
it('persists OAuth promotion for ad-hoc HTTP servers', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-persist-oauth-'));
|
|
const persistPath = path.join(tempDir, 'mcporter.json');
|
|
const definitions = new Map<string, ServerDefinition>();
|
|
const runtime = {
|
|
registerDefinition: vi.fn((definition: ServerDefinition) => {
|
|
definitions.set(definition.name, definition);
|
|
}),
|
|
getDefinition: vi.fn((name: string) => {
|
|
const entry = definitions.get(name);
|
|
if (!entry) {
|
|
throw new Error(`Unknown MCP server '${name}'.`);
|
|
}
|
|
return entry;
|
|
}),
|
|
getDefinitions: () => Array.from(definitions.values()),
|
|
listTools: vi.fn(async (name: string) => {
|
|
const entry = definitions.get(name);
|
|
if (!entry) {
|
|
throw new Error(`Unknown MCP server '${name}'.`);
|
|
}
|
|
definitions.set(name, { ...entry, auth: 'oauth' });
|
|
return [{ name: 'ok' }];
|
|
}),
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
try {
|
|
await handleList(runtime, ['https://mcp.granola.ai/mcp', '--persist', persistPath]);
|
|
|
|
const parsed = JSON.parse(await fs.readFile(persistPath, 'utf8')) as {
|
|
mcpServers: Record<string, { auth?: string; baseUrl?: string }>;
|
|
};
|
|
expect(parsed.mcpServers['mcp-granola-ai-mcp']).toMatchObject({
|
|
baseUrl: 'https://mcp.granola.ai/mcp',
|
|
auth: 'oauth',
|
|
});
|
|
} finally {
|
|
logSpy.mockRestore();
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('reuses configured servers when listing by URL', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const definition: ServerDefinition = {
|
|
name: 'vercel',
|
|
description: 'Vercel MCP',
|
|
command: { kind: 'http', url: new URL('https://mcp.vercel.com') },
|
|
source: { kind: 'local', path: '/tmp/config.json' },
|
|
};
|
|
const registerDefinition = vi.fn();
|
|
const listTools = vi.fn().mockResolvedValue([{ name: 'ok' }]);
|
|
const runtime = {
|
|
getDefinitions: () => [definition],
|
|
registerDefinition,
|
|
getDefinition: () => definition,
|
|
listTools,
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
await handleList(runtime, ['https://mcp.vercel.com']);
|
|
|
|
expect(listTools).toHaveBeenCalledWith(
|
|
'vercel',
|
|
expect.objectContaining({ includeSchema: true, autoAuthorize: false, allowCachedAuth: true })
|
|
);
|
|
expect(registerDefinition).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reuses configured servers when listing by HTTP tool selector', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const definition: ServerDefinition = {
|
|
name: 'shadcn',
|
|
description: 'shadcn/ui registry MCP',
|
|
command: { kind: 'http', url: new URL('https://shadcn.io/api/mcp') },
|
|
source: { kind: 'local', path: '/tmp/config.json' },
|
|
};
|
|
const registerDefinition = vi.fn();
|
|
const listTools = vi.fn().mockResolvedValue([{ name: 'getComponents' }]);
|
|
const runtime = {
|
|
getDefinitions: () => [definition],
|
|
registerDefinition,
|
|
getDefinition: () => definition,
|
|
listTools,
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
await handleList(runtime, ['https://www.shadcn.io/api/mcp.getComponents']);
|
|
|
|
expect(listTools).toHaveBeenCalledWith(
|
|
'shadcn',
|
|
expect.objectContaining({ includeSchema: true, autoAuthorize: false, allowCachedAuth: true })
|
|
);
|
|
expect(registerDefinition).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reuses configured servers for scheme-less HTTP tool selectors', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const definition: ServerDefinition = {
|
|
name: 'shadcn',
|
|
description: 'shadcn/ui registry MCP',
|
|
command: { kind: 'http', url: new URL('https://shadcn.io/api/mcp') },
|
|
source: { kind: 'local', path: '/tmp/config.json' },
|
|
};
|
|
const listTools = vi.fn().mockResolvedValue([{ name: 'getComponents' }]);
|
|
const runtime = {
|
|
getDefinitions: () => [definition],
|
|
registerDefinition: vi.fn(),
|
|
getDefinition: () => definition,
|
|
listTools,
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
await handleList(runtime, ['shadcn.io/api/mcp.getComponents']);
|
|
|
|
expect(listTools).toHaveBeenCalledWith(
|
|
'shadcn',
|
|
expect.objectContaining({ includeSchema: true, autoAuthorize: false, allowCachedAuth: true })
|
|
);
|
|
});
|
|
|
|
it('enables cached OAuth when listing all servers', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const definition: ServerDefinition = {
|
|
name: 'linear',
|
|
description: 'Linear MCP',
|
|
auth: 'oauth',
|
|
command: { kind: 'http', url: new URL('https://mcp.linear.app/sse') },
|
|
source: { kind: 'local', path: '/tmp/config.json' },
|
|
};
|
|
const listTools = vi.fn().mockResolvedValue([{ name: 'ok' }]);
|
|
const runtime = {
|
|
getDefinitions: () => [definition],
|
|
listTools,
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
await handleList(runtime, []);
|
|
|
|
expect(listTools).toHaveBeenCalledWith('linear', {
|
|
autoAuthorize: false,
|
|
allowCachedAuth: true,
|
|
});
|
|
});
|
|
|
|
it('registers an ad-hoc HTTP server when URL is provided', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const definitions = new Map<string, ServerDefinition>();
|
|
const registerDefinition = vi.fn((definition: ServerDefinition) => {
|
|
definitions.set(definition.name, definition);
|
|
});
|
|
const listTools = vi.fn(() => Promise.resolve([]));
|
|
const runtime = {
|
|
getDefinitions: () => Array.from(definitions.values()),
|
|
getDefinition: (name: string) => {
|
|
const definition = definitions.get(name);
|
|
if (!definition) {
|
|
throw new Error('missing');
|
|
}
|
|
return definition;
|
|
},
|
|
listTools,
|
|
registerDefinition,
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
await handleList(runtime, ['https://mcp.example.com/mcp']);
|
|
|
|
expect(registerDefinition).toHaveBeenCalled();
|
|
expect(definitions.get('mcp-example-com-mcp')).toBeDefined();
|
|
expect(listTools).toHaveBeenCalledWith(
|
|
'mcp-example-com-mcp',
|
|
expect.objectContaining({ includeSchema: true, autoAuthorize: false, allowCachedAuth: true })
|
|
);
|
|
|
|
logSpy.mockRestore();
|
|
});
|
|
|
|
it('auto-corrects unknown server names when the edit distance is small', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const definition = linearDefinition;
|
|
const getDefinition = vi.fn().mockImplementation((name: string) => {
|
|
if (name === 'linear') {
|
|
return definition;
|
|
}
|
|
throw new Error(`Unknown MCP server '${name}'.`);
|
|
});
|
|
const listTools = vi.fn(() => Promise.resolve([]));
|
|
const runtime = {
|
|
getDefinition,
|
|
getDefinitions: () => [definition],
|
|
listTools,
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
await handleList(runtime, ['linera']);
|
|
|
|
expect(getDefinition).toHaveBeenCalledTimes(2);
|
|
expect(listTools).toHaveBeenCalledWith(
|
|
'linear',
|
|
expect.objectContaining({ includeSchema: true, autoAuthorize: false, allowCachedAuth: true })
|
|
);
|
|
const messages = logSpy.mock.calls.map((call) => call.join(' '));
|
|
expect(messages.some((line) => line.includes('Auto-corrected server name to linear'))).toBe(true);
|
|
|
|
logSpy.mockRestore();
|
|
});
|
|
|
|
it('suggests a server name when the typo is large', async () => {
|
|
const { handleList } = await cliModulePromise;
|
|
const previousExitCode = process.exitCode;
|
|
process.exitCode = undefined;
|
|
const definition = linearDefinition;
|
|
const listTools = vi.fn();
|
|
const runtime = {
|
|
getDefinition: () => {
|
|
throw new Error("Unknown MCP server 'zzz'");
|
|
},
|
|
getDefinitions: () => [definition],
|
|
listTools,
|
|
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
|
|
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
try {
|
|
await handleList(runtime, ['zzz']);
|
|
|
|
const errorLines = errorSpy.mock.calls.map((call) => call.join(' '));
|
|
expect(errorLines.some((line) => line.includes('Did you mean linear?'))).toBe(true);
|
|
expect(listTools).not.toHaveBeenCalled();
|
|
expect(process.exitCode).toBe(1);
|
|
} finally {
|
|
errorSpy.mockRestore();
|
|
logSpy.mockRestore();
|
|
process.exitCode = previousExitCode;
|
|
}
|
|
});
|
|
});
|