feat: implement v2 3-tier architecture (mcpctl → mcplocal → mcpd)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

- 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:
Michal
2026-02-22 11:42:06 +00:00
parent a4fe5fdbe2
commit b8c5cf718a
82 changed files with 5832 additions and 123 deletions

View File

@@ -74,4 +74,27 @@ describe('ApiClient', () => {
const client = new ApiClient({ baseUrl: 'http://localhost:1' });
await expect(client.get('/anything')).rejects.toThrow();
});
it('sends Authorization header when token provided', async () => {
// We need a separate server to check the header
let receivedAuth = '';
const authServer = http.createServer((req, res) => {
receivedAuth = req.headers['authorization'] ?? '';
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
const authPort = await new Promise<number>((resolve) => {
authServer.listen(0, () => {
const addr = authServer.address();
if (addr && typeof addr === 'object') resolve(addr.port);
});
});
try {
const client = new ApiClient({ baseUrl: `http://localhost:${authPort}`, token: 'my-token' });
await client.get('/test');
expect(receivedAuth).toBe('Bearer my-token');
} finally {
authServer.close();
}
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { saveCredentials, loadCredentials, deleteCredentials } from '../../src/auth/index.js';
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-auth-test-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('saveCredentials', () => {
it('saves credentials file', () => {
saveCredentials({ token: 'tok123', mcpdUrl: 'http://x:3100', user: 'alice@test.com' }, { configDir: tempDir });
expect(existsSync(join(tempDir, 'credentials'))).toBe(true);
});
it('sets 0600 permissions', () => {
saveCredentials({ token: 'tok123', mcpdUrl: 'http://x:3100', user: 'alice@test.com' }, { configDir: tempDir });
const stat = statSync(join(tempDir, 'credentials'));
expect(stat.mode & 0o777).toBe(0o600);
});
it('creates config dir if missing', () => {
const nested = join(tempDir, 'sub', 'dir');
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'bob' }, { configDir: nested });
expect(existsSync(join(nested, 'credentials'))).toBe(true);
});
});
describe('loadCredentials', () => {
it('returns null when no credentials file', () => {
expect(loadCredentials({ configDir: tempDir })).toBeNull();
});
it('round-trips credentials', () => {
const creds = { token: 'tok456', mcpdUrl: 'http://remote:3100', user: 'charlie@test.com', expiresAt: '2099-01-01' };
saveCredentials(creds, { configDir: tempDir });
const loaded = loadCredentials({ configDir: tempDir });
expect(loaded).toEqual(creds);
});
});
describe('deleteCredentials', () => {
it('returns false when no credentials file', () => {
expect(deleteCredentials({ configDir: tempDir })).toBe(false);
});
it('deletes credentials file', () => {
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'u' }, { configDir: tempDir });
expect(deleteCredentials({ configDir: tempDir })).toBe(true);
expect(existsSync(join(tempDir, 'credentials'))).toBe(false);
});
});

View 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');
});
});

View File

@@ -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);
});
});

View File

@@ -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');

View File

