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:
@@ -2,7 +2,8 @@ import http from 'node:http';
|
||||
|
||||
export interface ApiClientOptions {
|
||||
baseUrl: string;
|
||||
timeout?: number;
|
||||
timeout?: number | undefined;
|
||||
token?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
@@ -20,16 +21,20 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function request<T>(method: string, url: string, timeout: number, body?: unknown): Promise<ApiResponse<T>> {
|
||||
function request<T>(method: string, url: string, timeout: number, body?: unknown, token?: string): Promise<ApiResponse<T>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method,
|
||||
timeout,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
};
|
||||
|
||||
const req = http.request(opts, (res) => {
|
||||
@@ -64,28 +69,30 @@ function request<T>(method: string, url: string, timeout: number, body?: unknown
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
private token?: string | undefined;
|
||||
|
||||
constructor(opts: ApiClientOptions) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||
this.timeout = opts.timeout ?? 10000;
|
||||
this.token = opts.token;
|
||||
}
|
||||
|
||||
async get<T = unknown>(path: string): Promise<T> {
|
||||
const res = await request<T>('GET', `${this.baseUrl}${path}`, this.timeout);
|
||||
const res = await request<T>('GET', `${this.baseUrl}${path}`, this.timeout, undefined, this.token);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async post<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await request<T>('POST', `${this.baseUrl}${path}`, this.timeout, body);
|
||||
const res = await request<T>('POST', `${this.baseUrl}${path}`, this.timeout, body, this.token);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async put<T = unknown>(path: string, body?: unknown): Promise<T> {
|
||||
const res = await request<T>('PUT', `${this.baseUrl}${path}`, this.timeout, body);
|
||||
const res = await request<T>('PUT', `${this.baseUrl}${path}`, this.timeout, body, this.token);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
await request('DELETE', `${this.baseUrl}${path}`, this.timeout);
|
||||
await request('DELETE', `${this.baseUrl}${path}`, this.timeout, undefined, this.token);
|
||||
}
|
||||
}
|
||||
|
||||
50
src/cli/src/auth/credentials.ts
Normal file
50
src/cli/src/auth/credentials.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export interface StoredCredentials {
|
||||
token: string;
|
||||
mcpdUrl: string;
|
||||
user: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface CredentialsDeps {
|
||||
configDir: string;
|
||||
}
|
||||
|
||||
function defaultConfigDir(): string {
|
||||
return join(homedir(), '.mcpctl');
|
||||
}
|
||||
|
||||
function credentialsPath(deps?: Partial<CredentialsDeps>): string {
|
||||
return join(deps?.configDir ?? defaultConfigDir(), 'credentials');
|
||||
}
|
||||
|
||||
export function saveCredentials(creds: StoredCredentials, deps?: Partial<CredentialsDeps>): void {
|
||||
const dir = deps?.configDir ?? defaultConfigDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const path = credentialsPath(deps);
|
||||
writeFileSync(path, JSON.stringify(creds, null, 2) + '\n', 'utf-8');
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
|
||||
export function loadCredentials(deps?: Partial<CredentialsDeps>): StoredCredentials | null {
|
||||
const path = credentialsPath(deps);
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
const raw = readFileSync(path, 'utf-8');
|
||||
return JSON.parse(raw) as StoredCredentials;
|
||||
}
|
||||
|
||||
export function deleteCredentials(deps?: Partial<CredentialsDeps>): boolean {
|
||||
const path = credentialsPath(deps);
|
||||
if (!existsSync(path)) {
|
||||
return false;
|
||||
}
|
||||
unlinkSync(path);
|
||||
return true;
|
||||
}
|
||||
2
src/cli/src/auth/index.ts
Normal file
2
src/cli/src/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { saveCredentials, loadCredentials, deleteCredentials } from './credentials.js';
|
||||
export type { StoredCredentials, CredentialsDeps } from './credentials.js';
|
||||
148
src/cli/src/commands/auth.ts
Normal file
148
src/cli/src/commands/auth.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Command } from 'commander';
|
||||
import http from 'node:http';
|
||||
import { loadConfig } from '../config/index.js';
|
||||
import type { ConfigLoaderDeps } from '../config/index.js';
|
||||
import { saveCredentials, loadCredentials, deleteCredentials } from '../auth/index.js';
|
||||
import type { CredentialsDeps } from '../auth/index.js';
|
||||
|
||||
export interface PromptDeps {
|
||||
input(message: string): Promise<string>;
|
||||
password(message: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface AuthCommandDeps {
|
||||
configDeps: Partial<ConfigLoaderDeps>;
|
||||
credentialsDeps: Partial<CredentialsDeps>;
|
||||
prompt: PromptDeps;
|
||||
log: (...args: string[]) => void;
|
||||
loginRequest: (mcpdUrl: string, email: string, password: string) => Promise<LoginResponse>;
|
||||
logoutRequest: (mcpdUrl: string, token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string;
|
||||
user: { email: string };
|
||||
}
|
||||
|
||||
function defaultLoginRequest(mcpdUrl: string, email: string, password: string): Promise<LoginResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL('/api/v1/auth/login', mcpdUrl);
|
||||
const body = JSON.stringify({ email, password });
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
||||
};
|
||||
const req = http.request(opts, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
if (res.statusCode === 401) {
|
||||
reject(new Error('Invalid credentials'));
|
||||
return;
|
||||
}
|
||||
if ((res.statusCode ?? 0) >= 400) {
|
||||
reject(new Error(`Login failed (${res.statusCode}): ${raw}`));
|
||||
return;
|
||||
}
|
||||
resolve(JSON.parse(raw) as LoginResponse);
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`)));
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('Login request timed out')); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function defaultLogoutRequest(mcpdUrl: string, token: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const url = new URL('/api/v1/auth/logout', mcpdUrl);
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
timeout: 10000,
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
};
|
||||
const req = http.request(opts, (res) => {
|
||||
res.resume();
|
||||
res.on('end', () => resolve());
|
||||
});
|
||||
req.on('error', () => resolve()); // Don't fail logout on network errors
|
||||
req.on('timeout', () => { req.destroy(); resolve(); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultInput(message: string): Promise<string> {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
|
||||
return answer as string;
|
||||
}
|
||||
|
||||
async function defaultPassword(message: string): Promise<string> {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]);
|
||||
return answer as string;
|
||||
}
|
||||
|
||||
const defaultDeps: AuthCommandDeps = {
|
||||
configDeps: {},
|
||||
credentialsDeps: {},
|
||||
prompt: { input: defaultInput, password: defaultPassword },
|
||||
log: (...args) => console.log(...args),
|
||||
loginRequest: defaultLoginRequest,
|
||||
logoutRequest: defaultLogoutRequest,
|
||||
};
|
||||
|
||||
export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
|
||||
const { configDeps, credentialsDeps, prompt, log, loginRequest } = { ...defaultDeps, ...deps };
|
||||
|
||||
return new Command('login')
|
||||
.description('Authenticate with mcpd')
|
||||
.option('--mcpd-url <url>', 'mcpd URL to authenticate against')
|
||||
.action(async (opts: { mcpdUrl?: string }) => {
|
||||
const config = loadConfig(configDeps);
|
||||
const mcpdUrl = opts.mcpdUrl ?? config.mcpdUrl;
|
||||
|
||||
const email = await prompt.input('Email:');
|
||||
const password = await prompt.password('Password:');
|
||||
|
||||
try {
|
||||
const result = await loginRequest(mcpdUrl, email, password);
|
||||
saveCredentials({
|
||||
token: result.token,
|
||||
mcpdUrl,
|
||||
user: result.user.email,
|
||||
}, credentialsDeps);
|
||||
log(`Logged in as ${result.user.email}`);
|
||||
} catch (err) {
|
||||
log(`Login failed: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createLogoutCommand(deps?: Partial<AuthCommandDeps>): Command {
|
||||
const { credentialsDeps, log, logoutRequest } = { ...defaultDeps, ...deps };
|
||||
|
||||
return new Command('logout')
|
||||
.description('Log out and remove stored credentials')
|
||||
.action(async () => {
|
||||
const creds = loadCredentials(credentialsDeps);
|
||||
if (!creds) {
|
||||
log('Not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
await logoutRequest(creds.mcpdUrl, creds.token);
|
||||
deleteCredentials(credentialsDeps);
|
||||
log('Logged out successfully');
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command
|
||||
updates[key] = parseInt(value, 10);
|
||||
} else if (key === 'registries') {
|
||||
updates[key] = value.split(',').map((s) => s.trim());
|
||||
} else if (key === 'daemonUrl') {
|
||||
// Backward compat: map daemonUrl to mcplocalUrl
|
||||
updates['mcplocalUrl'] = value;
|
||||
} else {
|
||||
updates[key] = value;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ import { Command } from 'commander';
|
||||
import http from 'node:http';
|
||||
import { loadConfig } from '../config/index.js';
|
||||
import type { ConfigLoaderDeps } from '../config/index.js';
|
||||
import { loadCredentials } from '../auth/index.js';
|
||||
import type { CredentialsDeps } from '../auth/index.js';
|
||||
import { formatJson, formatYaml } from '../formatters/index.js';
|
||||
import { APP_VERSION } from '@mcpctl/shared';
|
||||
|
||||
export interface StatusCommandDeps {
|
||||
configDeps: Partial<ConfigLoaderDeps>;
|
||||
credentialsDeps: Partial<CredentialsDeps>;
|
||||
log: (...args: string[]) => void;
|
||||
checkDaemon: (url: string) => Promise<boolean>;
|
||||
checkHealth: (url: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function defaultCheckDaemon(url: string): Promise<boolean> {
|
||||
function defaultCheckHealth(url: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`${url}/health`, { timeout: 3000 }, (res) => {
|
||||
resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400);
|
||||
@@ -27,24 +30,33 @@ function defaultCheckDaemon(url: string): Promise<boolean> {
|
||||
|
||||
const defaultDeps: StatusCommandDeps = {
|
||||
configDeps: {},
|
||||
credentialsDeps: {},
|
||||
log: (...args) => console.log(...args),
|
||||
checkDaemon: defaultCheckDaemon,
|
||||
checkHealth: defaultCheckHealth,
|
||||
};
|
||||
|
||||
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
|
||||
const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps };
|
||||
const { configDeps, credentialsDeps, log, checkHealth } = { ...defaultDeps, ...deps };
|
||||
|
||||
return new Command('status')
|
||||
.description('Show mcpctl status and connectivity')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.action(async (opts: { output: string }) => {
|
||||
const config = loadConfig(configDeps);
|
||||
const daemonReachable = await checkDaemon(config.daemonUrl);
|
||||
const creds = loadCredentials(credentialsDeps);
|
||||
|
||||
const [mcplocalReachable, mcpdReachable] = await Promise.all([
|
||||
checkHealth(config.mcplocalUrl),
|
||||
checkHealth(config.mcpdUrl),
|
||||
]);
|
||||
|
||||
const status = {
|
||||
version: APP_VERSION,
|
||||
daemonUrl: config.daemonUrl,
|
||||
daemonReachable,
|
||||
mcplocalUrl: config.mcplocalUrl,
|
||||
mcplocalReachable,
|
||||
mcpdUrl: config.mcpdUrl,
|
||||
mcpdReachable,
|
||||
auth: creds ? { user: creds.user } : null,
|
||||
registries: config.registries,
|
||||
outputFormat: config.outputFormat,
|
||||
};
|
||||
@@ -55,7 +67,9 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
||||
log(formatYaml(status));
|
||||
} else {
|
||||
log(`mcpctl v${status.version}`);
|
||||
log(`Daemon: ${status.daemonUrl} (${daemonReachable ? 'connected' : 'unreachable'})`);
|
||||
log(`mcplocal: ${status.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`);
|
||||
log(`mcpd: ${status.mcpdUrl} (${mcpdReachable ? 'connected' : 'unreachable'})`);
|
||||
log(`Auth: ${creds ? `logged in as ${creds.user}` : 'not logged in'}`);
|
||||
log(`Registries: ${status.registries.join(', ')}`);
|
||||
log(`Output: ${status.outputFormat}`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const McpctlConfigSchema = z.object({
|
||||
/** mcpd daemon endpoint */
|
||||
daemonUrl: z.string().default('http://localhost:3000'),
|
||||
/** mcplocal daemon endpoint (local LLM pre-processing proxy) */
|
||||
mcplocalUrl: z.string().default('http://localhost:3200'),
|
||||
/** mcpd daemon endpoint (remote instance manager) */
|
||||
mcpdUrl: z.string().default('http://localhost:3100'),
|
||||
/** @deprecated Use mcplocalUrl instead. Kept for backward compatibility. */
|
||||
daemonUrl: z.string().optional(),
|
||||
/** Active registries for search */
|
||||
registries: z.array(z.enum(['official', 'glama', 'smithery'])).default(['official', 'glama', 'smithery']),
|
||||
/** Cache TTL in milliseconds */
|
||||
@@ -15,6 +19,13 @@ export const McpctlConfigSchema = z.object({
|
||||
outputFormat: z.enum(['table', 'json', 'yaml']).default('table'),
|
||||
/** Smithery API key */
|
||||
smitheryApiKey: z.string().optional(),
|
||||
}).transform((cfg) => {
|
||||
// Backward compatibility: if old daemonUrl is set but mcplocalUrl wasn't explicitly changed,
|
||||
// use daemonUrl as mcplocalUrl
|
||||
if (cfg.daemonUrl && cfg.mcplocalUrl === 'http://localhost:3200') {
|
||||
return { ...cfg, mcplocalUrl: cfg.daemonUrl };
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
|
||||
export type McpctlConfig = z.infer<typeof McpctlConfigSchema>;
|
||||
|
||||
@@ -11,8 +11,10 @@ import { createSetupCommand } from './commands/setup.js';
|
||||
import { createClaudeCommand } from './commands/claude.js';
|
||||
import { createProjectCommand } from './commands/project.js';
|
||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||
import { ApiClient } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
|
||||
export function createProgram(): Command {
|
||||
const program = new Command()
|
||||
@@ -20,15 +22,28 @@ export function createProgram(): Command {
|
||||
.description('Manage MCP servers like kubectl manages containers')
|
||||
.version(APP_VERSION, '-v, --version')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.option('--daemon-url <url>', 'mcpd daemon URL');
|
||||
.option('--daemon-url <url>', 'mcplocal daemon URL')
|
||||
.option('--direct', 'bypass mcplocal and connect directly to mcpd');
|
||||
|
||||
program.addCommand(createConfigCommand());
|
||||
program.addCommand(createStatusCommand());
|
||||
program.addCommand(createLoginCommand());
|
||||
program.addCommand(createLogoutCommand());
|
||||
|
||||
// Create API-backed commands
|
||||
// Resolve target URL: --direct goes to mcpd, default goes to mcplocal
|
||||
const config = loadConfig();
|
||||
const daemonUrl = program.opts().daemonUrl ?? config.daemonUrl;
|
||||
const client = new ApiClient({ baseUrl: daemonUrl });
|
||||
const creds = loadCredentials();
|
||||
const opts = program.opts();
|
||||
let baseUrl: string;
|
||||
if (opts.daemonUrl) {
|
||||
baseUrl = opts.daemonUrl as string;
|
||||
} else if (opts.direct) {
|
||||
baseUrl = config.mcpdUrl;
|
||||
} else {
|
||||
baseUrl = config.mcplocalUrl;
|
||||
}
|
||||
|
||||
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
|
||||
|
||||
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => {
|
||||
if (id) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
59
src/cli/tests/auth/credentials.test.ts
Normal file
59
src/cli/tests/auth/credentials.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user