Compare commits

...

1 Commits

Author SHA1 Message Date
Peter Steinberger
708db33e58
feat: add list health check flags 2026-05-20 20:53:15 +01:00
8 changed files with 432 additions and 173 deletions

View File

@ -4,6 +4,7 @@
### CLI
- Add `mcporter list --status`, `--exit-code`, and `--quiet` for concise server health checks without introducing a separate health command.
- Make `generate-cli --bundle` artifacts deterministic by removing bundle-only paths/timestamps from embedded metadata and sorting generated tool/schema output. (Issue #180, thanks @imroc)
- Let daemon-managed OAuth servers reuse cached credentials for tool calls and tool listing after token expiry. (PR #182 / issue #181, thanks @bradhallett)
- Avoid restarting browser OAuth when an already-connected server has a still-valid cached access token. (Issue #179, thanks @jaigew and @StanAngeloff)

View File

@ -60,6 +60,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 `--status` for a concise single-server status check without tool docs, `--exit-code` to fail when any checked server is unhealthy, or `--quiet` for silent health gates.
- 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).
@ -163,6 +164,7 @@ Helpful flags:
- `--no-coerce` (on `mcporter call`) -- keep all `key=value` and positional values as raw strings (disables bool/null/number/JSON coercion).
- `--` (on `mcporter call`) -- stop flag parsing so the remaining tokens stay literal positional values, even when they start with `--`.
- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata.
- `--status`, `--exit-code`, `--quiet` (on `mcporter list`) -- run concise server health checks through the existing list flow; `--quiet` suppresses output and exits 1 if anything checked is unhealthy.
- `--output json/raw` (on `mcporter call`) -- when a connection fails, MCPorter prints the usual colorized hint and also emits a structured `{ server, tool, issue }` envelope so scripts can handle auth/offline/http errors programmatically.
- `--json` (on `mcporter auth`) -- emit the same structured connection envelope whenever OAuth/transport setup fails, instead of throwing an error. With `--no-browser`, it emits auth-start JSON containing `authorizationUrl` and `redirectUrl`.
- `--no-browser` / `--browser none` (on `mcporter auth` or `mcporter config login`) -- suppress browser launch and print the OAuth authorization URL for headless workflows; `MCPORTER_OAUTH_NO_BROWSER=1` / `true` / `yes` enables the same behavior.

View File

@ -21,6 +21,10 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- Add `--brief` or `--signatures` with a server or `server.tool` target to keep
the server header/instructions and print compact signatures without doc
comments, examples, or schemas.
- Add `--status` with a server target to print only the concise status row
instead of full tool docs.
- Add `--exit-code` to make the command exit 1 when any checked server is
unhealthy, or `--quiet` to suppress output and imply `--exit-code`.
- Hidden alias: `list-tools` (kept for muscle memory; not advertised in help output).
- Hidden ad-hoc flag aliases: `--sse` for `--http-url`, `--insecure` for `--allow-http` (for plain HTTP testing).
- Flags:
@ -29,6 +33,10 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
- `--signatures` alias for `--brief`.
- `--all-parameters` include every optional parameter in the signature.
- `--schema` pretty-print the JSON schema for each tool.
- `--status` check server status only; cannot be combined with `--brief`,
`--schema`, or `--all-parameters`.
- `--exit-code` exit 1 when any checked server is unhealthy.
- `--quiet` suppress output and exit 1 when any checked server is unhealthy.
- `--timeout <ms>` per-server timeout when enumerating all servers.
## `mcporter call <server.tool>`

View File

@ -12,7 +12,7 @@ This walkthrough assumes you already have an MCP server configured in Cursor, Cl
npx mcporter list
```
You get one row per server with auth status, transport type, and tool count. Add `--json` for machine output, or `--verbose` to see which config files registered each server.
You get one row per server with auth status, transport type, and tool count. Add `--json` for machine output, `--quiet` for a silent health gate, or `--verbose` to see which config files registered each server.
## 2. Inspect a single server
@ -26,6 +26,7 @@ Single-server output reads like a TypeScript header file: dimmed `/** … */` do
- `--all-parameters` — show every optional parameter inline.
- `--schema` — pretty-print the JSON schema for each tool.
- `--json` — machine-readable schema payload.
- `--status` — concise status only, without tool docs.
`mcporter list shadcn.io/api/mcp.getComponents` works too — bare URLs (with or without a `.tool` suffix or scheme) auto-resolve.

View File

@ -60,6 +60,9 @@ export async function handleList(
const perServerTimeoutSeconds = Math.round(perServerTimeoutMs / 1000);
if (servers.length === 0) {
if (flags.quiet) {
return;
}
if (flags.format === 'json') {
const payload = {
mode: 'list',
@ -73,17 +76,17 @@ export async function handleList(
return;
}
if (flags.format === 'text') {
if (!flags.quiet && flags.format === 'text') {
console.log(
`mcporter ${MCPORTER_VERSION} — Listing ${servers.length} server(s) (per-server timeout: ${perServerTimeoutSeconds}s)`
);
}
const spinner =
flags.format === 'text' && supportsSpinner
!flags.quiet && flags.format === 'text' && supportsSpinner
? ora(`Discovering ${servers.length} server(s)…`).start()
: undefined;
const renderedResults =
flags.format === 'text'
!flags.quiet && flags.format === 'text'
? (Array.from({ length: servers.length }, () => undefined) as Array<
ReturnType<typeof renderServerListRow> | undefined
>)
@ -95,28 +98,7 @@ export async function handleList(
let completedCount = 0;
const tasks = servers.map((server, index) =>
(async (): Promise<ListSummaryResult> => {
const startedAt = Date.now();
try {
const tools = await withTimeout(
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
perServerTimeoutMs
);
return {
server,
status: 'ok' as const,
tools,
durationMs: Date.now() - startedAt,
};
} catch (error) {
return {
server,
status: 'error' as const,
error,
durationMs: Date.now() - startedAt,
};
}
})().then((result) => {
checkListServer(runtime, server, perServerTimeoutMs).then((result) => {
summaryResults[index] = result;
if (renderedResults) {
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
@ -139,20 +121,25 @@ export async function handleList(
);
await Promise.all(tasks);
const jsonEntries = summaryResults.map((entry, index) => {
const serverDefinition = servers[index] ?? entry?.server ?? servers[0];
if (!serverDefinition) {
throw new Error('Unable to resolve server definition for JSON output.');
}
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
includeSchemas: Boolean(flags.schema),
includeSources: Boolean(flags.verbose || flags.includeSources),
});
});
const counts = summarizeStatusCounts(jsonEntries);
maybeSetListExitCode(jsonEntries, flags);
if (flags.quiet) {
return;
}
if (flags.format === 'json') {
const jsonEntries = summaryResults.map((entry, index) => {
const serverDefinition = servers[index] ?? entry?.server ?? servers[0];
if (!serverDefinition) {
throw new Error('Unable to resolve server definition for JSON output.');
}
const normalizedEntry = entry ?? createUnknownResult(serverDefinition);
return buildJsonListEntry(normalizedEntry, perServerTimeoutSeconds, {
includeSchemas: Boolean(flags.schema),
includeSources: Boolean(flags.verbose || flags.includeSources),
});
});
const counts = summarizeStatusCounts(jsonEntries);
console.log(JSON.stringify({ mode: 'list', counts, servers: jsonEntries }, null, 2));
return;
}
@ -160,21 +147,13 @@ export async function handleList(
if (spinner) {
spinner.stop();
}
const errorCounts = createEmptyStatusCounts();
renderedResults?.forEach((entry) => {
if (!entry) {
return;
}
const category = entry.category ?? 'error';
errorCounts[category] = (errorCounts[category] ?? 0) + 1;
});
const okSummary = `${errorCounts.ok} healthy`;
const okSummary = `${counts.ok} healthy`;
const parts = [
okSummary,
...(errorCounts.auth > 0 ? [`${errorCounts.auth} auth required`] : []),
...(errorCounts.offline > 0 ? [`${errorCounts.offline} offline`] : []),
...(errorCounts.http > 0 ? [`${errorCounts.http} http errors`] : []),
...(errorCounts.error > 0 ? [`${errorCounts.error} errors`] : []),
...(counts.auth > 0 ? [`${counts.auth} auth required`] : []),
...(counts.offline > 0 ? [`${counts.offline} offline`] : []),
...(counts.http > 0 ? [`${counts.http} http errors`] : []),
...(counts.error > 0 ? [`${counts.error} errors`] : []),
];
console.log(`✔ Listed ${servers.length} server${servers.length === 1 ? '' : 's'} (${parts.join('; ')}).`);
return;
@ -190,9 +169,13 @@ export async function handleList(
requestedTool = selector.tool;
}
}
if (flags.statusOnly && requestedTool) {
throw new Error('--status cannot be used with a tool selector.');
}
const resolved = resolveServerDefinition(runtime, target);
const resolved = resolveServerDefinition(runtime, target, { quiet: flags.quiet });
if (!resolved) {
maybeSetListExitCode([{ status: 'error' }], flags);
return;
}
target = resolved.name;
@ -204,8 +187,111 @@ export async function handleList(
: undefined;
const transportSummary = formatTransportSummary(definition);
const startedAt = Date.now();
if (flags.format === 'json') {
if (flags.statusOnly) {
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
try {
const result = await checkListServer(runtime, definition, timeoutMs);
await persistPreparedEphemeralServer(runtime, prepared);
const entry = buildJsonListEntry(result, Math.round(timeoutMs / 1000), {
includeSchemas: false,
includeSources: Boolean(flags.verbose || flags.includeSources),
});
maybeSetListExitCode([entry], flags);
if (flags.quiet) {
return;
}
if (flags.format === 'json') {
console.log(
JSON.stringify({ mode: 'list', counts: summarizeStatusCounts([entry]), servers: [entry] }, null, 2)
);
return;
}
const rendered = renderServerListRow(result, timeoutMs, { verbose: flags.verbose });
console.log(rendered.line);
console.log(
`✔ Listed 1 server (${entry.status === 'ok' ? '1 healthy' : `0 healthy; 1 ${statusLabel(entry.status)}`}).`
);
return;
} finally {
if (previousStdioLogMode !== undefined) {
setStdioLogMode(previousStdioLogMode);
}
}
}
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
try {
if (flags.format === 'json') {
try {
const metadataEntries = filterToolMetadata(
await withTimeout(
loadToolMetadata(runtime, target, {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
}),
timeoutMs
),
requestedTool
);
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
if (requestedTool && metadataEntries.length === 0) {
if (!flags.quiet) {
printMissingToolJson(definition, requestedTool, durationMs, transportSummary, flags);
}
process.exitCode = 1;
return;
}
const instructions = await loadServerInstructions(runtime, target);
const payload = {
mode: 'server',
name: definition.name,
status: 'ok' as StatusCategory,
durationMs,
description: definition.description,
instructions,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
tools: metadataEntries.map((entry) => ({
name: entry.tool.name,
description: entry.tool.description,
inputSchema: entry.tool.inputSchema,
outputSchema: entry.tool.outputSchema,
options: entry.options,
})),
};
if (!flags.quiet) {
console.log(JSON.stringify(payload, null, 2));
}
return;
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
const authCommand = buildAuthCommandHint(definition);
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
const payload = {
mode: 'server',
name: definition.name,
status: advice.category,
durationMs,
description: definition.description,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
issue: advice.issue,
authCommand: advice.authCommand,
error: advice.summary,
};
if (!flags.quiet) {
console.log(JSON.stringify(payload, null, 2));
}
process.exitCode = 1;
return;
}
}
try {
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
const metadataEntries = filterToolMetadata(
await withTimeout(
loadToolMetadata(runtime, target, {
@ -220,96 +306,62 @@ export async function handleList(
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
if (requestedTool && metadataEntries.length === 0) {
printMissingToolJson(definition, requestedTool, durationMs, transportSummary, flags);
if (!flags.quiet) {
printMissingToolText(definition, requestedTool, durationMs, transportSummary, sourcePath);
}
process.exitCode = 1;
return;
}
if (flags.quiet) {
return;
}
const instructions = await loadServerInstructions(runtime, target);
const payload = {
mode: 'server',
name: definition.name,
status: 'ok' as StatusCategory,
const summaryLine = printSingleServerHeader(
definition,
metadataEntries.length,
durationMs,
description: definition.description,
instructions,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
tools: metadataEntries.map((entry) => ({
name: entry.tool.name,
description: entry.tool.description,
inputSchema: entry.tool.inputSchema,
outputSchema: entry.tool.outputSchema,
options: entry.options,
})),
};
console.log(JSON.stringify(payload, null, 2));
return;
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
const authCommand = buildAuthCommandHint(definition);
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
const payload = {
mode: 'server',
name: definition.name,
status: advice.category,
durationMs,
description: definition.description,
transport: transportSummary,
source: definition.source,
sources: flags.verbose || flags.includeSources ? definition.sources : undefined,
issue: advice.issue,
authCommand: advice.authCommand,
error: advice.summary,
};
console.log(JSON.stringify(payload, null, 2));
process.exitCode = 1;
return;
}
}
try {
// Always request schemas so we can render CLI-style parameter hints without re-querying per tool.
const metadataEntries = filterToolMetadata(
await withTimeout(
loadToolMetadata(runtime, target, {
includeSchema: true,
autoAuthorize: false,
allowCachedAuth: true,
}),
timeoutMs
),
requestedTool
);
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
if (requestedTool && metadataEntries.length === 0) {
printMissingToolText(definition, requestedTool, durationMs, transportSummary, sourcePath);
return;
}
const instructions = await loadServerInstructions(runtime, target);
const summaryLine = printSingleServerHeader(
definition,
metadataEntries.length,
durationMs,
transportSummary,
sourcePath,
{
printSummaryNow: false,
instructions,
transportSummary,
sourcePath,
{
printSummaryNow: false,
instructions,
}
);
if (metadataEntries.length === 0) {
console.log(' Tools: <none>');
console.log(summaryLine);
console.log('');
return;
}
);
if (metadataEntries.length === 0) {
console.log(' Tools: <none>');
console.log(summaryLine);
console.log('');
return;
}
if (flags.brief) {
if (flags.brief) {
let optionalOmitted = false;
for (const entry of metadataEntries) {
const detail = printBriefTool(definition, entry, flags.requiredOnly);
optionalOmitted ||= detail.optionalOmitted;
}
if (flags.requiredOnly && optionalOmitted) {
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
console.log('');
}
console.log(summaryLine);
console.log('');
return;
}
const examples: string[] = [];
let optionalOmitted = false;
for (const entry of metadataEntries) {
const detail = printBriefTool(definition, entry, flags.requiredOnly);
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
examples.push(...detail.examples);
optionalOmitted ||= detail.optionalOmitted;
}
const uniqueExamples = formatExampleBlock(examples);
if (uniqueExamples.length > 0) {
console.log(` ${dimText('Examples:')}`);
for (const example of uniqueExamples) {
console.log(` ${example}`);
}
console.log('');
}
if (flags.requiredOnly && optionalOmitted) {
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
console.log('');
@ -317,42 +369,84 @@ export async function handleList(
console.log(summaryLine);
console.log('');
return;
}
const examples: string[] = [];
let optionalOmitted = false;
for (const entry of metadataEntries) {
const detail = printToolDetail(definition, entry, Boolean(flags.schema), flags.requiredOnly);
examples.push(...detail.examples);
optionalOmitted ||= detail.optionalOmitted;
}
const uniqueExamples = formatExampleBlock(examples);
if (uniqueExamples.length > 0) {
console.log(` ${dimText('Examples:')}`);
for (const example of uniqueExamples) {
console.log(` ${example}`);
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
maybeSetListExitCode([{ status: 'error' }], flags);
if (flags.quiet) {
return;
}
const durationMs = Date.now() - startedAt;
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
const authCommand = buildAuthCommandHint(definition);
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
const timedOut = message === 'Timeout' || /\btimed out\b/i.test(message);
console.warn(` Tools: ${timedOut ? `<timed out after ${timeoutMs}ms>` : '<unavailable>'}`);
console.warn(` Reason: ${message}`);
if (advice.category === 'auth' && advice.authCommand) {
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
}
console.log('');
}
if (flags.requiredOnly && optionalOmitted) {
console.log(` ${extraDimText('Optional parameters hidden; run with --all-parameters to view all fields.')}`);
console.log('');
} finally {
if (previousStdioLogMode !== undefined) {
setStdioLogMode(previousStdioLogMode);
}
console.log(summaryLine);
console.log('');
return;
}
}
async function checkListServer(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
server: ServerDefinition,
timeoutMs: number
): Promise<ListSummaryResult> {
const startedAt = Date.now();
try {
const tools = await withTimeout(
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
timeoutMs
);
return {
server,
status: 'ok' as const,
tools,
durationMs: Date.now() - startedAt,
};
} catch (error) {
await persistPreparedEphemeralServer(runtime, prepared);
const durationMs = Date.now() - startedAt;
printSingleServerHeader(definition, undefined, durationMs, transportSummary, sourcePath);
const message = error instanceof Error ? error.message : 'Failed to load tool list.';
const authCommand = buildAuthCommandHint(definition);
const advice = classifyListError(error, definition.name, timeoutMs, { authCommand });
const timedOut = message === 'Timeout' || /\btimed out\b/i.test(message);
console.warn(` Tools: ${timedOut ? `<timed out after ${timeoutMs}ms>` : '<unavailable>'}`);
console.warn(` Reason: ${message}`);
if (advice.category === 'auth' && advice.authCommand) {
console.warn(` Next: run '${advice.authCommand}' to finish authentication.`);
}
return {
server,
status: 'error' as const,
error,
durationMs: Date.now() - startedAt,
};
}
}
function maybeSetListExitCode(
entries: readonly { status: StatusCategory }[],
flags: ReturnType<typeof extractListFlags>
): void {
if (!flags.exitCode) {
return;
}
if (entries.some((entry) => entry.status !== 'ok')) {
process.exitCode = 1;
}
}
function statusLabel(status: StatusCategory): string {
switch (status) {
case 'auth':
return 'auth required';
case 'offline':
return 'offline';
case 'http':
return 'http error';
case 'error':
return 'error';
case 'ok':
return 'healthy';
default:
return 'error';
}
}
@ -383,13 +477,18 @@ 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.',
' --status Check server status only, without tool docs.',
' --exit-code Exit 1 when any checked server is unhealthy.',
' --quiet Suppress output; implies --exit-code.',
' --verbose Show all config sources for matching servers.',
' --sources Include source arrays in JSON output without other verbose details.',
' --timeout <ms> Override the per-server discovery timeout.',
'',
'Examples:',
' mcporter list',
' mcporter list --quiet',
' mcporter list linear --schema',
' mcporter list linear --status --json',
' mcporter list linear --brief',
' mcporter list linear.list_issues --signatures',
' mcporter list https://mcp.example.com/mcp',
@ -400,7 +499,8 @@ export function printListHelp(): void {
function resolveServerDefinition(
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
name: string
name: string,
options: { quiet?: boolean } = {}
): { definition: ServerDefinition; name: string } | undefined {
try {
const definition = runtime.getDefinition(name);
@ -411,7 +511,9 @@ function resolveServerDefinition(
}
const suggestion = suggestServerName(runtime, name);
if (!suggestion) {
console.error(error.message);
if (!options.quiet) {
console.error(error.message);
}
return undefined;
}
const messages = renderIdentifierResolutionMessages({
@ -420,13 +522,17 @@ function resolveServerDefinition(
resolution: suggestion,
});
if (suggestion.kind === 'auto' && messages.auto) {
console.log(dimText(messages.auto));
return resolveServerDefinition(runtime, suggestion.value);
if (!options.quiet) {
console.log(dimText(messages.auto));
}
return resolveServerDefinition(runtime, suggestion.value, options);
}
if (messages.suggest) {
if (!options.quiet && messages.suggest) {
console.error(yellowText(messages.suggest));
}
console.error(error.message);
if (!options.quiet) {
console.error(error.message);
}
return undefined;
}
}

View File

@ -14,6 +14,9 @@ export function extractListFlags(args: string[]): {
verbose: boolean;
includeSources: boolean;
brief: boolean;
quiet: boolean;
exitCode: boolean;
statusOnly: boolean;
} {
let schema = false;
let timeoutMs: number | undefined;
@ -21,6 +24,9 @@ export function extractListFlags(args: string[]): {
let verbose = false;
let includeSources = false;
let brief = false;
let quiet = false;
let exitCode = false;
let statusOnly = false;
const format = consumeOutputFormat(args, {
defaultFormat: 'text',
allowed: ['text', 'json'],
@ -60,6 +66,22 @@ export function extractListFlags(args: string[]): {
args.splice(index, 1);
continue;
}
if (token === '--quiet') {
quiet = true;
exitCode = true;
args.splice(index, 1);
continue;
}
if (token === '--exit-code') {
exitCode = true;
args.splice(index, 1);
continue;
}
if (token === '--status') {
statusOnly = true;
args.splice(index, 1);
continue;
}
if (token === '--timeout') {
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
continue;
@ -84,5 +106,32 @@ export function extractListFlags(args: string[]): {
throw new Error(`--brief cannot be used with ${conflicts.join(', ')}`);
}
}
return { schema, timeoutMs, requiredOnly, ephemeral, format, verbose, includeSources, brief };
if (statusOnly) {
const conflicts: string[] = [];
if (brief) {
conflicts.push('--brief');
}
if (schema) {
conflicts.push('--schema');
}
if (!requiredOnly) {
conflicts.push('--all-parameters');
}
if (conflicts.length > 0) {
throw new Error(`--status cannot be used with ${conflicts.join(', ')}`);
}
}
return {
schema,
timeoutMs,
requiredOnly,
ephemeral,
format,
verbose,
includeSources,
brief,
quiet,
exitCode,
statusOnly,
};
}

View File

@ -16,6 +16,9 @@ describe('CLI list flag parsing', () => {
verbose: false,
ephemeral: undefined,
format: 'text',
quiet: false,
exitCode: false,
statusOnly: false,
});
expect(args).toEqual(['server']);
});
@ -33,6 +36,9 @@ describe('CLI list flag parsing', () => {
verbose: false,
ephemeral: undefined,
format: 'text',
quiet: false,
exitCode: false,
statusOnly: false,
});
expect(args).toEqual(['server']);
});
@ -46,6 +52,30 @@ describe('CLI list flag parsing', () => {
expect(args).toEqual(['server']);
});
it('parses status check flags', async () => {
const { extractListFlags } = await cliModulePromise;
const quietArgs = ['--quiet', 'server'];
const quietFlags = extractListFlags(quietArgs);
expect(quietFlags.quiet).toBe(true);
expect(quietFlags.exitCode).toBe(true);
expect(quietArgs).toEqual(['server']);
const statusArgs = ['--status', '--exit-code', 'server'];
const statusFlags = extractListFlags(statusArgs);
expect(statusFlags.statusOnly).toBe(true);
expect(statusFlags.exitCode).toBe(true);
expect(statusArgs).toEqual(['server']);
});
it('rejects --status with tool-doc display flags', async () => {
const { extractListFlags } = await cliModulePromise;
expect(() => extractListFlags(['--status', '--brief', 'server'])).toThrow('--status cannot be used with --brief');
expect(() => extractListFlags(['--status', '--schema', 'server'])).toThrow('--status cannot be used with --schema');
expect(() => extractListFlags(['--status', '--all-parameters', 'server'])).toThrow(
'--status cannot be used with --all-parameters'
);
});
it('parses --brief and --signatures aliases', async () => {
const { extractListFlags } = await cliModulePromise;
const briefArgs = ['--brief', 'server'];

View File

@ -52,4 +52,66 @@ describe('handleList JSON output', () => {
logSpy.mockRestore();
});
it('sets a non-zero exit code for unhealthy multi-server checks when requested', async () => {
const runtime = createRuntime();
const previousExitCode = process.exitCode;
process.exitCode = undefined;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await runHandleList(runtime, ['--json', '--exit-code']);
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
expect(payload.counts.auth).toBe(1);
expect(process.exitCode).toBe(1);
} finally {
logSpy.mockRestore();
process.exitCode = previousExitCode;
}
});
it('suppresses output and sets the exit code for quiet checks', async () => {
const runtime = createRuntime();
const previousExitCode = process.exitCode;
process.exitCode = undefined;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
await runHandleList(runtime, ['--quiet']);
expect(logSpy).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
} finally {
logSpy.mockRestore();
warnSpy.mockRestore();
process.exitCode = previousExitCode;
}
});
it('emits a concise single-server status payload', async () => {
const runtime = createRuntime();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await runHandleList(runtime, ['healthy', '--status', '--json']);
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
expect(payload.mode).toBe('list');
expect(payload.counts.ok).toBe(1);
expect(payload.servers).toHaveLength(1);
expect(payload.servers[0].name).toBe('healthy');
expect(payload.servers[0].status).toBe('ok');
logSpy.mockRestore();
});
it('rejects status checks for configured tool selectors', async () => {
const runtime = createRuntime();
await expect(runHandleList(runtime, ['healthy.list_documents', '--status'])).rejects.toThrow(
'--status cannot be used with a tool selector.'
);
});
});