@@ -28,18 +28,25 @@ describe('loadConfig', () => {
});
it('loads config from file', () => {
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5000' }, { configDir: tempDir });
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://custom:5000' }, { configDir: tempDir });
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://custom:5000');
expect(config.mcplocalUrl).toBe('http://custom:5000');
});
it('applies defaults for missing fields', () => {
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
writeFileSync(join(tempDir, 'config.json'), '{"daemonUrl":"http://x:1"}');
writeFileSync(join(tempDir, 'config.json'), '{"mcplocalUrl":"http://x:1"}');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://x:1');
expect(config.mcplocalUrl).toBe('http://x:1');
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
});
it('backward compat: daemonUrl maps to mcplocalUrl', () => {
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
writeFileSync(join(tempDir, 'config.json'), '{"daemonUrl":"http://old:3000"}');
const config = loadConfig({ configDir: tempDir });
expect(config.mcplocalUrl).toBe('http://old:3000');
});
});
describe('saveConfig', () => {
@@ -57,7 +64,7 @@ describe('saveConfig', () => {
it('round-trips configuration', () => {
const custom = {
...DEFAULT_CONFIG,
daemonUrl: 'http://custom:9000',
mcplocalUrl: 'http://custom:9000',
registries: ['official' as const],
outputFormat: 'json' as const,
};
@@ -70,14 +77,14 @@ describe('saveConfig', () => {
describe('mergeConfig', () => {
it('merges overrides into existing config', () => {
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
const merged = mergeConfig({ daemonUrl: 'http://new:1234' }, { configDir: tempDir });
expect(merged.daemonUrl).toBe('http://new:1234');
const merged = mergeConfig({ mcplocalUrl: 'http://new:1234' }, { configDir: tempDir });
expect(merged.mcplocalUrl).toBe('http://new:1234');
expect(merged.registries).toEqual(DEFAULT_CONFIG.registries);
});
it('works when no config file exists', () => {
const merged = mergeConfig({ outputFormat: 'yaml' }, { configDir: tempDir });
expect(merged.outputFormat).toBe('yaml');
expect(merged.daemonUrl).toBe('http://localhost:3000');
expect(merged.mcplocalUrl).toBe('http://localhost:3200');
});
});

View File

@@ -4,7 +4,8 @@ import { McpctlConfigSchema, DEFAULT_CONFIG } from '../../src/config/schema.js';
describe('McpctlConfigSchema', () => {
it('provides sensible defaults from empty object', () => {
const config = McpctlConfigSchema.parse({});
expect(config.daemonUrl).toBe('http://localhost:3000');
expect(config.mcplocalUrl).toBe('http://localhost:3200');
expect(config.mcpdUrl).toBe('http://localhost:3100');
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
expect(config.cacheTTLMs).toBe(3_600_000);
expect(config.outputFormat).toBe('table');
@@ -15,7 +16,8 @@ describe('McpctlConfigSchema', () => {
it('validates a full config', () => {
const config = McpctlConfigSchema.parse({
daemonUrl: 'http://custom:4000',
mcplocalUrl: 'http://local:3200',
mcpdUrl: 'http://custom:4000',
registries: ['official'],
cacheTTLMs: 60_000,
httpProxy: 'http://proxy:8080',
@@ -23,11 +25,26 @@ describe('McpctlConfigSchema', () => {
outputFormat: 'json',
smitheryApiKey: 'sk-test',
});
expect(config.daemonUrl).toBe('http://custom:4000');
expect(config.mcplocalUrl).toBe('http://local:3200');
expect(config.mcpdUrl).toBe('http://custom:4000');
expect(config.registries).toEqual(['official']);
expect(config.outputFormat).toBe('json');
});
it('backward compat: maps daemonUrl to mcplocalUrl', () => {
const config = McpctlConfigSchema.parse({ daemonUrl: 'http://legacy:3000' });
expect(config.mcplocalUrl).toBe('http://legacy:3000');
expect(config.mcpdUrl).toBe('http://localhost:3100');
});
it('mcplocalUrl takes precedence over daemonUrl', () => {
const config = McpctlConfigSchema.parse({
daemonUrl: 'http://legacy:3000',
mcplocalUrl: 'http://explicit:3200',
});
expect(config.mcplocalUrl).toBe('http://explicit:3200');
});
it('rejects invalid registry names', () => {
expect(() => McpctlConfigSchema.parse({ registries: ['invalid'] })).toThrow();
});

View File

@@ -12,6 +12,8 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('config');
expect(commandNames).toContain('status');
expect(commandNames).toContain('login');
expect(commandNames).toContain('logout');
expect(commandNames).toContain('get');
expect(commandNames).toContain('describe');
expect(commandNames).toContain('instance');