From 708db33e585f539333e9fffdee81c6d406d6718f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 20:53:15 +0100 Subject: [PATCH] feat: add list health check flags --- CHANGELOG.md | 1 + README.md | 2 + docs/cli-reference.md | 8 + docs/quickstart.md | 3 +- src/cli/list-command.ts | 448 ++++++++++++++++++++++------------- src/cli/list-flags.ts | 51 +++- tests/cli-list-flags.test.ts | 30 +++ tests/cli-list-json.test.ts | 62 +++++ 8 files changed, 432 insertions(+), 173 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ea8e12c..db34f41f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 8900579c..46056489 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 82792d19..19708f21 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 ` – per-server timeout when enumerating all servers. ## `mcporter call ` diff --git a/docs/quickstart.md b/docs/quickstart.md index 58c1a3c7..5e5f4a8a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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. diff --git a/src/cli/list-command.ts b/src/cli/list-command.ts index 9a89da97..77610d4e 100644 --- a/src/cli/list-command.ts +++ b/src/cli/list-command.ts @@ -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 | undefined >) @@ -95,28 +98,7 @@ export async function handleList( let completedCount = 0; const tasks = servers.map((server, index) => - (async (): Promise => { - 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, - 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, + const summaryLine = printSingleServerHeader( + definition, + metadataEntries.length, 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: '); + console.log(summaryLine); + console.log(''); + return; } - ); - if (metadataEntries.length === 0) { - console.log(' Tools: '); - 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 ? `` : ''}`); + 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>, + server: ServerDefinition, + timeoutMs: number +): Promise { + 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 ? `` : ''}`); - 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 +): 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 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>, - 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; } } diff --git a/src/cli/list-flags.ts b/src/cli/list-flags.ts index 6bf1a908..4f261d55 100644 --- a/src/cli/list-flags.ts +++ b/src/cli/list-flags.ts @@ -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, + }; } diff --git a/tests/cli-list-flags.test.ts b/tests/cli-list-flags.test.ts index 241e6905..66b451d9 100644 --- a/tests/cli-list-flags.test.ts +++ b/tests/cli-list-flags.test.ts @@ -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']; diff --git a/tests/cli-list-json.test.ts b/tests/cli-list-json.test.ts index daabcff4..9678f48e 100644 --- a/tests/cli-list-json.test.ts +++ b/tests/cli-list-json.test.ts @@ -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.' + ); + }); });