169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
|
import { createTestCommand } from '../../src/commands/test-mcp.js';
|
||
|
|
|
||
|
|
function makeSession(overrides: Partial<{
|
||
|
|
initialize: () => Promise<unknown>;
|
||
|
|
listTools: () => Promise<Array<{ name: string }>>;
|
||
|
|
callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
|
||
|
|
close: () => Promise<void>;
|
||
|
|
}> = {}) {
|
||
|
|
return {
|
||
|
|
initialize: overrides.initialize ?? vi.fn(async () => ({ protocolVersion: '2024-11-05' })),
|
||
|
|
listTools: overrides.listTools ?? vi.fn(async () => [{ name: 'echo' }, { name: 'search' }]),
|
||
|
|
callTool: overrides.callTool ?? vi.fn(async () => ({ content: [{ type: 'text', text: 'hi' }] })),
|
||
|
|
close: overrides.close ?? vi.fn(async () => { /* no-op */ }),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('mcpctl test mcp', () => {
|
||
|
|
const output: string[] = [];
|
||
|
|
const log = (...args: unknown[]) => {
|
||
|
|
output.push(args.map(String).join(' '));
|
||
|
|
};
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
output.length = 0;
|
||
|
|
process.exitCode = 0;
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
process.exitCode = 0;
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exits 0 on happy path (health + initialize + tools/list)', async () => {
|
||
|
|
const session = makeSession();
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: () => session,
|
||
|
|
healthCheck: async () => true,
|
||
|
|
});
|
||
|
|
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
|
||
|
|
expect(process.exitCode).toBe(0);
|
||
|
|
expect(session.initialize).toHaveBeenCalled();
|
||
|
|
expect(session.listTools).toHaveBeenCalled();
|
||
|
|
expect(output.join('\n')).toContain('Result: PASS');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exits 1 when the /healthz preflight fails', async () => {
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: () => makeSession(),
|
||
|
|
healthCheck: async () => false,
|
||
|
|
});
|
||
|
|
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
|
||
|
|
expect(process.exitCode).toBe(1);
|
||
|
|
expect(output.join('\n')).toContain('healthz preflight failed');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exits 2 (contract fail) when --expect-tools are missing', async () => {
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: () => makeSession({
|
||
|
|
listTools: async () => [{ name: 'echo' }],
|
||
|
|
}),
|
||
|
|
healthCheck: async () => true,
|
||
|
|
});
|
||
|
|
await cmd.parseAsync(
|
||
|
|
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--expect-tools', 'echo,search'],
|
||
|
|
{ from: 'user' },
|
||
|
|
);
|
||
|
|
expect(process.exitCode).toBe(2);
|
||
|
|
expect(output.join('\n')).toContain('Missing: search');
|
||
|
|
expect(output.join('\n')).toContain('CONTRACT FAIL');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exits 0 when --expect-tools all match', async () => {
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: () => makeSession({
|
||
|
|
listTools: async () => [{ name: 'echo' }, { name: 'search' }, { name: 'x' }],
|
||
|
|
}),
|
||
|
|
healthCheck: async () => true,
|
||
|
|
});
|
||
|
|
await cmd.parseAsync(
|
||
|
|
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--expect-tools', 'echo,search'],
|
||
|
|
{ from: 'user' },
|
||
|
|
);
|
||
|
|
expect(process.exitCode).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exits 1 on transport/auth failure (initialize throws)', async () => {
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: () => makeSession({
|
||
|
|
initialize: async () => { throw new Error('HTTP 401: unauthorized'); },
|
||
|
|
}),
|
||
|
|
healthCheck: async () => true,
|
||
|
|
});
|
||
|
|
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
|
||
|
|
expect(process.exitCode).toBe(1);
|
||
|
|
expect(output.join('\n')).toContain('Error:');
|
||
|
|
expect(output.join('\n')).toContain('TRANSPORT/AUTH FAIL');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('invokes --tool with --args and reports isError', async () => {
|
||
|
|
const callTool = vi.fn(async () => ({ content: [{ type: 'text', text: 'oops' }], isError: true }));
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: () => makeSession({ callTool }),
|
||
|
|
healthCheck: async () => true,
|
||
|
|
});
|
||
|
|
await cmd.parseAsync(
|
||
|
|
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--tool', 'echo', '--args', '{"msg":"hi"}'],
|
||
|
|
{ from: 'user' },
|
||
|
|
);
|
||
|
|
expect(callTool).toHaveBeenCalledWith('echo', { msg: 'hi' });
|
||
|
|
expect(process.exitCode).toBe(2);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('outputs a JSON report with -o json', async () => {
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: () => makeSession(),
|
||
|
|
healthCheck: async () => true,
|
||
|
|
});
|
||
|
|
await cmd.parseAsync(
|
||
|
|
['mcp', 'https://mcp.example.com/projects/foo/mcp', '-o', 'json'],
|
||
|
|
{ from: 'user' },
|
||
|
|
);
|
||
|
|
const parsed = JSON.parse(output.join('\n')) as { exitCode: number; tools: string[] };
|
||
|
|
expect(parsed.exitCode).toBe(0);
|
||
|
|
expect(parsed.tools).toEqual(['echo', 'search']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reads $MCPCTL_TOKEN when --token is not given', async () => {
|
||
|
|
let observedBearer: string | undefined;
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: (_url, opts) => {
|
||
|
|
observedBearer = opts.bearer;
|
||
|
|
return makeSession();
|
||
|
|
},
|
||
|
|
healthCheck: async () => true,
|
||
|
|
});
|
||
|
|
const prev = process.env.MCPCTL_TOKEN;
|
||
|
|
process.env.MCPCTL_TOKEN = 'mcpctl_pat_fromenv';
|
||
|
|
try {
|
||
|
|
await cmd.parseAsync(['mcp', 'https://mcp.example.com/projects/foo/mcp'], { from: 'user' });
|
||
|
|
} finally {
|
||
|
|
if (prev === undefined) delete process.env.MCPCTL_TOKEN;
|
||
|
|
else process.env.MCPCTL_TOKEN = prev;
|
||
|
|
}
|
||
|
|
expect(observedBearer).toBe('mcpctl_pat_fromenv');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects invalid --args as JSON', async () => {
|
||
|
|
const cmd = createTestCommand({
|
||
|
|
log,
|
||
|
|
createSession: () => makeSession(),
|
||
|
|
healthCheck: async () => true,
|
||
|
|
});
|
||
|
|
await cmd.parseAsync(
|
||
|
|
['mcp', 'https://mcp.example.com/projects/foo/mcp', '--tool', 'echo', '--args', 'not-json'],
|
||
|
|
{ from: 'user' },
|
||
|
|
);
|
||
|
|
expect(process.exitCode).toBe(1);
|
||
|
|
expect(output.join('\n')).toContain('must be valid JSON');
|
||
|
|
});
|
||
|
|
});
|