feat: LLM provider configuration, secret store, and setup wizard

Add secure credential storage (GNOME Keyring + file fallback),
LLM provider config in ~/.mcpctl/config.json, interactive setup
wizard (mcpctl config setup), and wire configured provider into
mcplocal for smart pagination summaries.

- Secret store: SecretStore interface, GnomeKeyringStore, FileSecretStore
- Config schema: LlmConfigSchema with provider/model/url/binaryPath
- Setup wizard: arrow-key provider/model selection, dynamic model fetch
- Provider factory: creates ProviderRegistry from config + secrets
- Status: shows LLM line with hint when not configured
- 572 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-24 22:48:17 +00:00
parent d6e4951a69
commit 5bc39c988c
22 changed files with 1448 additions and 9 deletions

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { loadLlmConfig } from '../../src/http/config.js';
import { existsSync, readFileSync } from 'node:fs';
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('loadLlmConfig', () => {
it('returns undefined when config file does not exist', () => {
vi.mocked(existsSync).mockReturnValue(false);
expect(loadLlmConfig()).toBeUndefined();
});
it('returns undefined when config has no llm section', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ mcplocalUrl: 'http://localhost:3200' }));
expect(loadLlmConfig()).toBeUndefined();
});
it('returns undefined when provider is none', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ llm: { provider: 'none' } }));
expect(loadLlmConfig()).toBeUndefined();
});
it('returns LLM config when provider is configured', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
llm: { provider: 'anthropic', model: 'claude-haiku-3-5-20241022' },
}));
const result = loadLlmConfig();
expect(result).toEqual({ provider: 'anthropic', model: 'claude-haiku-3-5-20241022' });
});
it('returns full LLM config with all fields', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
llm: { provider: 'vllm', model: 'my-model', url: 'http://gpu:8000' },
}));
const result = loadLlmConfig();
expect(result).toEqual({ provider: 'vllm', model: 'my-model', url: 'http://gpu:8000' });
});
it('returns undefined on malformed JSON', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue('NOT JSON!!!');
expect(loadLlmConfig()).toBeUndefined();
});
it('returns undefined on read error', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockImplementation(() => { throw new Error('EACCES'); });
expect(loadLlmConfig()).toBeUndefined();
});
});

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi } from 'vitest';
import { createProviderFromConfig } from '../src/llm-config.js';
import type { SecretStore } from '@mcpctl/shared';
function mockSecretStore(secrets: Record<string, string> = {}): SecretStore {
return {
get: vi.fn(async (key: string) => secrets[key] ?? null),
set: vi.fn(async () => {}),
delete: vi.fn(async () => true),
backend: () => 'mock',
};
}
describe('createProviderFromConfig', () => {
it('returns empty registry for undefined config', async () => {
const store = mockSecretStore();
const registry = await createProviderFromConfig(undefined, store);
expect(registry.getActive()).toBeNull();
expect(registry.list()).toEqual([]);
});
it('returns empty registry for provider=none', async () => {
const store = mockSecretStore();
const registry = await createProviderFromConfig({ provider: 'none' }, store);
expect(registry.getActive()).toBeNull();
});
it('creates gemini-cli provider', async () => {
const store = mockSecretStore();
const registry = await createProviderFromConfig(
{ provider: 'gemini-cli', model: 'gemini-2.5-flash', binaryPath: '/usr/bin/gemini' },
store,
);
expect(registry.getActive()).not.toBeNull();
expect(registry.getActive()!.name).toBe('gemini-cli');
});
it('creates ollama provider', async () => {
const store = mockSecretStore();
const registry = await createProviderFromConfig(
{ provider: 'ollama', model: 'llama3.2', url: 'http://localhost:11434' },
store,
);
expect(registry.getActive()!.name).toBe('ollama');
});
it('creates anthropic provider with API key from secret store', async () => {
const store = mockSecretStore({ 'anthropic-api-key': 'sk-ant-test' });
const registry = await createProviderFromConfig(
{ provider: 'anthropic', model: 'claude-haiku-3-5-20241022' },
store,
);
expect(registry.getActive()!.name).toBe('anthropic');
expect(store.get).toHaveBeenCalledWith('anthropic-api-key');
});
it('returns empty registry when anthropic API key is missing', async () => {
const store = mockSecretStore();
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const registry = await createProviderFromConfig(
{ provider: 'anthropic', model: 'claude-haiku-3-5-20241022' },
store,
);
expect(registry.getActive()).toBeNull();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('Anthropic API key not found'));
stderrSpy.mockRestore();
});
it('creates openai provider with API key from secret store', async () => {
const store = mockSecretStore({ 'openai-api-key': 'sk-test' });
const registry = await createProviderFromConfig(
{ provider: 'openai', model: 'gpt-4o', url: 'https://api.openai.com' },
store,
);
expect(registry.getActive()!.name).toBe('openai');
expect(store.get).toHaveBeenCalledWith('openai-api-key');
});
it('returns empty registry when openai API key is missing', async () => {
const store = mockSecretStore();
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const registry = await createProviderFromConfig(
{ provider: 'openai' },
store,
);
expect(registry.getActive()).toBeNull();
stderrSpy.mockRestore();
});
it('creates deepseek provider with API key from secret store', async () => {
const store = mockSecretStore({ 'deepseek-api-key': 'sk-ds-test' });
const registry = await createProviderFromConfig(
{ provider: 'deepseek', model: 'deepseek-chat' },
store,
);
expect(registry.getActive()!.name).toBe('deepseek');
expect(store.get).toHaveBeenCalledWith('deepseek-api-key');
});
it('returns empty registry when deepseek API key is missing', async () => {
const store = mockSecretStore();
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const registry = await createProviderFromConfig(
{ provider: 'deepseek' },
store,
);
expect(registry.getActive()).toBeNull();
stderrSpy.mockRestore();
});
it('creates vllm provider using OpenAI provider', async () => {
const store = mockSecretStore();
const registry = await createProviderFromConfig(
{ provider: 'vllm', model: 'my-model', url: 'http://gpu-server:8000' },
store,
);
// vLLM reuses OpenAI provider under the hood
expect(registry.getActive()).not.toBeNull();
expect(registry.getActive()!.name).toBe('openai');
});
it('returns empty registry when vllm URL is missing', async () => {
const store = mockSecretStore();
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const registry = await createProviderFromConfig(
{ provider: 'vllm' },
store,
);
expect(registry.getActive()).toBeNull();
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('vLLM URL not configured'));
stderrSpy.mockRestore();
});
});