feat: add list health check flags
This commit is contained in:
parent
a1201d1955
commit
708db33e58
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>`
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user