feat(list): show verbose config sources

This commit is contained in:
Peter Steinberger 2025-11-17 08:16:26 +01:00
parent 87775e9ffb
commit 575fd0e16b
12 changed files with 180 additions and 43 deletions

View File

@ -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

View File

@ -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).

View File

@ -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",

View File

@ -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:',

View File

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

View File

@ -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),

View File

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

View File

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

View File

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

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

View File

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

View File

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