feat: implement v2 3-tier architecture (mcpctl → mcplocal → mcpd)
- Rename local-proxy to mcplocal with HTTP server, LLM pipeline, mcpd discovery - Add LLM pre-processing: token estimation, filter cache, metrics, Gemini CLI + DeepSeek providers - Add mcpd auth (login/logout) and MCP proxy endpoints - Update CLI: dual URLs (mcplocalUrl/mcpdUrl), auth commands, --direct flag - Add tiered health monitoring, shell completions, e2e integration tests - 57 test files, 597 tests passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
144
src/cli/tests/commands/auth.test.ts
Normal file
144
src/cli/tests/commands/auth.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createLoginCommand, createLogoutCommand } from '../../src/commands/auth.js';
|
||||
import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
|
||||
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
|
||||
|
||||
let tempDir: string;
|
||||
let output: string[];
|
||||
|
||||
function log(...args: string[]) {
|
||||
output.push(args.join(' '));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-auth-cmd-test-'));
|
||||
output = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('login command', () => {
|
||||
it('stores credentials on successful login', async () => {
|
||||
const cmd = createLoginCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
prompt: {
|
||||
input: async () => 'alice@test.com',
|
||||
password: async () => 'secret123',
|
||||
},
|
||||
log,
|
||||
loginRequest: async (_url, email, _password) => ({
|
||||
token: 'session-token-123',
|
||||
user: { email },
|
||||
}),
|
||||
logoutRequest: async () => {},
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output[0]).toContain('Logged in as alice@test.com');
|
||||
|
||||
const creds = loadCredentials({ configDir: tempDir });
|
||||
expect(creds).not.toBeNull();
|
||||
expect(creds!.token).toBe('session-token-123');
|
||||
expect(creds!.user).toBe('alice@test.com');
|
||||
});
|
||||
|
||||
it('shows error on failed login', async () => {
|
||||
const cmd = createLoginCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
prompt: {
|
||||
input: async () => 'alice@test.com',
|
||||
password: async () => 'wrong',
|
||||
},
|
||||
log,
|
||||
loginRequest: async () => { throw new Error('Invalid credentials'); },
|
||||
logoutRequest: async () => {},
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output[0]).toContain('Login failed');
|
||||
expect(output[0]).toContain('Invalid credentials');
|
||||
|
||||
const creds = loadCredentials({ configDir: tempDir });
|
||||
expect(creds).toBeNull();
|
||||
});
|
||||
|
||||
it('uses mcpdUrl from config', async () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, mcpdUrl: 'http://custom:3100' }, { configDir: tempDir });
|
||||
let capturedUrl = '';
|
||||
const cmd = createLoginCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
prompt: {
|
||||
input: async () => 'user@test.com',
|
||||
password: async () => 'pass',
|
||||
},
|
||||
log,
|
||||
loginRequest: async (url, email) => {
|
||||
capturedUrl = url;
|
||||
return { token: 'tok', user: { email } };
|
||||
},
|
||||
logoutRequest: async () => {},
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(capturedUrl).toBe('http://custom:3100');
|
||||
});
|
||||
|
||||
it('allows --mcpd-url flag override', async () => {
|
||||
let capturedUrl = '';
|
||||
const cmd = createLoginCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
prompt: {
|
||||
input: async () => 'user@test.com',
|
||||
password: async () => 'pass',
|
||||
},
|
||||
log,
|
||||
loginRequest: async (url, email) => {
|
||||
capturedUrl = url;
|
||||
return { token: 'tok', user: { email } };
|
||||
},
|
||||
logoutRequest: async () => {},
|
||||
});
|
||||
await cmd.parseAsync(['--mcpd-url', 'http://override:3100'], { from: 'user' });
|
||||
expect(capturedUrl).toBe('http://override:3100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout command', () => {
|
||||
it('removes credentials on logout', async () => {
|
||||
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice' }, { configDir: tempDir });
|
||||
let logoutCalled = false;
|
||||
const cmd = createLogoutCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
prompt: { input: async () => '', password: async () => '' },
|
||||
log,
|
||||
loginRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
logoutRequest: async () => { logoutCalled = true; },
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output[0]).toContain('Logged out successfully');
|
||||
expect(logoutCalled).toBe(true);
|
||||
|
||||
const creds = loadCredentials({ configDir: tempDir });
|
||||
expect(creds).toBeNull();
|
||||
});
|
||||
|
||||
it('shows not logged in when no credentials', async () => {
|
||||
const cmd = createLogoutCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
prompt: { input: async () => '', password: async () => '' },
|
||||
log,
|
||||
loginRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
logoutRequest: async () => {},
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output[0]).toContain('Not logged in');
|
||||
});
|
||||
});
|
||||
@@ -34,23 +34,38 @@ describe('config view', () => {
|
||||
await cmd.parseAsync(['view'], { from: 'user' });
|
||||
expect(output).toHaveLength(1);
|
||||
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
||||
expect(parsed['daemonUrl']).toBe('http://localhost:3000');
|
||||
expect(parsed['mcplocalUrl']).toBe('http://localhost:3200');
|
||||
expect(parsed['mcpdUrl']).toBe('http://localhost:3100');
|
||||
});
|
||||
|
||||
it('outputs config as YAML with --output yaml', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['view', '-o', 'yaml'], { from: 'user' });
|
||||
expect(output[0]).toContain('daemonUrl:');
|
||||
expect(output[0]).toContain('mcplocalUrl:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config set', () => {
|
||||
it('sets a string value', async () => {
|
||||
it('sets mcplocalUrl', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['set', 'daemonUrl', 'http://new:9000'], { from: 'user' });
|
||||
expect(output[0]).toContain('daemonUrl');
|
||||
await cmd.parseAsync(['set', 'mcplocalUrl', 'http://new:9000'], { from: 'user' });
|
||||
expect(output[0]).toContain('mcplocalUrl');
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.daemonUrl).toBe('http://new:9000');
|
||||
expect(config.mcplocalUrl).toBe('http://new:9000');
|
||||
});
|
||||
|
||||
it('sets mcpdUrl', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['set', 'mcpdUrl', 'http://remote:3100'], { from: 'user' });
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.mcpdUrl).toBe('http://remote:3100');
|
||||
});
|
||||
|
||||
it('maps daemonUrl to mcplocalUrl for backward compat', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['set', 'daemonUrl', 'http://legacy:3000'], { from: 'user' });
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.mcplocalUrl).toBe('http://legacy:3000');
|
||||
});
|
||||
|
||||
it('sets cacheTTLMs as integer', async () => {
|
||||
@@ -87,13 +102,13 @@ describe('config path', () => {
|
||||
describe('config reset', () => {
|
||||
it('resets to defaults', async () => {
|
||||
// First set a custom value
|
||||
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom' }, { configDir: tempDir });
|
||||
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://custom' }, { configDir: tempDir });
|
||||
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['reset'], { from: 'user' });
|
||||
expect(output[0]).toContain('reset');
|
||||
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.daemonUrl).toBe(DEFAULT_CONFIG.daemonUrl);
|
||||
expect(config.mcplocalUrl).toBe(DEFAULT_CONFIG.mcplocalUrl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createStatusCommand } from '../../src/commands/status.js';
|
||||
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
|
||||
import { saveCredentials } from '../../src/auth/index.js';
|
||||
|
||||
let tempDir: string;
|
||||
let output: string[];
|
||||
@@ -25,67 +26,101 @@ describe('status command', () => {
|
||||
it('shows status in table format', async () => {
|
||||
const cmd = createStatusCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
log,
|
||||
checkDaemon: async () => true,
|
||||
checkHealth: async () => true,
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('mcpctl v');
|
||||
expect(output.join('\n')).toContain('connected');
|
||||
const out = output.join('\n');
|
||||
expect(out).toContain('mcpctl v');
|
||||
expect(out).toContain('mcplocal:');
|
||||
expect(out).toContain('mcpd:');
|
||||
expect(out).toContain('connected');
|
||||
});
|
||||
|
||||
it('shows unreachable when daemon is down', async () => {
|
||||
it('shows unreachable when daemons are down', async () => {
|
||||
const cmd = createStatusCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
log,
|
||||
checkDaemon: async () => false,
|
||||
checkHealth: async () => false,
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('unreachable');
|
||||
});
|
||||
|
||||
it('shows not logged in when no credentials', async () => {
|
||||
const cmd = createStatusCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
log,
|
||||
checkHealth: async () => true,
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('not logged in');
|
||||
});
|
||||
|
||||
it('shows logged in user when credentials exist', async () => {
|
||||
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice@example.com' }, { configDir: tempDir });
|
||||
const cmd = createStatusCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
log,
|
||||
checkHealth: async () => true,
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('logged in as alice@example.com');
|
||||
});
|
||||
|
||||
it('shows status in JSON format', async () => {
|
||||
const cmd = createStatusCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
log,
|
||||
checkDaemon: async () => true,
|
||||
checkHealth: async () => true,
|
||||
});
|
||||
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
|
||||
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
||||
expect(parsed['version']).toBe('0.1.0');
|
||||
expect(parsed['daemonReachable']).toBe(true);
|
||||
expect(parsed['mcplocalReachable']).toBe(true);
|
||||
expect(parsed['mcpdReachable']).toBe(true);
|
||||
});
|
||||
|
||||
it('shows status in YAML format', async () => {
|
||||
const cmd = createStatusCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
log,
|
||||
checkDaemon: async () => false,
|
||||
checkHealth: async () => false,
|
||||
});
|
||||
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
|
||||
expect(output[0]).toContain('daemonReachable: false');
|
||||
expect(output[0]).toContain('mcplocalReachable: false');
|
||||
});
|
||||
|
||||
it('uses custom daemon URL from config', async () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5555' }, { configDir: tempDir });
|
||||
let checkedUrl = '';
|
||||
it('checks correct URLs from config', async () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://local:3200', mcpdUrl: 'http://remote:3100' }, { configDir: tempDir });
|
||||
const checkedUrls: string[] = [];
|
||||
const cmd = createStatusCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
log,
|
||||
checkDaemon: async (url) => {
|
||||
checkedUrl = url;
|
||||
checkHealth: async (url) => {
|
||||
checkedUrls.push(url);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(checkedUrl).toBe('http://custom:5555');
|
||||
expect(checkedUrls).toContain('http://local:3200');
|
||||
expect(checkedUrls).toContain('http://remote:3100');
|
||||
});
|
||||
|
||||
it('shows registries from config', async () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
|
||||
const cmd = createStatusCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
log,
|
||||
checkDaemon: async () => true,
|
||||
checkHealth: async () => true,
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('official');
|
||||
|
||||
Reference in New Issue
Block a user