feat(list): show verbose config sources
This commit is contained in:
parent
87775e9ffb
commit
575fd0e16b
@ -4,6 +4,12 @@
|
||||
|
||||
_No changes yet._
|
||||
|
||||
## [0.6.1] - 2025-11-17
|
||||
|
||||
### CLI
|
||||
- `mcporter list --verbose` now surfaces every config path that registers the target server (primary first, then duplicates) in both text and JSON output, making it easier to trace where a name is coming from.
|
||||
- JSON list payloads include a new `sources` array when `--verbose` is set, mirroring the on-screen path list for programmatic consumers.
|
||||
|
||||
## [0.6.0] - 2025-11-16
|
||||
|
||||
### Configuration
|
||||
|
||||
@ -37,6 +37,7 @@ npx mcporter list --stdio "bun run ./local-server.ts" --env TOKEN=xyz
|
||||
```
|
||||
|
||||
- Add `--json` to emit a machine-readable summary with per-server statuses (auth/offline/http/error counts) and, for single-server runs, the full tool schema payload.
|
||||
- Add `--verbose` to show every config source that registered the server name (primary first), both in text and JSON list output.
|
||||
|
||||
You can now point `mcporter list` at ad-hoc servers: provide a URL directly or use the new `--http-url/--stdio` flags (plus `--env`, `--cwd`, `--name`, or `--persist`) to describe any MCP endpoint. Until you persist that definition, you still need to repeat the same URL/stdio flags for `mcporter call`—the printed slug only becomes reusable once you merge it into a config via `--persist` or `mcporter config add` (use `--scope home|project` to pick the write target). Follow up with `mcporter auth https://…` (or the same flag set) to finish OAuth without editing config. Full details live in [docs/adhoc.md](docs/adhoc.md).
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mcporter",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@ -32,10 +32,12 @@ export function extractListFlags(args: string[]): {
|
||||
requiredOnly: boolean;
|
||||
ephemeral?: EphemeralServerSpec;
|
||||
format: ListOutputFormat;
|
||||
verbose: boolean;
|
||||
} {
|
||||
let schema = false;
|
||||
let timeoutMs: number | undefined;
|
||||
let requiredOnly = true;
|
||||
let verbose = false;
|
||||
const format = consumeOutputFormat(args, {
|
||||
defaultFormat: 'text',
|
||||
allowed: ['text', 'json'],
|
||||
@ -60,13 +62,18 @@ export function extractListFlags(args: string[]): {
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--verbose') {
|
||||
verbose = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
if (token === '--timeout') {
|
||||
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return { schema, timeoutMs, requiredOnly, ephemeral, format };
|
||||
return { schema, timeoutMs, requiredOnly, ephemeral, format, verbose };
|
||||
}
|
||||
|
||||
type ListOutputFormat = 'text' | 'json';
|
||||
@ -159,7 +166,7 @@ export async function handleList(
|
||||
})().then((result) => {
|
||||
summaryResults[index] = result;
|
||||
if (renderedResults) {
|
||||
const rendered = renderServerListRow(result, perServerTimeoutMs);
|
||||
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
|
||||
renderedResults[index] = rendered;
|
||||
completedCount += 1;
|
||||
if (spinner) {
|
||||
@ -189,6 +196,7 @@ export async function handleList(
|
||||
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
|
||||
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
|
||||
includeSchemas: Boolean(flags.schema),
|
||||
includeSources: Boolean(flags.verbose),
|
||||
});
|
||||
});
|
||||
const counts = summarizeStatusCounts(jsonEntries);
|
||||
@ -230,8 +238,8 @@ export async function handleList(
|
||||
const definition = resolved.definition;
|
||||
const timeoutMs = flags.timeoutMs ?? LIST_TIMEOUT_MS;
|
||||
const sourcePath =
|
||||
definition.source?.kind === 'import' || definition.source?.kind === 'local'
|
||||
? formatSourceSuffix(definition.source, true)
|
||||
definition.sources?.length || definition.source
|
||||
? formatSourceSuffix(definition.sources ?? definition.source, true, { verbose: flags.verbose })
|
||||
: undefined;
|
||||
const transportSummary = formatTransportSummary(definition);
|
||||
const startedAt = Date.now();
|
||||
@ -247,6 +255,7 @@ export async function handleList(
|
||||
description: definition.description,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose ? definition.sources : undefined,
|
||||
tools: metadataEntries.map((entry) => ({
|
||||
name: entry.tool.name,
|
||||
description: entry.tool.description,
|
||||
@ -269,6 +278,7 @@ export async function handleList(
|
||||
description: definition.description,
|
||||
transport: transportSummary,
|
||||
source: definition.source,
|
||||
sources: flags.verbose ? definition.sources : undefined,
|
||||
issue: advice.issue,
|
||||
authCommand: advice.authCommand,
|
||||
error: advice.summary,
|
||||
@ -359,6 +369,7 @@ export function printListHelp(): void {
|
||||
' --schema Show tool schemas when listing servers.',
|
||||
' --all-parameters Include optional parameters in tool docs.',
|
||||
' --json Emit a JSON summary instead of text.',
|
||||
' --verbose Show all config sources for matching servers.',
|
||||
' --timeout <ms> Override the per-server discovery timeout.',
|
||||
'',
|
||||
'Examples:',
|
||||
|
||||
@ -23,7 +23,8 @@ export type ListSummaryResult =
|
||||
|
||||
export function renderServerListRow(
|
||||
result: ListSummaryResult,
|
||||
timeoutMs: number
|
||||
timeoutMs: number,
|
||||
options: { verbose?: boolean } = {}
|
||||
): {
|
||||
line: string;
|
||||
summary: string;
|
||||
@ -33,7 +34,9 @@ export function renderServerListRow(
|
||||
} {
|
||||
const description = result.server.description ? dimText(` — ${result.server.description}`) : '';
|
||||
const durationLabel = dimText(`${(result.durationMs / 1000).toFixed(1)}s`);
|
||||
const sourceSuffix = formatSourceSuffix(result.server.source);
|
||||
const sourceSuffix = formatSourceSuffix(result.server.sources ?? result.server.source, false, {
|
||||
verbose: options.verbose,
|
||||
});
|
||||
const prefix = `- ${result.server.name}${description}`;
|
||||
|
||||
if (result.status === 'ok') {
|
||||
@ -66,13 +69,29 @@ export function truncateForSpinner(text: string, maxLength = 72): string {
|
||||
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
||||
}
|
||||
|
||||
export function formatSourceSuffix(source: ServerSource | undefined, inline = false): string {
|
||||
if (!source || source.kind !== 'import') {
|
||||
export function formatSourceSuffix(
|
||||
sourceOrSources: ServerSource | readonly ServerSource[] | undefined,
|
||||
inline = false,
|
||||
options: { verbose?: boolean } = {}
|
||||
): string {
|
||||
const sources = Array.isArray(sourceOrSources) ? [...sourceOrSources] : sourceOrSources ? [sourceOrSources] : [];
|
||||
if (sources.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const formatted = formatPathForDisplay(source.path);
|
||||
const text = inline ? formatted : `[source: ${formatted}]`;
|
||||
const tinted = extraDimText(text);
|
||||
const verbose = options.verbose ?? false;
|
||||
if (!verbose) {
|
||||
const primary = sources[0];
|
||||
if (primary.kind !== 'import') {
|
||||
return '';
|
||||
}
|
||||
const formatted = formatPathForDisplay(primary.path);
|
||||
const tinted = extraDimText(inline ? formatted : `[source: ${formatted}]`);
|
||||
return inline ? tinted : ` ${tinted}`;
|
||||
}
|
||||
// When verbose, show every contributing source (primary first) so duplicates are discoverable.
|
||||
const formatted = sources.map((entry) => formatPathForDisplay(entry.path));
|
||||
const label = sources.length === 1 ? `source: ${formatted[0]}` : `sources: ${formatted.join(' · ')}`;
|
||||
const tinted = extraDimText(inline ? label : `[${label}]`);
|
||||
return inline ? tinted : ` ${tinted}`;
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export interface ListJsonServerEntry {
|
||||
description?: string;
|
||||
transport?: string;
|
||||
source?: ServerDefinition['source'];
|
||||
sources?: ServerDefinition['sources'];
|
||||
tools?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
@ -139,7 +140,7 @@ export function summarizeStatusCounts(entries: ListJsonServerEntry[]): Record<St
|
||||
export function buildJsonListEntry(
|
||||
result: ListSummaryResult,
|
||||
timeoutSeconds: number,
|
||||
options: { includeSchemas: boolean }
|
||||
options: { includeSchemas: boolean; includeSources?: boolean }
|
||||
): ListJsonServerEntry {
|
||||
if (result.status === 'ok') {
|
||||
return {
|
||||
@ -153,6 +154,7 @@ export function buildJsonListEntry(
|
||||
>
|
||||
),
|
||||
source: result.server.source,
|
||||
sources: options.includeSources ? result.server.sources : undefined,
|
||||
tools: result.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@ -174,6 +176,7 @@ export function buildJsonListEntry(
|
||||
result.server as ReturnType<Awaited<ReturnType<typeof import('../runtime.js')['createRuntime']>>['getDefinition']>
|
||||
),
|
||||
source: result.server.source,
|
||||
sources: options.includeSources ? result.server.sources : undefined,
|
||||
issue: serializeConnectionIssue(advice.issue),
|
||||
authCommand: advice.authCommand,
|
||||
error: formatErrorMessage(result.error),
|
||||
|
||||
@ -8,7 +8,8 @@ export function normalizeServerEntry(
|
||||
name: string,
|
||||
raw: RawEntry,
|
||||
baseDir: string,
|
||||
source: ServerSource
|
||||
source: ServerSource,
|
||||
sources: readonly ServerSource[]
|
||||
): ServerDefinition {
|
||||
const description = raw.description;
|
||||
const env = raw.env ? { ...raw.env } : undefined;
|
||||
@ -56,6 +57,7 @@ export function normalizeServerEntry(
|
||||
clientName,
|
||||
oauthRedirectUrl,
|
||||
source,
|
||||
sources,
|
||||
lifecycle,
|
||||
logging,
|
||||
};
|
||||
|
||||
@ -123,6 +123,7 @@ export interface ServerDefinition {
|
||||
readonly clientName?: string;
|
||||
readonly oauthRedirectUrl?: string;
|
||||
readonly source?: ServerSource;
|
||||
readonly sources?: readonly ServerSource[];
|
||||
readonly lifecycle?: ServerLifecycle;
|
||||
readonly logging?: ServerLoggingOptions;
|
||||
}
|
||||
|
||||
@ -35,7 +35,7 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
||||
const rootDir = options.rootDir ?? process.cwd();
|
||||
const layers = await loadConfigLayers(options, rootDir);
|
||||
|
||||
const merged = new Map<string, { raw: RawEntry; baseDir: string; source: ServerSource }>();
|
||||
const merged = new Map<string, { raw: RawEntry; baseDir: string; source: ServerSource; sources: ServerSource[] }>();
|
||||
|
||||
for (const layer of layers) {
|
||||
const configuredImports = layer.config.imports;
|
||||
@ -57,27 +57,45 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
||||
if (merged.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const source: ServerSource = { kind: 'import', path: resolved };
|
||||
const existing = merged.get(name);
|
||||
// Keep the first-seen source as canonical while tracking all alternates
|
||||
if (existing) {
|
||||
existing.sources.push(source);
|
||||
continue;
|
||||
}
|
||||
merged.set(name, {
|
||||
raw: rawEntry,
|
||||
baseDir: path.dirname(resolved),
|
||||
source: { kind: 'import', path: resolved },
|
||||
source,
|
||||
sources: [source],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, entryRaw] of Object.entries(layer.config.mcpServers)) {
|
||||
const source: ServerSource = { kind: 'local', path: layer.path };
|
||||
const parsed = RawEntrySchema.parse(entryRaw);
|
||||
const existing = merged.get(name);
|
||||
// Local definitions win; stash any prior imports after the local path
|
||||
if (existing) {
|
||||
const sources = [source, ...existing.sources];
|
||||
merged.set(name, { raw: parsed, baseDir: path.dirname(layer.path), source, sources });
|
||||
continue;
|
||||
}
|
||||
merged.set(name, {
|
||||
raw: RawEntrySchema.parse(entryRaw),
|
||||
raw: parsed,
|
||||
baseDir: path.dirname(layer.path),
|
||||
source: { kind: 'local', path: layer.path },
|
||||
source,
|
||||
sources: [source],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const servers: ServerDefinition[] = [];
|
||||
for (const [name, { raw, baseDir: entryBaseDir, source }] of merged) {
|
||||
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source));
|
||||
for (const [name, { raw, baseDir: entryBaseDir, source, sources }] of merged) {
|
||||
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
|
||||
}
|
||||
|
||||
return servers;
|
||||
|
||||
58
tests/config-sources.test.ts
Normal file
58
tests/config-sources.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
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 { loadServerDefinitions } from '../src/config.js';
|
||||
|
||||
describe('config sources tracking', () => {
|
||||
let tempDir: string;
|
||||
let originalCwd: string;
|
||||
let restoreHomedir: (() => void) | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalCwd = process.cwd();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-config-sources-'));
|
||||
process.chdir(tempDir);
|
||||
const spy = vi.spyOn(os, 'homedir');
|
||||
spy.mockReturnValue(tempDir);
|
||||
restoreHomedir = () => spy.mockRestore();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreHomedir?.();
|
||||
process.chdir(originalCwd);
|
||||
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('keeps primary definition first and includes alternate sources when duplicates exist', async () => {
|
||||
const projectConfigPath = path.join(tempDir, 'config', 'mcporter.json');
|
||||
await fs.mkdir(path.dirname(projectConfigPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
projectConfigPath,
|
||||
JSON.stringify(
|
||||
{ imports: ['cursor'], mcpServers: { alpha: { baseUrl: 'https://primary.example.com/mcp' } } },
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const cursorConfigPath = path.join(tempDir, '.cursor', 'mcp.json');
|
||||
await fs.mkdir(path.dirname(cursorConfigPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
cursorConfigPath,
|
||||
JSON.stringify({ mcpServers: { alpha: { baseUrl: 'https://shadow.example.com/mcp' } } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const definitions = await loadServerDefinitions({ configPath: projectConfigPath, rootDir: tempDir });
|
||||
|
||||
expect(definitions).toHaveLength(1);
|
||||
const definition = definitions[0];
|
||||
if (!definition) {
|
||||
throw new Error('definition should be present');
|
||||
}
|
||||
expect(definition.source?.path).toBe(projectConfigPath);
|
||||
expect(definition.sources?.map((entry) => entry.path)).toEqual([projectConfigPath, cursorConfigPath]);
|
||||
});
|
||||
});
|
||||
@ -1,29 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatSourceSuffix } from '../src/cli/list-format.js';
|
||||
import { stripAnsi } from './fixtures/cli-list-fixtures.js';
|
||||
|
||||
import { classifyListError } from '../src/cli/list-format.js';
|
||||
|
||||
describe('classifyListError', () => {
|
||||
it('respects custom auth command hints', () => {
|
||||
const result = classifyListError(new Error('SSE error: Non-200 status code (401)'), 'adhoc-server', 30, {
|
||||
authCommand: 'mcporter auth https://example.com/mcp',
|
||||
});
|
||||
expect(result.category).toBe('auth');
|
||||
expect(result.authCommand).toBe('mcporter auth https://example.com/mcp');
|
||||
expect(result.colored).toContain('mcporter auth https://example.com/mcp');
|
||||
expect(result.issue?.kind).toBe('auth');
|
||||
describe('list format helpers', () => {
|
||||
it('shows only primary import path by default', () => {
|
||||
const suffix = formatSourceSuffix({ kind: 'import', path: '/home/user/.cursor/mcp.json' });
|
||||
expect(stripAnsi(suffix)).toContain('[source: /home/user/.cursor/mcp.json]');
|
||||
});
|
||||
|
||||
it('classifies transport errors as offline', () => {
|
||||
const result = classifyListError(new Error('fetch failed: connect ECONNREFUSED 127.0.0.1:3000'), 'local', 30);
|
||||
expect(result.category).toBe('offline');
|
||||
expect(result.summary).toBe('offline');
|
||||
expect(result.issue?.kind).toBe('offline');
|
||||
});
|
||||
|
||||
it('classifies HTTP errors separately', () => {
|
||||
const result = classifyListError(new Error('HTTP error 500: upstream unavailable'), 'remote', 30);
|
||||
expect(result.category).toBe('http');
|
||||
expect(result.summary).toContain('http 500');
|
||||
expect(result.issue?.kind).toBe('http');
|
||||
it('shows all sources when verbose is enabled and preserves order', () => {
|
||||
const suffix = formatSourceSuffix(
|
||||
[
|
||||
{ kind: 'import', path: '/project/config/mcporter.json' },
|
||||
{ kind: 'import', path: '/home/user/.cursor/mcp.json' },
|
||||
],
|
||||
false,
|
||||
{ verbose: true }
|
||||
);
|
||||
const plain = stripAnsi(suffix);
|
||||
expect(plain).toContain('[sources: /project/config/mcporter.json · /home/user/.cursor/mcp.json]');
|
||||
});
|
||||
});
|
||||
|
||||
@ -107,6 +107,30 @@ describe('list output helpers', () => {
|
||||
expect(entry.authCommand).toBe(buildAuthCommandHint(definition));
|
||||
});
|
||||
|
||||
it('exposes source list in JSON only when includeSources is true', () => {
|
||||
const withSources: ServerDefinition = {
|
||||
...definition,
|
||||
source: { kind: 'local', path: '/project/config/mcporter.json' },
|
||||
sources: [
|
||||
{ kind: 'local', path: '/project/config/mcporter.json' },
|
||||
{ kind: 'import', path: '/home/.cursor/mcp.json' },
|
||||
],
|
||||
};
|
||||
const summary: ListSummaryResult = {
|
||||
status: 'ok',
|
||||
server: withSources,
|
||||
durationMs: 10,
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const minimal = buildJsonListEntry(summary, 30, { includeSchemas: false });
|
||||
expect(minimal.sources).toBeUndefined();
|
||||
|
||||
const verbose = buildJsonListEntry(summary, 30, { includeSchemas: false, includeSources: true });
|
||||
expect(verbose.sources).toEqual(withSources.sources);
|
||||
expect(verbose.source).toEqual(withSources.source);
|
||||
});
|
||||
|
||||
it('creates empty status counts with zeroed categories', () => {
|
||||
const counts = createEmptyStatusCounts();
|
||||
expect(counts).toEqual({ ok: 0, auth: 0, offline: 0, http: 0, error: 0 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user