test(config): add modular config tests and helpers
This commit is contained in:
parent
f15ab3b69e
commit
d0bb8884ad
File diff suppressed because it is too large
Load Diff
308
src/cli/config/add.ts
Normal file
308
src/cli/config/add.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { LoadConfigOptions, RawEntry } from '../../config.js';
|
||||
import { writeRawConfig } from '../../config.js';
|
||||
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
|
||||
import { expandHome } from '../../env.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { cloneConfig, loadOrCreateConfig } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
|
||||
export type AddFlags = {
|
||||
transport?: 'http' | 'sse' | 'stdio';
|
||||
url?: string;
|
||||
command?: string;
|
||||
stdio?: string;
|
||||
args: string[];
|
||||
description?: string;
|
||||
env: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
tokenCacheDir?: string;
|
||||
clientName?: string;
|
||||
oauthRedirectUrl?: string;
|
||||
auth?: string;
|
||||
copyFrom?: string;
|
||||
persistPath?: string;
|
||||
scope?: 'home' | 'project';
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export async function handleAddCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
|
||||
const name = args.shift();
|
||||
if (!name) {
|
||||
throw new CliUsageError('Usage: mcporter config add <name> [target]');
|
||||
}
|
||||
let positionalTarget: string | undefined;
|
||||
if (args[0] && !args[0].startsWith('--')) {
|
||||
positionalTarget = args.shift();
|
||||
}
|
||||
const flags = extractAddFlags(args);
|
||||
|
||||
const targetPath = resolveWriteTarget(flags, options.loadOptions, options.loadOptions.rootDir ?? process.cwd());
|
||||
const effectiveLoadOptions: LoadConfigOptions = { ...options.loadOptions, configPath: targetPath };
|
||||
|
||||
const { config, path: configPath } = await loadOrCreateConfig(effectiveLoadOptions);
|
||||
const nextConfig = cloneConfig(config);
|
||||
|
||||
const baseEntry = await resolveBaseEntry(flags.copyFrom, options.loadOptions);
|
||||
const entry: RawEntry = baseEntry ? { ...baseEntry } : {};
|
||||
|
||||
applyTargetToEntry(entry, positionalTarget, flags);
|
||||
applyFlagsToEntry(entry, flags);
|
||||
validateTransportChoice(entry, flags.transport);
|
||||
|
||||
const hasHttpTarget =
|
||||
Boolean(entry.baseUrl) ||
|
||||
Boolean(entry.base_url) ||
|
||||
Boolean(entry.url) ||
|
||||
Boolean(entry.serverUrl) ||
|
||||
Boolean(entry.server_url);
|
||||
const hasCommandTarget = Boolean(entry.command ?? entry.executable);
|
||||
|
||||
if (flags.args.length > 0 && !hasCommandTarget) {
|
||||
throw new CliUsageError('--arg requires a stdio command (use --command, --stdio, or provide a positional target).');
|
||||
}
|
||||
|
||||
if (!hasHttpTarget && !hasCommandTarget) {
|
||||
throw new CliUsageError('Server definitions require either a --url/target or a stdio command.');
|
||||
}
|
||||
|
||||
if (!nextConfig.mcpServers) {
|
||||
nextConfig.mcpServers = {};
|
||||
}
|
||||
nextConfig.mcpServers[name] = entry;
|
||||
|
||||
if (flags.dryRun) {
|
||||
console.log(JSON.stringify({ [name]: entry }, null, 2));
|
||||
console.log('(dry-run) No changes were written.');
|
||||
return;
|
||||
}
|
||||
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
console.log(`Added '${name}' to ${configPath}`);
|
||||
}
|
||||
|
||||
export function resolveWriteTarget(flags: AddFlags, loadOptions: LoadConfigOptions, rootDir: string): string {
|
||||
if (flags.persistPath) {
|
||||
return path.resolve(expandHome(flags.persistPath));
|
||||
}
|
||||
if (flags.scope === 'home') {
|
||||
return path.join(os.homedir(), '.mcporter', 'mcporter.json');
|
||||
}
|
||||
if (flags.scope === 'project') {
|
||||
return path.resolve(rootDir, 'config', 'mcporter.json');
|
||||
}
|
||||
if (loadOptions.configPath) {
|
||||
return path.resolve(expandHome(loadOptions.configPath));
|
||||
}
|
||||
return path.resolve(rootDir, 'config', 'mcporter.json');
|
||||
}
|
||||
|
||||
function extractAddFlags(args: string[]): AddFlags {
|
||||
const flags: AddFlags = { args: [], env: {}, headers: {} };
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
switch (token) {
|
||||
case '--transport':
|
||||
flags.transport = parseTransport(requireValue(args, index, token));
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--url':
|
||||
flags.url = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--command':
|
||||
flags.command = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--stdio':
|
||||
flags.stdio = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--arg':
|
||||
flags.args.push(requireValue(args, index, token));
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--description':
|
||||
flags.description = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--env':
|
||||
parseKeyValue(requireValue(args, index, token), flags.env, '--env');
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--header':
|
||||
parseKeyValue(requireValue(args, index, token), flags.headers, '--header');
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--token-cache-dir':
|
||||
flags.tokenCacheDir = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--client-name':
|
||||
flags.clientName = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--oauth-redirect-url':
|
||||
flags.oauthRedirectUrl = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--auth':
|
||||
flags.auth = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--copy-from':
|
||||
flags.copyFrom = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--persist':
|
||||
flags.persistPath = requireValue(args, index, token);
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--scope': {
|
||||
const scopeValue = requireValue(args, index, token);
|
||||
if (scopeValue !== 'home' && scopeValue !== 'project') {
|
||||
throw new CliUsageError('--scope must be either "home" or "project".');
|
||||
}
|
||||
flags.scope = scopeValue;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
case '--dry-run':
|
||||
flags.dryRun = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
case '--':
|
||||
args.splice(index, 1);
|
||||
while (index < args.length) {
|
||||
const value = args[index];
|
||||
if (value !== undefined) {
|
||||
flags.args.push(value);
|
||||
}
|
||||
args.splice(index, 1);
|
||||
}
|
||||
continue;
|
||||
default:
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function parseTransport(value: string | undefined): 'http' | 'sse' | 'stdio' {
|
||||
if (value !== 'http' && value !== 'sse' && value !== 'stdio') {
|
||||
throw new CliUsageError("--transport must be one of 'http', 'sse', or 'stdio'.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseKeyValue(input: string | undefined, target: Record<string, string>, flagName: string): void {
|
||||
if (!input || !input.includes('=')) {
|
||||
throw new CliUsageError(`${flagName} requires KEY=value.`);
|
||||
}
|
||||
const [key, ...rest] = input.split('=');
|
||||
if (!key) {
|
||||
throw new CliUsageError(`${flagName} requires KEY=value.`);
|
||||
}
|
||||
target[key] = rest.join('=');
|
||||
}
|
||||
|
||||
function requireValue(args: string[], index: number, flagName: string): string {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new CliUsageError(`Flag '${flagName}' requires a value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function resolveBaseEntry(copyFrom: string | undefined, options: LoadConfigOptions): Promise<RawEntry | null> {
|
||||
if (!copyFrom) {
|
||||
return null;
|
||||
}
|
||||
const [kind, ...rest] = copyFrom.split(':');
|
||||
const name = rest.join(':');
|
||||
if (!kind || !name) {
|
||||
throw new CliUsageError("--copy-from requires the format '<import>:<name>'.");
|
||||
}
|
||||
const rootDir = options.rootDir ?? process.cwd();
|
||||
const paths = pathsForImport(kind as never, rootDir);
|
||||
for (const candidate of paths) {
|
||||
const resolved = expandHome(candidate);
|
||||
const entries = await readExternalEntries(resolved, { projectRoot: rootDir, importKind: kind as never });
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
const entry = entries.get(name);
|
||||
if (entry) {
|
||||
return structuredClone(entry);
|
||||
}
|
||||
}
|
||||
throw new CliUsageError(`Unable to find '${name}' in import '${kind}'.`);
|
||||
}
|
||||
|
||||
function applyTargetToEntry(entry: RawEntry, positionalTarget: string | undefined, flags: AddFlags): void {
|
||||
if (flags.url) {
|
||||
entry.baseUrl = flags.url;
|
||||
return;
|
||||
}
|
||||
if (flags.command) {
|
||||
entry.command = flags.command;
|
||||
}
|
||||
if (flags.stdio) {
|
||||
entry.command = flags.stdio;
|
||||
}
|
||||
if (positionalTarget) {
|
||||
if (looksLikeHttp(positionalTarget)) {
|
||||
entry.baseUrl = positionalTarget;
|
||||
} else {
|
||||
entry.command = positionalTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyFlagsToEntry(entry: RawEntry, flags: AddFlags): void {
|
||||
if (flags.args.length > 0) {
|
||||
entry.args = flags.args;
|
||||
}
|
||||
if (flags.description) {
|
||||
entry.description = flags.description;
|
||||
}
|
||||
if (Object.keys(flags.env).length > 0) {
|
||||
entry.env = entry.env ? { ...entry.env, ...flags.env } : { ...flags.env };
|
||||
}
|
||||
if (Object.keys(flags.headers).length > 0) {
|
||||
entry.headers = entry.headers ? { ...entry.headers, ...flags.headers } : { ...flags.headers };
|
||||
}
|
||||
if (flags.tokenCacheDir) {
|
||||
entry.tokenCacheDir = flags.tokenCacheDir;
|
||||
}
|
||||
if (flags.clientName) {
|
||||
entry.clientName = flags.clientName;
|
||||
}
|
||||
if (flags.oauthRedirectUrl) {
|
||||
entry.oauthRedirectUrl = flags.oauthRedirectUrl;
|
||||
}
|
||||
if (flags.auth) {
|
||||
entry.auth = flags.auth;
|
||||
}
|
||||
}
|
||||
|
||||
function validateTransportChoice(entry: RawEntry, transport: AddFlags['transport']): void {
|
||||
if (!transport) {
|
||||
return;
|
||||
}
|
||||
const isHttp = Boolean(entry.baseUrl ?? entry.url ?? entry.serverUrl);
|
||||
const isStdio = Boolean(entry.command ?? entry.args);
|
||||
if (transport === 'stdio' && !isStdio) {
|
||||
throw new CliUsageError("Transport 'stdio' requires a stdio command.");
|
||||
}
|
||||
if ((transport === 'http' || transport === 'sse') && !isHttp) {
|
||||
throw new CliUsageError(`Transport '${transport}' requires a URL target.`);
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeHttp(value: string): boolean {
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
}
|
||||
27
src/cli/config/auth.ts
Normal file
27
src/cli/config/auth.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { loadServerDefinitions } from '../../config.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { resolveServerDefinition } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
|
||||
export async function handleLoginCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
throw new CliUsageError('Usage: mcporter config login <name|url>');
|
||||
}
|
||||
await options.invokeAuth([...args]);
|
||||
}
|
||||
|
||||
export async function handleLogoutCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
|
||||
const name = args.shift();
|
||||
if (!name) {
|
||||
throw new CliUsageError('Usage: mcporter config logout <name>');
|
||||
}
|
||||
const servers = await loadServerDefinitions(options.loadOptions);
|
||||
const target = resolveServerDefinition(name, servers);
|
||||
if (!target.tokenCacheDir) {
|
||||
console.log(`Server '${name}' does not expose a token cache directory.`);
|
||||
return;
|
||||
}
|
||||
await fs.rm(target.tokenCacheDir, { recursive: true, force: true });
|
||||
console.log(`Cleared cached credentials for '${target.name}' (${target.tokenCacheDir})`);
|
||||
}
|
||||
30
src/cli/config/doctor.ts
Normal file
30
src/cli/config/doctor.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { loadServerDefinitions } from '../../config.js';
|
||||
import { MCPORTER_VERSION } from '../../runtime.js';
|
||||
import { logConfigLocations, resolveConfigLocations } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
|
||||
export async function handleDoctorCommand(options: ConfigCliOptions, _args: string[]): Promise<void> {
|
||||
console.log(`MCPorter ${MCPORTER_VERSION}`);
|
||||
const configLocations = await resolveConfigLocations(options.loadOptions);
|
||||
logConfigLocations(configLocations, { leadingNewline: false });
|
||||
console.log('');
|
||||
const servers = await loadServerDefinitions(options.loadOptions);
|
||||
const issues: string[] = [];
|
||||
for (const server of servers) {
|
||||
if (server.command.kind === 'stdio' && !path.isAbsolute(server.command.cwd)) {
|
||||
issues.push(`Server '${server.name}' has a non-absolute working directory.`);
|
||||
}
|
||||
if (server.auth === 'oauth' && !server.tokenCacheDir) {
|
||||
issues.push(`Server '${server.name}' enables OAuth but lacks a token cache directory.`);
|
||||
}
|
||||
}
|
||||
if (issues.length === 0) {
|
||||
console.log('Config looks good.');
|
||||
return;
|
||||
}
|
||||
console.log('Config issues detected:');
|
||||
for (const issue of issues) {
|
||||
console.log(` - ${issue}`);
|
||||
}
|
||||
}
|
||||
47
src/cli/config/get.ts
Normal file
47
src/cli/config/get.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { loadServerDefinitions } from '../../config.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { printServerSummary, serializeDefinition } from './render.js';
|
||||
import { resolveServerDefinition } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
|
||||
export async function handleGetCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
|
||||
const flags = extractGetFlags(args);
|
||||
const name = args.shift();
|
||||
if (!name) {
|
||||
throw new CliUsageError('Usage: mcporter config get <name>');
|
||||
}
|
||||
const servers = await loadServerDefinitions(options.loadOptions);
|
||||
const target = resolveServerDefinition(name, servers);
|
||||
if (flags.format === 'json') {
|
||||
console.log(JSON.stringify(serializeDefinition(target), null, 2));
|
||||
return;
|
||||
}
|
||||
printServerSummary(target);
|
||||
if (target.command.kind === 'http' && target.command.headers && Object.keys(target.command.headers).length > 0) {
|
||||
console.log(' Headers:');
|
||||
for (const [key, value] of Object.entries(target.command.headers)) {
|
||||
console.log(` ${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
if (target.env && Object.keys(target.env).length > 0) {
|
||||
console.log(' Env:');
|
||||
for (const [key, value] of Object.entries(target.env)) {
|
||||
console.log(` ${key}=${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractGetFlags(args: string[]): { format: 'text' | 'json' } {
|
||||
let format: 'text' | 'json' = 'text';
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
if (token === '--json') {
|
||||
format = 'json';
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return { format };
|
||||
}
|
||||
213
src/cli/config/help.ts
Normal file
213
src/cli/config/help.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { boldText, dimText, extraDimText } from '../terminal.js';
|
||||
import { printConfigUsageExamples } from './render.js';
|
||||
import { COLOR_ENABLED } from './shared.js';
|
||||
|
||||
export type ConfigSubcommand = 'list' | 'get' | 'add' | 'remove' | 'import' | 'login' | 'logout' | 'doctor';
|
||||
|
||||
export type ConfigHelpEntry = {
|
||||
readonly name: string;
|
||||
readonly summary: string;
|
||||
readonly usage: string;
|
||||
readonly description: string;
|
||||
readonly flags?: Array<{ flag: string; description: string }>;
|
||||
readonly examples?: string[];
|
||||
};
|
||||
|
||||
export const CONFIG_HELP_ENTRIES: Record<ConfigSubcommand, ConfigHelpEntry> = {
|
||||
list: {
|
||||
name: 'list [options] [filter]',
|
||||
summary: 'Show merged servers',
|
||||
usage: 'mcporter config list [options] [filter]',
|
||||
description: 'Lists configured servers. Defaults to local entries, but you can view imports and emit JSON.',
|
||||
flags: [
|
||||
{ flag: '--json', description: 'Print JSON payloads instead of ANSI text.' },
|
||||
{ flag: '--source <local|import>', description: 'Filter to local definitions or imported entries only.' },
|
||||
{ flag: 'filter (positional)', description: 'Substring match applied to server names.' },
|
||||
],
|
||||
examples: ['pnpm mcporter config list', 'pnpm mcporter config list --json --source import cursor'],
|
||||
},
|
||||
get: {
|
||||
name: 'get <name> [--json]',
|
||||
summary: 'Inspect a single server',
|
||||
usage: 'mcporter config get <name> [--json]',
|
||||
description: 'Shows one server definition, including transport, headers, and env overrides.',
|
||||
flags: [{ flag: '--json', description: 'Emit the server entry as JSON.' }],
|
||||
examples: ['pnpm mcporter config get linear', 'pnpm mcporter config get claude --json'],
|
||||
},
|
||||
add: {
|
||||
name: 'add [options] <name> [target]',
|
||||
summary: 'Persist a server definition',
|
||||
usage: 'mcporter config add [options] <name> [target]',
|
||||
description:
|
||||
'Adds HTTP or stdio servers to the local config. Accepts URLs, commands, env vars, and OAuth metadata.',
|
||||
flags: [
|
||||
{ flag: '--url <https://host>', description: 'Set the HTTP/S base URL (implies http transport).' },
|
||||
{ flag: '--command <binary>', description: 'Set the stdio executable (implies stdio transport).' },
|
||||
{ flag: '--stdio <binary>', description: 'Alias for --command.' },
|
||||
{ flag: '--transport <http|sse|stdio>', description: 'Force a specific transport (validates target).' },
|
||||
{ flag: '--arg <value>', description: 'Pass through additional stdio arguments (repeatable).' },
|
||||
{ flag: '--description <text>', description: 'Set a human-friendly summary.' },
|
||||
{ flag: '--env KEY=value', description: 'Attach environment variables (repeatable).' },
|
||||
{ flag: '--header KEY=value', description: 'Attach HTTP headers (repeatable).' },
|
||||
{ flag: '--token-cache-dir <path>', description: 'Override where OAuth tokens are persisted.' },
|
||||
{ flag: '--client-name <name>', description: 'Customize the OAuth client identifier.' },
|
||||
{ flag: '--oauth-redirect-url <url>', description: 'Set a custom OAuth redirect URL.' },
|
||||
{ flag: '--auth <strategy>', description: 'Force the auth type (e.g., oauth).' },
|
||||
{ flag: '--copy-from <import:name>', description: 'Start with an imported definition by name.' },
|
||||
{ flag: '--persist <config-path>', description: 'Write to an alternate mcporter.json path.' },
|
||||
{
|
||||
flag: '--scope <home|project>',
|
||||
description: 'Choose whether to write to the home or project config (default: project).',
|
||||
},
|
||||
{ flag: '--dry-run', description: 'Print the would-be entry without writing to disk.' },
|
||||
{ flag: '--', description: 'Forward every subsequent token as a stdio arg.' },
|
||||
],
|
||||
examples: [
|
||||
'pnpm mcporter config add linear https://mcp.linear.app/mcp',
|
||||
'pnpm mcporter config add cursor --command "npx -y cursor" --arg --stdio',
|
||||
],
|
||||
},
|
||||
remove: {
|
||||
name: 'remove <name>',
|
||||
summary: 'Delete a local entry',
|
||||
usage: 'mcporter config remove <name>',
|
||||
description: 'Removes a server definition from the active mcporter.json file.',
|
||||
examples: ['pnpm mcporter config remove linear'],
|
||||
},
|
||||
import: {
|
||||
name: 'import <kind> [options]',
|
||||
summary: 'Inspect or copy imported servers',
|
||||
usage: 'mcporter config import <kind> [options]',
|
||||
description:
|
||||
'Shows entries from Cursor, Claude, Codex, and other supported imports. Optionally copies them locally.',
|
||||
flags: [
|
||||
{ flag: '--path <file>', description: 'Manually point at a config file path.' },
|
||||
{ flag: '--filter <substring>', description: 'Match server names by substring before listing/copying.' },
|
||||
{ flag: '--copy', description: 'Write the filtered entries into the local config.' },
|
||||
{ flag: '--json', description: 'Emit JSON instead of plain text listings.' },
|
||||
],
|
||||
examples: ['pnpm mcporter config import cursor --copy', 'pnpm mcporter config import claude --filter notion'],
|
||||
},
|
||||
login: {
|
||||
name: 'login <name|url> [options]',
|
||||
summary: 'Run the OAuth/auth flow',
|
||||
usage: 'mcporter config login <name|url> [options]',
|
||||
description: 'Delegates to `mcporter auth`, so you can pass ephemeral flags like --http-url/--stdio/--reset.',
|
||||
examples: ['pnpm mcporter config login linear', 'pnpm mcporter config login https://example.com/mcp --reset'],
|
||||
},
|
||||
logout: {
|
||||
name: 'logout <name>',
|
||||
summary: 'Clear cached credentials',
|
||||
usage: 'mcporter config logout <name>',
|
||||
description: 'Deletes the token cache directory for an OAuth-enabled server.',
|
||||
examples: ['pnpm mcporter config logout linear'],
|
||||
},
|
||||
doctor: {
|
||||
name: 'doctor',
|
||||
summary: 'Validate config files',
|
||||
usage: 'mcporter config doctor',
|
||||
description: 'Validates config files, warns about missing token caches, and prints config locations.',
|
||||
examples: ['pnpm mcporter config doctor'],
|
||||
},
|
||||
};
|
||||
|
||||
export const CONFIG_HELP_ORDER: ConfigSubcommand[] = [
|
||||
'list',
|
||||
'get',
|
||||
'add',
|
||||
'remove',
|
||||
'import',
|
||||
'login',
|
||||
'logout',
|
||||
'doctor',
|
||||
];
|
||||
|
||||
export function isHelpToken(token: string): boolean {
|
||||
return token === '--help' || token === '-h';
|
||||
}
|
||||
|
||||
export function consumeInlineHelpTokens(args: string[]): boolean {
|
||||
let found = false;
|
||||
for (let index = args.length - 1; index >= 0; index -= 1) {
|
||||
const token = args[index];
|
||||
if (token && isHelpToken(token)) {
|
||||
args.splice(index, 1);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export function printConfigHelp(subcommand?: string): void {
|
||||
const colorize = COLOR_ENABLED();
|
||||
if (!subcommand) {
|
||||
printConfigOverview(colorize);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveHelpSubcommand(subcommand);
|
||||
if (!resolved) {
|
||||
console.log(`Unknown config subcommand '${subcommand}'. Available commands: ${CONFIG_HELP_ORDER.join(', ')}.`);
|
||||
return;
|
||||
}
|
||||
printSubcommandHelp(resolved, colorize);
|
||||
}
|
||||
|
||||
function resolveHelpSubcommand(token: string | undefined): ConfigSubcommand | undefined {
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = token.toLowerCase() as ConfigSubcommand;
|
||||
return normalized in CONFIG_HELP_ENTRIES ? normalized : undefined;
|
||||
}
|
||||
|
||||
function printConfigOverview(colorize: boolean): void {
|
||||
const title = colorize ? boldText('mcporter config') : 'mcporter config';
|
||||
const subtitle = colorize
|
||||
? dimText('Manage configured MCP servers, imports, and ad-hoc discoveries.')
|
||||
: 'Manage configured MCP servers, imports, and ad-hoc discoveries.';
|
||||
const commandsHeader = colorize ? boldText('Commands') : 'Commands';
|
||||
const examplesHeader = colorize ? boldText('Examples') : 'Examples';
|
||||
const lines: string[] = [title, subtitle, '', commandsHeader];
|
||||
const maxName = Math.max(...CONFIG_HELP_ORDER.map((key) => CONFIG_HELP_ENTRIES[key].name.length));
|
||||
for (const key of CONFIG_HELP_ORDER) {
|
||||
const entry = CONFIG_HELP_ENTRIES[key];
|
||||
const padded = entry.name.padEnd(maxName);
|
||||
const renderedName = colorize ? boldText(padded) : padded;
|
||||
const renderedDesc = colorize ? dimText(entry.summary) : entry.summary;
|
||||
lines.push(` ${renderedName} ${renderedDesc}`);
|
||||
}
|
||||
lines.push('', examplesHeader);
|
||||
for (const example of printConfigUsageExamples()) {
|
||||
lines.push(` ${colorize ? extraDimText(example) : example}`);
|
||||
}
|
||||
const pointer = "Run 'mcporter config <command> --help' for detailed flag info.";
|
||||
lines.push('', colorize ? extraDimText(pointer) : pointer);
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
function printSubcommandHelp(subcommand: ConfigSubcommand, colorize: boolean): void {
|
||||
const entry = CONFIG_HELP_ENTRIES[subcommand];
|
||||
const title = colorize ? boldText(`mcporter config ${subcommand}`) : `mcporter config ${subcommand}`;
|
||||
const description = colorize ? dimText(entry.description) : entry.description;
|
||||
const usageHeader = colorize ? boldText('Usage') : 'Usage';
|
||||
const lines: string[] = [title, description, '', usageHeader, ` ${entry.usage}`];
|
||||
if (entry.flags && entry.flags.length > 0) {
|
||||
const flagsHeader = colorize ? boldText('Flags') : 'Flags';
|
||||
const maxFlag = Math.max(...entry.flags.map((flag) => flag.flag.length));
|
||||
lines.push('', flagsHeader);
|
||||
for (const flag of entry.flags) {
|
||||
const padded = flag.flag.padEnd(maxFlag);
|
||||
const renderedFlag = colorize ? boldText(padded) : padded;
|
||||
const renderedDesc = colorize ? dimText(flag.description) : flag.description;
|
||||
lines.push(` ${renderedFlag} ${renderedDesc}`);
|
||||
}
|
||||
}
|
||||
if (entry.examples && entry.examples.length > 0) {
|
||||
const examplesHeader = colorize ? boldText('Examples') : 'Examples';
|
||||
lines.push('', examplesHeader);
|
||||
for (const example of entry.examples) {
|
||||
lines.push(` ${colorize ? extraDimText(example) : example}`);
|
||||
}
|
||||
}
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
97
src/cli/config/import.ts
Normal file
97
src/cli/config/import.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import path from 'node:path';
|
||||
import type { RawEntry } from '../../config.js';
|
||||
import { writeRawConfig } from '../../config.js';
|
||||
import { pathsForImport, readExternalEntries } from '../../config-imports.js';
|
||||
import { expandHome } from '../../env.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { cloneConfig, loadOrCreateConfig } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
|
||||
export interface ImportFlags {
|
||||
path?: string;
|
||||
filter?: string;
|
||||
copy?: boolean;
|
||||
format: 'text' | 'json';
|
||||
}
|
||||
|
||||
export async function handleImportCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
|
||||
const kind = args.shift();
|
||||
if (!kind) {
|
||||
throw new CliUsageError('Usage: mcporter config import <kind>');
|
||||
}
|
||||
const flags = extractImportFlags(args);
|
||||
const rootDir = options.loadOptions.rootDir ?? process.cwd();
|
||||
const paths = flags.path ? [path.resolve(expandHome(flags.path))] : pathsForImport(kind as never, rootDir);
|
||||
const entries: Array<{ name: string; entry: RawEntry; source: string }> = [];
|
||||
const seenNames = new Set<string>();
|
||||
for (const candidate of paths) {
|
||||
const resolved = expandHome(candidate);
|
||||
const map = await readExternalEntries(resolved, { projectRoot: rootDir, importKind: kind as never });
|
||||
if (!map) {
|
||||
continue;
|
||||
}
|
||||
for (const [name, entry] of map) {
|
||||
if (flags.filter && !name.includes(flags.filter)) {
|
||||
continue;
|
||||
}
|
||||
if (seenNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
seenNames.add(name);
|
||||
entries.push({ name, entry, source: resolved });
|
||||
}
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
console.log('No entries found.');
|
||||
return;
|
||||
}
|
||||
if (flags.format === 'json') {
|
||||
console.log(JSON.stringify({ entries }, null, 2));
|
||||
} else {
|
||||
for (const item of entries) {
|
||||
console.log(`${item.name} (${item.source})`);
|
||||
}
|
||||
}
|
||||
if (flags.copy) {
|
||||
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
|
||||
const nextConfig = cloneConfig(config);
|
||||
if (!nextConfig.mcpServers) {
|
||||
nextConfig.mcpServers = {};
|
||||
}
|
||||
for (const item of entries) {
|
||||
nextConfig.mcpServers[item.name] = structuredClone(item.entry);
|
||||
}
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
console.log(`Copied ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractImportFlags(args: string[]): ImportFlags {
|
||||
const flags: ImportFlags = { format: 'text' };
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
switch (token) {
|
||||
case '--path':
|
||||
flags.path = args[index + 1];
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--filter':
|
||||
flags.filter = args[index + 1];
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
case '--copy':
|
||||
flags.copy = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
case '--json':
|
||||
flags.format = 'json';
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
default:
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
1
src/cli/config/index.ts
Normal file
1
src/cli/config/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { __configCommandInternals, handleConfigCli } from '../config-command.js';
|
||||
81
src/cli/config/list.ts
Normal file
81
src/cli/config/list.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { loadServerDefinitions } from '../../config.js';
|
||||
import type { ServerDefinition } from '../../config-schema.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { dimText } from '../terminal.js';
|
||||
import { printImportSummary, printServerSummary, serializeDefinition } from './render.js';
|
||||
import { COLOR_ENABLED, logConfigLocations, resolveConfigLocations } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
|
||||
export interface ListFlags {
|
||||
format: 'text' | 'json';
|
||||
source?: 'local' | 'import';
|
||||
}
|
||||
|
||||
export async function handleListCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
|
||||
const flags = extractListFlags(args);
|
||||
const filter = args.shift();
|
||||
const servers = await loadServerDefinitions(options.loadOptions);
|
||||
let filtered = servers;
|
||||
if (flags.source) {
|
||||
filtered = filtered.filter((server) => (server.source?.kind ?? 'local') === flags.source);
|
||||
}
|
||||
if (filter) {
|
||||
filtered = filtered.filter((server) => filterMatches(filter, server));
|
||||
}
|
||||
if (flags.format === 'json') {
|
||||
const payload = filtered.map((server) => serializeDefinition(server));
|
||||
console.log(JSON.stringify({ servers: payload }, null, 2));
|
||||
return;
|
||||
}
|
||||
const colorize = COLOR_ENABLED();
|
||||
if (filtered.length === 0) {
|
||||
console.log(
|
||||
colorize
|
||||
? dimText('No local servers match the provided filters.')
|
||||
: 'No local servers match the provided filters.'
|
||||
);
|
||||
} else {
|
||||
for (const server of filtered) {
|
||||
printServerSummary(server);
|
||||
}
|
||||
}
|
||||
if ((!flags.source || flags.source === 'local') && flags.format === 'text') {
|
||||
printImportSummary(servers.filter((server) => server.source?.kind === 'import'));
|
||||
}
|
||||
if (flags.format === 'text') {
|
||||
const summary = await resolveConfigLocations(options.loadOptions);
|
||||
logConfigLocations(summary, { leadingNewline: true });
|
||||
}
|
||||
}
|
||||
|
||||
function extractListFlags(args: string[]): ListFlags {
|
||||
const flags: ListFlags = { format: 'text', source: 'local' };
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
if (token === '--json') {
|
||||
flags.format = 'json';
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--source') {
|
||||
const value = args[index + 1];
|
||||
if (value !== 'local' && value !== 'import') {
|
||||
throw new CliUsageError("--source must be either 'local' or 'import'.");
|
||||
}
|
||||
flags.source = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function filterMatches(filter: string, server: ServerDefinition): boolean {
|
||||
if (filter.startsWith('source:')) {
|
||||
const origin = server.source?.kind ?? 'local';
|
||||
return `source:${origin}` === filter;
|
||||
}
|
||||
return server.name.includes(filter);
|
||||
}
|
||||
20
src/cli/config/remove.ts
Normal file
20
src/cli/config/remove.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { writeRawConfig } from '../../config.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { cloneConfig, findServerNameWithFuzzyMatch, loadOrCreateConfig } from './shared.js';
|
||||
import type { ConfigCliOptions } from './types.js';
|
||||
|
||||
export async function handleRemoveCommand(options: ConfigCliOptions, args: string[]): Promise<void> {
|
||||
const name = args.shift();
|
||||
if (!name) {
|
||||
throw new CliUsageError('Usage: mcporter config remove <name>');
|
||||
}
|
||||
const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions);
|
||||
const targetName = findServerNameWithFuzzyMatch(name, Object.keys(config.mcpServers ?? {}));
|
||||
if (!targetName) {
|
||||
throw new CliUsageError(`Server '${name}' does not exist in ${configPath}.`);
|
||||
}
|
||||
const nextConfig = cloneConfig(config);
|
||||
delete nextConfig.mcpServers[targetName];
|
||||
await writeRawConfig(configPath, nextConfig);
|
||||
console.log(`Removed '${targetName}' from ${configPath}`);
|
||||
}
|
||||
116
src/cli/config/render.ts
Normal file
116
src/cli/config/render.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import type { ServerDefinition } from '../../config-schema.js';
|
||||
import { boldText, dimText } from '../terminal.js';
|
||||
import { COLOR_ENABLED } from './shared.js';
|
||||
|
||||
export type SerializedServerDefinition = {
|
||||
name: string;
|
||||
description?: string;
|
||||
source: NonNullable<ServerDefinition['source']> | { kind: 'local'; path: string };
|
||||
auth?: ServerDefinition['auth'];
|
||||
tokenCacheDir?: string;
|
||||
clientName?: string;
|
||||
oauthRedirectUrl?: string;
|
||||
env?: Record<string, string>;
|
||||
transport: 'http' | 'stdio';
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export function serializeDefinition(definition: ServerDefinition): SerializedServerDefinition {
|
||||
const origin = definition.source ?? { kind: 'local', path: '' };
|
||||
if (definition.command.kind === 'http') {
|
||||
return {
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
source: origin,
|
||||
auth: definition.auth,
|
||||
tokenCacheDir: definition.tokenCacheDir,
|
||||
clientName: definition.clientName,
|
||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||
env: definition.env,
|
||||
transport: 'http',
|
||||
baseUrl: definition.command.url.href,
|
||||
headers: definition.command.headers,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
source: origin,
|
||||
auth: definition.auth,
|
||||
tokenCacheDir: definition.tokenCacheDir,
|
||||
clientName: definition.clientName,
|
||||
oauthRedirectUrl: definition.oauthRedirectUrl,
|
||||
env: definition.env,
|
||||
transport: 'stdio',
|
||||
command: definition.command.command,
|
||||
args: definition.command.args,
|
||||
cwd: definition.command.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export function printServerSummary(definition: ServerDefinition): void {
|
||||
const colorize = COLOR_ENABLED();
|
||||
const origin = definition.source;
|
||||
const header = colorize ? boldText(definition.name) : definition.name;
|
||||
const label = (text: string): string => (colorize ? dimText(text) : text);
|
||||
console.log(header);
|
||||
if (origin) {
|
||||
console.log(` ${label('Source')}: ${origin.kind}${origin.path ? ` (${origin.path})` : ''}`);
|
||||
} else {
|
||||
console.log(` ${label('Source')}: local`);
|
||||
}
|
||||
if (definition.command.kind === 'http') {
|
||||
console.log(` ${label('Transport')}: http (${definition.command.url.href})`);
|
||||
} else {
|
||||
const renderedArgs = definition.command.args.length > 0 ? ` ${definition.command.args.join(' ')}` : '';
|
||||
console.log(` ${label('Transport')}: stdio (${definition.command.command}${renderedArgs})`);
|
||||
console.log(` ${label('CWD')}: ${definition.command.cwd}`);
|
||||
}
|
||||
if (definition.description) {
|
||||
console.log(` ${label('Description')}: ${definition.description}`);
|
||||
}
|
||||
if (definition.auth === 'oauth') {
|
||||
console.log(` ${label('Auth')}: oauth`);
|
||||
}
|
||||
}
|
||||
|
||||
export function printImportSummary(importServers: ServerDefinition[]): void {
|
||||
if (importServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const colorize = COLOR_ENABLED();
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const server of importServers) {
|
||||
const sourcePath = server.source?.path ?? '<unknown>';
|
||||
const list = grouped.get(sourcePath) ?? [];
|
||||
list.push(server.name);
|
||||
grouped.set(sourcePath, list);
|
||||
}
|
||||
console.log('');
|
||||
const header = colorize
|
||||
? boldText('Other sources available via --source import')
|
||||
: 'Other sources available via --source import';
|
||||
console.log(header);
|
||||
for (const [path, names] of grouped) {
|
||||
names.sort();
|
||||
const sample = names.slice(0, 3).join(', ');
|
||||
const suffix = names.length > 3 ? ', …' : '';
|
||||
const countLabel = `${names.length} server${names.length === 1 ? '' : 's'}`;
|
||||
const pathLabel = colorize ? dimText(path) : path;
|
||||
console.log(` ${pathLabel} — ${countLabel} (${sample}${suffix})`);
|
||||
}
|
||||
const guidance = 'Use `mcporter config import <kind>` to copy them locally.';
|
||||
console.log(colorize ? dimText(guidance) : guidance);
|
||||
}
|
||||
|
||||
export function printConfigUsageExamples(): string[] {
|
||||
return [
|
||||
'pnpm mcporter config list --json',
|
||||
'pnpm mcporter config add linear https://mcp.linear.app/mcp',
|
||||
'pnpm mcporter config import cursor --copy',
|
||||
];
|
||||
}
|
||||
159
src/cli/config/shared.ts
Normal file
159
src/cli/config/shared.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { LoadConfigOptions, RawConfig } from '../../config.js';
|
||||
import { loadRawConfig, resolveConfigPath } from '../../config.js';
|
||||
import type { ServerDefinition } from '../../config-schema.js';
|
||||
import { CliUsageError } from '../errors.js';
|
||||
import { chooseClosestIdentifier, renderIdentifierResolutionMessages } from '../identifier-helpers.js';
|
||||
import { dimText, supportsAnsiColor } from '../terminal.js';
|
||||
|
||||
export const COLOR_ENABLED = (): boolean => Boolean(supportsAnsiColor && process.stdout.isTTY);
|
||||
|
||||
export type ConfigLocationSummary = {
|
||||
projectPath: string;
|
||||
projectExists: boolean;
|
||||
systemPath: string;
|
||||
systemExists: boolean;
|
||||
};
|
||||
|
||||
export function cloneConfig(config: RawConfig): RawConfig {
|
||||
return {
|
||||
mcpServers: config.mcpServers ? { ...config.mcpServers } : {},
|
||||
imports: config.imports ? [...config.imports] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadOrCreateConfig(loadOptions: LoadConfigOptions): Promise<{ config: RawConfig; path: string }> {
|
||||
try {
|
||||
const { config, path } = await loadRawConfig(loadOptions);
|
||||
return { config, path };
|
||||
} catch (error) {
|
||||
if (isErrno(error, 'ENOENT')) {
|
||||
const rootDir = loadOptions.rootDir ?? process.cwd();
|
||||
const resolved = resolveConfigPath(loadOptions.configPath, rootDir);
|
||||
return { config: { mcpServers: {}, imports: [] }, path: resolved.path };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveConfigLocations(loadOptions: LoadConfigOptions): Promise<ConfigLocationSummary> {
|
||||
const rootDir = loadOptions.rootDir ?? process.cwd();
|
||||
const projectPath = path.resolve(rootDir, 'config', 'mcporter.json');
|
||||
const projectExists = await pathExists(projectPath);
|
||||
const systemCandidates = buildSystemConfigCandidates();
|
||||
const systemResolved = await resolveFirstExisting(systemCandidates);
|
||||
return {
|
||||
projectPath,
|
||||
projectExists,
|
||||
systemPath: systemResolved.path,
|
||||
systemExists: systemResolved.exists,
|
||||
};
|
||||
}
|
||||
|
||||
export function logConfigLocations(summary: ConfigLocationSummary, options?: { leadingNewline?: boolean }): void {
|
||||
const shouldAddNewline = options?.leadingNewline ?? true;
|
||||
if (shouldAddNewline) {
|
||||
console.log('');
|
||||
}
|
||||
console.log(`Project config: ${formatPath(summary.projectPath, summary.projectExists)}`);
|
||||
console.log(`System config: ${formatPath(summary.systemPath, summary.systemExists)}`);
|
||||
}
|
||||
|
||||
export function findServerNameWithFuzzyMatch(
|
||||
name: string,
|
||||
candidates: string[],
|
||||
logger: ((message: string) => void) | null = console.log
|
||||
): string | null {
|
||||
if (candidates.includes(name)) {
|
||||
return name;
|
||||
}
|
||||
const resolution = chooseClosestIdentifier(name, candidates);
|
||||
if (!resolution) {
|
||||
return null;
|
||||
}
|
||||
const messages = renderIdentifierResolutionMessages({
|
||||
entity: 'server',
|
||||
attempted: name,
|
||||
resolution,
|
||||
});
|
||||
if (messages.auto && logger) {
|
||||
logger(dimText(messages.auto));
|
||||
}
|
||||
if (resolution.kind === 'auto') {
|
||||
return resolution.value;
|
||||
}
|
||||
if (messages.suggest && logger) {
|
||||
logger(dimText(messages.suggest));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveServerDefinition(
|
||||
name: string,
|
||||
servers: ServerDefinition[],
|
||||
logger: ((message: string) => void) | null = console.log
|
||||
): ServerDefinition {
|
||||
const direct = servers.find((server) => server.name === name);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const resolution = chooseClosestIdentifier(
|
||||
name,
|
||||
servers.map((server) => server.name)
|
||||
);
|
||||
if (!resolution) {
|
||||
throw new CliUsageError(`[mcporter] Unknown server '${name}'.`);
|
||||
}
|
||||
const messages = renderIdentifierResolutionMessages({
|
||||
entity: 'server',
|
||||
attempted: name,
|
||||
resolution,
|
||||
});
|
||||
if (messages.auto && logger) {
|
||||
logger(dimText(messages.auto));
|
||||
}
|
||||
if (resolution.kind === 'auto') {
|
||||
const match = servers.find((server) => server.name === resolution.value);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
if (messages.suggest && logger) {
|
||||
logger(dimText(messages.suggest));
|
||||
}
|
||||
throw new CliUsageError(`[mcporter] Unknown server '${name}'.`);
|
||||
}
|
||||
|
||||
function buildSystemConfigCandidates(): string[] {
|
||||
const homeDir = os.homedir();
|
||||
const base = path.join(homeDir, '.mcporter');
|
||||
return [path.join(base, 'mcporter.json'), path.join(base, 'mcporter.jsonc')];
|
||||
}
|
||||
|
||||
async function resolveFirstExisting(pathsToCheck: string[]): Promise<{ path: string; exists: boolean }> {
|
||||
for (const candidate of pathsToCheck) {
|
||||
if (await pathExists(candidate)) {
|
||||
return { path: candidate, exists: true };
|
||||
}
|
||||
}
|
||||
return { path: pathsToCheck[0] ?? '', exists: false };
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPath(targetPath: string, exists: boolean): string {
|
||||
return exists ? targetPath : `${targetPath} (missing)`;
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return Boolean(error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === code);
|
||||
}
|
||||
8
src/cli/config/types.ts
Normal file
8
src/cli/config/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { LoadConfigOptions } from '../../config.js';
|
||||
|
||||
export interface ConfigCliOptions {
|
||||
readonly loadOptions: LoadConfigOptions;
|
||||
readonly invokeAuth: (args: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export type ConfigScope = 'home' | 'project';
|
||||
45
tests/config-add-dry-run.test.ts
Normal file
45
tests/config-add-dry-run.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleAddCommand } from '../src/cli/config/add.js';
|
||||
import type { LoadConfigOptions } from '../src/config.js';
|
||||
|
||||
describe('config add --dry-run', () => {
|
||||
let tempDir: string;
|
||||
let loadOptions: LoadConfigOptions;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-add-'));
|
||||
loadOptions = { rootDir: tempDir };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('prints entry without writing config when dry-run is set', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleAddCommand({ loadOptions } as never, [
|
||||
'linear',
|
||||
'https://linear.app/mcp',
|
||||
'--description',
|
||||
'test',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
const outputs = logSpy.mock.calls.map((call) => call[0]).join('\n');
|
||||
logSpy.mockRestore();
|
||||
|
||||
const configPath = path.join(tempDir, 'config', 'mcporter.json');
|
||||
const exists = await fs
|
||||
.access(configPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(exists).toBe(false);
|
||||
expect(outputs).toContain('(dry-run) No changes were written.');
|
||||
expect(outputs).toContain('"linear"');
|
||||
});
|
||||
});
|
||||
61
tests/config-add-flags.test.ts
Normal file
61
tests/config-add-flags.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleAddCommand } from '../src/cli/config/add.js';
|
||||
import type { LoadConfigOptions } from '../src/config.js';
|
||||
import { createTempConfig } from './fixtures/config-fixture.js';
|
||||
|
||||
describe('config add flag parsing', () => {
|
||||
let loadOptions: LoadConfigOptions;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
const ctx = await createTempConfig();
|
||||
loadOptions = ctx.loadOptions;
|
||||
cleanup = () => ctx.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (cleanup) {
|
||||
await cleanup();
|
||||
cleanup = undefined;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('merges env, headers, description and auth metadata, and respects dry-run', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleAddCommand({ loadOptions } as never, [
|
||||
'example',
|
||||
'https://api.example.com/mcp',
|
||||
'--env',
|
||||
'FOO=bar',
|
||||
'--header',
|
||||
'X-Test=1',
|
||||
'--description',
|
||||
'desc',
|
||||
'--auth',
|
||||
'oauth',
|
||||
'--client-name',
|
||||
'mcporter',
|
||||
'--oauth-redirect-url',
|
||||
'https://example.com/callback',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
const payloadLine = logSpy.mock.calls
|
||||
.map((call) => call[0])
|
||||
.find((msg) => typeof msg === 'string' && msg.trim().startsWith('{'));
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(payloadLine).toBeDefined();
|
||||
const payload = JSON.parse(String(payloadLine)) as Record<string, unknown>;
|
||||
const entry = payload.example as Record<string, unknown>;
|
||||
expect(entry.baseUrl).toBe('https://api.example.com/mcp');
|
||||
expect(entry.env).toEqual({ FOO: 'bar' });
|
||||
expect(entry.headers).toEqual({ 'X-Test': '1' });
|
||||
expect(entry.description).toBe('desc');
|
||||
expect(entry.auth).toBe('oauth');
|
||||
expect(entry.clientName).toBe('mcporter');
|
||||
expect(entry.oauthRedirectUrl).toBe('https://example.com/callback');
|
||||
});
|
||||
});
|
||||
27
tests/config-add-persist.test.ts
Normal file
27
tests/config-add-persist.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleAddCommand } from '../src/cli/config/add.js';
|
||||
import { createTempConfig } from './fixtures/config-fixture.js';
|
||||
|
||||
describe('config add persist and scope', () => {
|
||||
it('writes to custom persist path when provided', async () => {
|
||||
const ctx = await createTempConfig();
|
||||
const persistPath = path.join(ctx.tempDir, 'custom', 'mcporter.json');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleAddCommand({ loadOptions: ctx.loadOptions } as never, [
|
||||
'persisted',
|
||||
'https://persist.example/mcp',
|
||||
'--persist',
|
||||
persistPath,
|
||||
]);
|
||||
|
||||
logSpy.mockRestore();
|
||||
const buffer = await fs.readFile(persistPath, 'utf8');
|
||||
const parsed = JSON.parse(buffer) as { mcpServers: Record<string, { baseUrl: string }> };
|
||||
expect(parsed.mcpServers.persisted).toBeDefined();
|
||||
expect(parsed.mcpServers.persisted?.baseUrl).toBe('https://persist.example/mcp');
|
||||
await ctx.cleanup();
|
||||
});
|
||||
});
|
||||
46
tests/config-add-scope-behavior.test.ts
Normal file
46
tests/config-add-scope-behavior.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleAddCommand } from '../src/cli/config/add.js';
|
||||
import type { LoadConfigOptions } from '../src/config.js';
|
||||
|
||||
describe('config add scope behavior', () => {
|
||||
let projectDir: string;
|
||||
let homeDir: string;
|
||||
let loadOptions: LoadConfigOptions;
|
||||
let restoreHomedir: (() => void) | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-add-scope-project-'));
|
||||
homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-add-scope-home-'));
|
||||
loadOptions = { rootDir: projectDir };
|
||||
const spy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
|
||||
restoreHomedir = () => spy.mockRestore();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreHomedir?.();
|
||||
restoreHomedir = undefined;
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes to home config when scope=home', async () => {
|
||||
await handleAddCommand({ loadOptions } as never, ['homescope', 'https://home.example/mcp', '--scope', 'home']);
|
||||
const homeConfigPath = path.join(homeDir, '.mcporter', 'mcporter.json');
|
||||
const buffer = await fs.readFile(homeConfigPath, 'utf8');
|
||||
const parsed = JSON.parse(buffer) as { mcpServers: Record<string, { baseUrl: string }> };
|
||||
expect(parsed.mcpServers.homescope).toBeDefined();
|
||||
expect(parsed.mcpServers.homescope?.baseUrl).toBe('https://home.example/mcp');
|
||||
});
|
||||
|
||||
it('writes to project config when scope=project', async () => {
|
||||
await handleAddCommand({ loadOptions } as never, ['projects', 'https://project.example/mcp', '--scope', 'project']);
|
||||
const projectConfigPath = path.join(projectDir, 'config', 'mcporter.json');
|
||||
const buffer = await fs.readFile(projectConfigPath, 'utf8');
|
||||
const parsed = JSON.parse(buffer) as { mcpServers: Record<string, { baseUrl: string }> };
|
||||
expect(parsed.mcpServers.projects).toBeDefined();
|
||||
expect(parsed.mcpServers.projects?.baseUrl).toBe('https://project.example/mcp');
|
||||
});
|
||||
});
|
||||
23
tests/config-add-sse.test.ts
Normal file
23
tests/config-add-sse.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleAddCommand } from '../src/cli/config/add.js';
|
||||
import { createTempConfig } from './fixtures/config-fixture.js';
|
||||
|
||||
describe('config add with sse transport', () => {
|
||||
it('accepts sse transport when url is provided', async () => {
|
||||
const ctx = await createTempConfig();
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleAddCommand({ loadOptions: ctx.loadOptions } as never, [
|
||||
'sse-server',
|
||||
'https://sse.example/mcp',
|
||||
'--transport',
|
||||
'sse',
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
logSpy.mockRestore();
|
||||
await ctx.cleanup();
|
||||
// If no error thrown, transport validation succeeded. Ensure dry-run printed payload.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
46
tests/config-doctor.test.ts
Normal file
46
tests/config-doctor.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleDoctorCommand } from '../src/cli/config/doctor.js';
|
||||
import type { LoadConfigOptions } from '../src/config.js';
|
||||
import * as configModule from '../src/config.js';
|
||||
|
||||
let tempDir: string;
|
||||
let loadOptions: LoadConfigOptions;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-doctor-'));
|
||||
loadOptions = { rootDir: tempDir };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('config doctor', () => {
|
||||
it('reports issues for stdio cwd and missing oauth token cache', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([
|
||||
{
|
||||
name: 'bad-stdio',
|
||||
command: { kind: 'stdio', command: 'node', args: [], cwd: 'relative/path' },
|
||||
},
|
||||
{
|
||||
name: 'oauth-missing-cache',
|
||||
command: { kind: 'http', url: new URL('https://example.com/mcp'), headers: {} },
|
||||
auth: 'oauth',
|
||||
tokenCacheDir: undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
await handleDoctorCommand({ loadOptions } as never, []);
|
||||
|
||||
const output = logSpy.mock.calls.flat().join('\n');
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(output).toContain('has a non-absolute working directory');
|
||||
expect(output).toContain('enables OAuth but lacks a token cache directory');
|
||||
});
|
||||
});
|
||||
24
tests/config-get-json.test.ts
Normal file
24
tests/config-get-json.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleGetCommand } from '../src/cli/config/get.js';
|
||||
import * as configModule from '../src/config.js';
|
||||
import type { ServerDefinition } from '../src/config-schema.js';
|
||||
|
||||
describe('config get --json', () => {
|
||||
it('includes headers and env in JSON output', async () => {
|
||||
const server: ServerDefinition = {
|
||||
name: 'http-one',
|
||||
command: { kind: 'http', url: new URL('https://api.example/mcp'), headers: { Authorization: 'Bearer token' } },
|
||||
env: { FOO: 'bar' },
|
||||
};
|
||||
vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([server]);
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleGetCommand({ loadOptions: {} } as never, ['http-one', '--json']);
|
||||
|
||||
const output = logSpy.mock.calls.map((call) => call[0]).join('\n');
|
||||
logSpy.mockRestore();
|
||||
const payload = JSON.parse(output) as { headers?: Record<string, string>; env?: Record<string, string> };
|
||||
expect(payload.headers?.Authorization).toBe('Bearer token');
|
||||
expect(payload.env?.FOO).toBe('bar');
|
||||
});
|
||||
});
|
||||
27
tests/config-import-dedupe.test.ts
Normal file
27
tests/config-import-dedupe.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleImportCommand } from '../src/cli/config/import.js';
|
||||
import * as importModule from '../src/config-imports.js';
|
||||
import { createTempConfig } from './fixtures/config-fixture.js';
|
||||
|
||||
describe('config import deduplication', () => {
|
||||
it('keeps first matching entry when multiple imports share names', async () => {
|
||||
const ctx = await createTempConfig();
|
||||
const firstPath = '/first.json';
|
||||
const secondPath = '/second.json';
|
||||
vi.spyOn(importModule, 'pathsForImport').mockReturnValue([firstPath, secondPath]);
|
||||
vi.spyOn(importModule, 'readExternalEntries')
|
||||
.mockResolvedValueOnce(new Map([['dup', { baseUrl: 'https://first.example/mcp' }]]) as never)
|
||||
.mockResolvedValueOnce(new Map([['dup', { baseUrl: 'https://second.example/mcp' }]]) as never);
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
await handleImportCommand({ loadOptions: ctx.loadOptions } as never, ['cursor', '--copy']);
|
||||
logSpy.mockRestore();
|
||||
|
||||
const buffer = await fs.readFile(ctx.loadOptions.configPath ?? '', 'utf8');
|
||||
const parsed = JSON.parse(buffer) as { mcpServers: Record<string, { baseUrl: string }> };
|
||||
expect(parsed.mcpServers.dup).toBeDefined();
|
||||
expect(parsed.mcpServers.dup?.baseUrl).toBe('https://first.example/mcp');
|
||||
await ctx.cleanup();
|
||||
});
|
||||
});
|
||||
64
tests/config-import.test.ts
Normal file
64
tests/config-import.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleImportCommand } from '../src/cli/config/import.js';
|
||||
import type { LoadConfigOptions, RawConfig } from '../src/config.js';
|
||||
import * as configModule from '../src/config.js';
|
||||
import * as importModule from '../src/config-imports.js';
|
||||
|
||||
let tempDir: string;
|
||||
let loadOptions: LoadConfigOptions;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-import-'));
|
||||
loadOptions = { rootDir: tempDir };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('config import', () => {
|
||||
it('copies filtered entries into project config', async () => {
|
||||
vi.spyOn(importModule, 'pathsForImport').mockReturnValue([path.join(tempDir, 'imports', 'cursor.json')]);
|
||||
vi.spyOn(importModule, 'readExternalEntries').mockResolvedValue(
|
||||
new Map([
|
||||
['keep', { baseUrl: 'https://example.com/mcp' }],
|
||||
['skip', { baseUrl: 'https://skip.example/mcp' }],
|
||||
]) as never
|
||||
);
|
||||
|
||||
let writtenConfig: RawConfig | undefined;
|
||||
vi.spyOn(configModule, 'writeRawConfig').mockImplementation(async (_path, config) => {
|
||||
writtenConfig = config as RawConfig;
|
||||
});
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
await handleImportCommand({ loadOptions } as never, ['cursor', '--copy', '--filter', 'keep']);
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(writtenConfig?.mcpServers?.keep).toBeDefined();
|
||||
expect(writtenConfig?.mcpServers?.skip).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits JSON when --json is provided', async () => {
|
||||
vi.spyOn(importModule, 'pathsForImport').mockReturnValue([path.join(tempDir, 'imports', 'cursor.json')]);
|
||||
vi.spyOn(importModule, 'readExternalEntries').mockResolvedValue(
|
||||
new Map([['keep', { baseUrl: 'https://example.com/mcp' }]]) as never
|
||||
);
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleImportCommand({ loadOptions } as never, ['cursor', '--json']);
|
||||
|
||||
const json = logSpy.mock.calls
|
||||
.map((call) => call[0])
|
||||
.find((msg) => typeof msg === 'string' && msg.trim().startsWith('{'));
|
||||
logSpy.mockRestore();
|
||||
expect(json).toBeDefined();
|
||||
const payload = JSON.parse(String(json)) as { entries: Array<{ name: string }> };
|
||||
expect(payload.entries).toHaveLength(1);
|
||||
expect(payload.entries[0]?.name).toBe('keep');
|
||||
});
|
||||
});
|
||||
33
tests/config-list-text-footer.test.ts
Normal file
33
tests/config-list-text-footer.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleListCommand } from '../src/cli/config/list.js';
|
||||
import * as shared from '../src/cli/config/shared.js';
|
||||
import * as configModule from '../src/config.js';
|
||||
import type { ServerDefinition } from '../src/config-schema.js';
|
||||
|
||||
describe('config list text footer and import summary', () => {
|
||||
it('prints footer paths and import summary when no local matches', async () => {
|
||||
const importServer: ServerDefinition = {
|
||||
name: 'imported',
|
||||
command: { kind: 'http', url: new URL('https://import.example/mcp'), headers: {} },
|
||||
source: { kind: 'import', path: '/tmp/import.json' },
|
||||
};
|
||||
vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([importServer]);
|
||||
vi.spyOn(shared, 'resolveConfigLocations').mockResolvedValue({
|
||||
projectPath: '/tmp/project/config/mcporter.json',
|
||||
projectExists: true,
|
||||
systemPath: '/Users/test/.mcporter/mcporter.json',
|
||||
systemExists: false,
|
||||
});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleListCommand({ loadOptions: {} } as never, []);
|
||||
|
||||
const output = logSpy.mock.calls.map((c) => c[0]).join('\n');
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(output).toContain('No local servers match');
|
||||
expect(output).toContain('Other sources available via --source import');
|
||||
expect(output).toContain('/tmp/project/config/mcporter.json');
|
||||
expect(output).toContain('/Users/test/.mcporter/mcporter.json (missing)');
|
||||
});
|
||||
});
|
||||
62
tests/config-list.test.ts
Normal file
62
tests/config-list.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleListCommand } from '../src/cli/config/list.js';
|
||||
import * as shared from '../src/cli/config/shared.js';
|
||||
import * as configModule from '../src/config.js';
|
||||
import type { ServerDefinition } from '../src/config-schema.js';
|
||||
|
||||
const localServer: ServerDefinition = {
|
||||
name: 'local-one',
|
||||
command: { kind: 'http', url: new URL('https://local.example/mcp'), headers: {} },
|
||||
};
|
||||
|
||||
const importServer: ServerDefinition = {
|
||||
name: 'import-one',
|
||||
command: { kind: 'stdio', command: 'bin', args: [], cwd: '/tmp' },
|
||||
source: { kind: 'import', path: '/imports/cursor.json' },
|
||||
};
|
||||
|
||||
describe('config list', () => {
|
||||
it('filters by source and outputs json payload', async () => {
|
||||
vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([localServer, importServer]);
|
||||
vi.spyOn(shared, 'resolveConfigLocations').mockResolvedValue({
|
||||
projectPath: '/tmp/config/mcporter.json',
|
||||
projectExists: true,
|
||||
systemPath: '/home/user/.mcporter/mcporter.json',
|
||||
systemExists: false,
|
||||
});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleListCommand({ loadOptions: {} } as never, ['--json', '--source', 'import']);
|
||||
|
||||
const jsonLine = logSpy.mock.calls
|
||||
.map((call) => call[0])
|
||||
.find((msg) => typeof msg === 'string' && msg.trim().startsWith('{'));
|
||||
logSpy.mockRestore();
|
||||
expect(jsonLine).toBeDefined();
|
||||
const payload = JSON.parse(String(jsonLine));
|
||||
expect(payload.servers).toHaveLength(1);
|
||||
expect(payload.servers[0].name).toBe('import-one');
|
||||
});
|
||||
|
||||
it('shows only local servers when source=local', async () => {
|
||||
vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([localServer, importServer]);
|
||||
vi.spyOn(shared, 'resolveConfigLocations').mockResolvedValue({
|
||||
projectPath: '/tmp/config/mcporter.json',
|
||||
projectExists: true,
|
||||
systemPath: '/home/user/.mcporter/mcporter.json',
|
||||
systemExists: false,
|
||||
});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleListCommand({ loadOptions: {} } as never, ['--json', '--source', 'local']);
|
||||
|
||||
const jsonLine = logSpy.mock.calls
|
||||
.map((call) => call[0])
|
||||
.find((msg) => typeof msg === 'string' && msg.trim().startsWith('{'));
|
||||
logSpy.mockRestore();
|
||||
expect(jsonLine).toBeDefined();
|
||||
const payload = JSON.parse(String(jsonLine));
|
||||
expect(payload.servers).toHaveLength(1);
|
||||
expect(payload.servers[0].name).toBe('local-one');
|
||||
});
|
||||
});
|
||||
44
tests/config-remove.test.ts
Normal file
44
tests/config-remove.test.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleRemoveCommand } from '../src/cli/config/remove.js';
|
||||
import type { LoadConfigOptions, RawConfig } from '../src/config.js';
|
||||
import { createTempConfig } from './fixtures/config-fixture.js';
|
||||
|
||||
describe('config remove', () => {
|
||||
let loadOptions: LoadConfigOptions;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
const initial: RawConfig = {
|
||||
mcpServers: {
|
||||
linear: { baseUrl: 'https://linear.app/mcp' },
|
||||
alpha: { baseUrl: 'https://alpha.example/mcp' },
|
||||
},
|
||||
imports: [],
|
||||
};
|
||||
const ctx = await createTempConfig(initial);
|
||||
loadOptions = ctx.loadOptions;
|
||||
cleanup = () => ctx.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (cleanup) {
|
||||
await cleanup();
|
||||
cleanup = undefined;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fuzzy-matches server names when removing entries', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await handleRemoveCommand({ loadOptions } as never, ['linr']);
|
||||
|
||||
const buffer = await fs.readFile(loadOptions.configPath ?? '', 'utf8');
|
||||
const parsed = JSON.parse(buffer) as RawConfig;
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(parsed.mcpServers?.linear).toBeUndefined();
|
||||
expect(parsed.mcpServers?.alpha).toBeDefined();
|
||||
});
|
||||
});
|
||||
59
tests/config-render.test.ts
Normal file
59
tests/config-render.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { serializeDefinition } from '../src/cli/config/render.js';
|
||||
import type { ServerDefinition } from '../src/config-schema.js';
|
||||
|
||||
describe('config render helpers', () => {
|
||||
it('serializes HTTP definitions with headers and oauth fields', () => {
|
||||
const definition: ServerDefinition = {
|
||||
name: 'http-server',
|
||||
description: 'A test server',
|
||||
command: {
|
||||
kind: 'http',
|
||||
url: new URL('https://example.com/mcp'),
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
source: { kind: 'import', path: '/tmp/source.json' },
|
||||
auth: 'oauth',
|
||||
tokenCacheDir: '/tmp/cache',
|
||||
clientName: 'mcporter',
|
||||
oauthRedirectUrl: 'https://example.com/callback',
|
||||
env: { FOO: 'bar' },
|
||||
};
|
||||
|
||||
const payload = serializeDefinition(definition);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
transport: 'http',
|
||||
baseUrl: 'https://example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
auth: 'oauth',
|
||||
tokenCacheDir: '/tmp/cache',
|
||||
clientName: 'mcporter',
|
||||
oauthRedirectUrl: 'https://example.com/callback',
|
||||
env: { FOO: 'bar' },
|
||||
source: { kind: 'import', path: '/tmp/source.json' },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes stdio definitions with command metadata', () => {
|
||||
const definition: ServerDefinition = {
|
||||
name: 'stdio-server',
|
||||
command: {
|
||||
kind: 'stdio',
|
||||
command: 'node',
|
||||
args: ['--version'],
|
||||
cwd: '/tmp',
|
||||
},
|
||||
};
|
||||
|
||||
const payload = serializeDefinition(definition);
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['--version'],
|
||||
cwd: '/tmp',
|
||||
name: 'stdio-server',
|
||||
});
|
||||
});
|
||||
});
|
||||
41
tests/config-shared.test.ts
Normal file
41
tests/config-shared.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { findServerNameWithFuzzyMatch, resolveServerDefinition } from '../src/cli/config/shared.js';
|
||||
import type { ServerDefinition } from '../src/config-schema.js';
|
||||
|
||||
function buildServers(): ServerDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'linear',
|
||||
command: { kind: 'http', url: new URL('https://linear.app/mcp'), headers: {} },
|
||||
},
|
||||
{
|
||||
name: 'slack',
|
||||
command: { kind: 'stdio', command: 'slack-mcp', args: [], cwd: '/tmp' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('config shared helpers', () => {
|
||||
it('auto-corrects close names when resolving server definitions', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const server = resolveServerDefinition('linea', buildServers(), logSpy);
|
||||
logSpy.mockRestore();
|
||||
expect(server.name).toBe('linear');
|
||||
});
|
||||
|
||||
it('throws when no close match exists', () => {
|
||||
expect(() => resolveServerDefinition('unknown', buildServers(), null)).toThrow(/Unknown server/);
|
||||
});
|
||||
|
||||
it('returns matched name when helper finds direct match', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const match = findServerNameWithFuzzyMatch('linear', ['linear', 'other'], logSpy);
|
||||
logSpy.mockRestore();
|
||||
expect(match).toBe('linear');
|
||||
});
|
||||
|
||||
it('returns null when fuzzy helper has no candidate', () => {
|
||||
const match = findServerNameWithFuzzyMatch('nothing', ['alpha', 'beta'], null);
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
29
tests/fixtures/config-fixture.ts
vendored
Normal file
29
tests/fixtures/config-fixture.ts
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { LoadConfigOptions, RawConfig } from '../../src/config.js';
|
||||
|
||||
type TempConfigCtx = {
|
||||
tempDir: string;
|
||||
configPath: string;
|
||||
loadOptions: LoadConfigOptions;
|
||||
cleanup(): Promise<void>;
|
||||
};
|
||||
|
||||
export async function createTempConfig(initial?: RawConfig): Promise<TempConfigCtx> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-config-fixture-'));
|
||||
const configPath = path.join(tempDir, 'config', 'mcporter.json');
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
if (initial) {
|
||||
await fs.writeFile(configPath, JSON.stringify(initial), 'utf8');
|
||||
}
|
||||
const loadOptions: LoadConfigOptions = { rootDir: tempDir, configPath };
|
||||
return {
|
||||
tempDir,
|
||||
configPath,
|
||||
loadOptions,
|
||||
async cleanup() {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user