From 941097ca5922ac76653ca8dac46e94653819d6d5 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 16 May 2026 10:28:55 -0700 Subject: [PATCH] feat(health): add mcporter health for at-a-glance server status mcporter health pings every configured MCP server (in parallel via Promise.allSettled) and reports per-server status, initialize latency, tool count, and OAuth token state in one table. Exits non-zero when any server is not ok, so it works as a CI/uptime monitor. Flags: - --server : check only this server - --timeout : per-server timeout (default 10) - --json: machine-readable JSON output Classification handles common failure modes: - auth_required for 401/forbidden/token errors - unreachable for ECONNREFUSED/ETIMEDOUT/ENOTFOUND - error for anything else - ok with latency and tool count when healthy --- CHANGELOG.md | 4 +- README.md | 1 + docs/health.md | 13 ++ src/cli.ts | 20 ++ src/cli/command-inference.ts | 1 + src/cli/health-command.ts | 310 +++++++++++++++++++++++++++++++ src/cli/help-output.ts | 5 + tests/cli-health-command.test.ts | 167 +++++++++++++++++ 8 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 docs/health.md create mode 100644 src/cli/health-command.ts create mode 100644 tests/cli-health-command.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f2b96..6c1ff65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ ## [0.11.2] - Unreleased -- Nothing yet. +### Added + +- Add `mcporter health` for at-a-glance server status, latency, tool count, OAuth state, and JSON/quiet output. ## [0.11.1] - 2026-05-14 diff --git a/README.md b/README.md index efb84a4..3f64215 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ MCPorter helps you lean into the "code execution" workflows highlighted in Anthr - **Zero-config discovery.** `createRuntime()` merges your home config (`~/.mcporter/mcporter.json[c]`, or `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` when set) first, then `config/mcporter.json`, plus Cursor/Claude/Codex/Windsurf/OpenCode/VS Code imports, expands `${ENV}` placeholders, and pools connections so you can reuse transports across multiple calls. - **One-command CLI generation.** `mcporter generate-cli` turns any MCP server definition into a ready-to-run CLI, with optional bundling/compilation and metadata for easy regeneration. +- **Health checks.** `mcporter health` pings every configured server and reports status, latency, tool count, and OAuth state in one table. - **Typed tool clients.** `mcporter emit-ts` emits `.d.ts` interfaces or ready-to-run client wrappers so agents/tests can call MCP servers with strong TypeScript types without hand-writing plumbing. - **Friendly composable API.** `createServerProxy()` exposes tools as ergonomic camelCase methods, automatically applies JSON-schema defaults, validates required arguments, and hands back a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, and `.content()` helpers. - **OAuth and stdio ergonomics.** Built-in OAuth caching, log tailing, and stdio wrappers let you work with HTTP, SSE, and stdio transports from the same interface. diff --git a/docs/health.md b/docs/health.md new file mode 100644 index 0000000..5dadcd4 --- /dev/null +++ b/docs/health.md @@ -0,0 +1,13 @@ +# mcporter health + +At-a-glance status check for every configured MCP server. + +```bash +mcporter health # check all servers +mcporter health --server linear # check one +mcporter health --json # machine-readable +mcporter health --timeout 5 # per-server timeout in seconds +``` + +Reports per-server status (ok / auth_required / unreachable / error), initialize latency, tool count, and OAuth +token state. Exits non-zero if any server is not ok. diff --git a/src/cli.ts b/src/cli.ts index caafc3c..5a0b2d6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -55,6 +55,13 @@ export async function handleList( return imported(...args); } +export async function handleHealth( + ...args: Parameters +): ReturnType { + const { handleHealth: imported } = await import('./cli/health-command.js'); + return imported(...args); +} + export async function handleResource( ...args: Parameters ): ReturnType { @@ -234,6 +241,18 @@ export async function runCli(argv: string[]): Promise { return; } + if (resolvedCommand === 'health') { + if (consumeHelpTokens(resolvedArgs)) { + const { printHealthHelp } = await import('./cli/health-command.js'); + printHealthHelp(); + process.exitCode = 0; + return; + } + const { handleHealth: importedHandleHealth } = await import('./cli/health-command.js'); + await importedHandleHealth(runtime, resolvedArgs); + return; + } + if (resolvedCommand === 'call') { if (consumeHelpTokens(resolvedArgs)) { const { printCallHelp } = await import('./cli/call-command.js'); @@ -450,6 +469,7 @@ function isExplicitNonCallCommand(command: string): boolean { return ( command === 'list' || command === 'auth' || + command === 'health' || command === 'resource' || command === 'resources' || command === 'daemon' || diff --git a/src/cli/command-inference.ts b/src/cli/command-inference.ts index 47599f4..bcaf21c 100644 --- a/src/cli/command-inference.ts +++ b/src/cli/command-inference.ts @@ -88,6 +88,7 @@ function isExplicitCommand(token: string): boolean { token === 'list' || token === 'call' || token === 'auth' || + token === 'health' || token === 'vault' || token === 'resource' || token === 'resources' diff --git a/src/cli/health-command.ts b/src/cli/health-command.ts new file mode 100644 index 0000000..f3821bb --- /dev/null +++ b/src/cli/health-command.ts @@ -0,0 +1,310 @@ +import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { ServerDefinition } from '../config.js'; +import { analyzeConnectionError } from '../error-classifier.js'; +import { buildOAuthPersistence } from '../oauth-persistence.js'; +import type { Runtime } from '../runtime.js'; +import { setStdioLogMode } from '../sdk-patches.js'; +import { formatErrorMessage } from './json-output.js'; +import { redText, yellowText } from './terminal.js'; +import { withTimeout } from './timeouts.js'; + +export type HealthStatus = 'ok' | 'auth_required' | 'unreachable' | 'error'; +export type OAuthState = 'valid' | 'expired' | 'not_required' | 'unknown'; + +export interface HealthRow { + server: string; + status: HealthStatus; + initialize_ms: number | null; + tool_count: number | null; + oauth_state: OAuthState; + error: string | null; +} + +interface HealthFlags { + readonly server?: string; + readonly timeoutMs: number; + readonly format: 'text' | 'json'; + readonly quiet: boolean; +} + +const DEFAULT_HEALTH_TIMEOUT_MS = 10_000; +const ERROR_PREVIEW_LENGTH = 200; + +export async function handleHealth(runtime: Runtime, args: string[]): Promise { + const flags = parseHealthFlags(args); + const previousStdioLogMode = flags.server ? undefined : setStdioLogMode('silent'); + try { + const definitions = selectHealthServers(runtime, flags.server); + + if (definitions.length === 0) { + if (!flags.quiet && flags.format === 'json') { + console.log(JSON.stringify([], null, 2)); + } else if (!flags.quiet) { + console.log('No MCP servers configured.'); + } + return; + } + + const results = await Promise.allSettled( + definitions.map((definition) => checkServer(definition, runtime, flags.timeoutMs)) + ); + const rows = results.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } + const server = definitions[index]?.name ?? 'unknown'; + return buildErrorRow(server, result.reason); + }); + const hasFailures = rows.some((row) => row.status !== 'ok'); + + if (hasFailures) { + process.exitCode = 1; + } + + if (flags.quiet) { + return; + } + + if (flags.format === 'json') { + console.log(JSON.stringify(rows, null, 2)); + return; + } + + printHealthTable(rows, flags.timeoutMs); + } finally { + if (previousStdioLogMode !== undefined) { + setStdioLogMode(previousStdioLogMode); + } + } +} + +export async function checkServer( + definition: ServerDefinition, + runtime: Runtime, + timeoutMs: number +): Promise { + const startedAt = performance.now(); + try { + const tools = await withTimeout( + runtime.listTools(definition.name, { autoAuthorize: false, allowCachedAuth: true }), + timeoutMs + ); + const elapsed = Math.round(performance.now() - startedAt); + const oauthState = await resolveOAuthState(definition); + return { + server: definition.name, + status: 'ok', + initialize_ms: elapsed, + tool_count: tools.length, + oauth_state: oauthState, + error: null, + }; + } catch (error) { + return { + ...buildErrorRow(definition.name, error), + oauth_state: await resolveOAuthState(definition).catch(() => 'unknown' as const), + }; + } +} + +export function printHealthHelp(): void { + console.log(`Usage: mcporter health [--server ] [--timeout ] [--json] [--quiet] + +Check configured MCP servers at a glance. + +Flags: + --server Check only one configured server. + --timeout Per-server timeout in seconds (default: 10). + --json Emit an array of health rows. + --quiet Suppress output and only set the exit code.`); +} + +function parseHealthFlags(args: string[]): HealthFlags { + let server: string | undefined; + let timeoutMs = DEFAULT_HEALTH_TIMEOUT_MS; + let format: 'text' | 'json' = 'text'; + let quiet = false; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (!token) { + continue; + } + if (token === '--server') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--server' requires a value."); + } + server = value; + index += 1; + continue; + } + if (token.startsWith('--server=')) { + server = requireFlagValue('--server', token.slice('--server='.length)); + continue; + } + if (token === '--timeout') { + const value = args[index + 1]; + if (!value) { + throw new Error("Flag '--timeout' requires a value."); + } + timeoutMs = parseTimeoutSeconds(value); + index += 1; + continue; + } + if (token.startsWith('--timeout=')) { + timeoutMs = parseTimeoutSeconds(token.slice('--timeout='.length)); + continue; + } + if (token === '--json') { + format = 'json'; + continue; + } + if (token === '--quiet') { + quiet = true; + continue; + } + throw new Error(`Unknown health flag '${token}'.`); + } + + return { server, timeoutMs, format, quiet }; +} + +function selectHealthServers(runtime: Runtime, serverName: string | undefined): ServerDefinition[] { + if (!serverName) { + return runtime.getDefinitions(); + } + return [runtime.getDefinition(serverName)]; +} + +function buildErrorRow(server: string, error: unknown): HealthRow { + const issue = analyzeConnectionError(error); + const message = formatErrorMessage(error).slice(0, ERROR_PREVIEW_LENGTH); + const status: HealthStatus = + issue.kind === 'auth' ? 'auth_required' : issue.kind === 'offline' ? 'unreachable' : 'error'; + return { + server, + status, + initialize_ms: null, + tool_count: null, + oauth_state: 'unknown', + error: message, + }; +} + +async function resolveOAuthState(definition: ServerDefinition): Promise { + if (!isOAuthConfigured(definition)) { + return 'not_required'; + } + try { + const persistence = await buildOAuthPersistence(definition); + const tokens = await persistence.readTokens(); + if (!tokens || !hasAccessToken(tokens)) { + return 'expired'; + } + return isExpired(tokens) ? 'expired' : 'valid'; + } catch { + return 'unknown'; + } +} + +function isOAuthConfigured(definition: ServerDefinition): boolean { + return Boolean( + definition.auth === 'oauth' || + definition.auth === 'refreshable_bearer' || + definition.tokenCacheDir || + definition.oauthClientId || + definition.oauthClientSecret || + definition.oauthClientSecretEnv || + definition.oauthRedirectUrl || + definition.oauthScope || + definition.oauthCommand + ); +} + +function hasAccessToken(tokens: OAuthTokens): boolean { + return typeof tokens.access_token === 'string' && tokens.access_token.trim().length > 0; +} + +function isExpired(tokens: OAuthTokens): boolean { + const record = tokens as OAuthTokens & { + expires_at?: number; + expiresAt?: number; + }; + const nowSeconds = Math.floor(Date.now() / 1000); + if (typeof record.expires_at === 'number' && Number.isFinite(record.expires_at)) { + return record.expires_at <= nowSeconds; + } + if (typeof record.expiresAt === 'number' && Number.isFinite(record.expiresAt)) { + return record.expiresAt <= nowSeconds; + } + if (typeof tokens.expires_in === 'number' && Number.isFinite(tokens.expires_in)) { + return tokens.expires_in <= 0; + } + return false; +} + +function printHealthTable(rows: readonly HealthRow[], timeoutMs: number): void { + const timeoutSeconds = Math.round(timeoutMs / 1000); + console.log(`mcporter health (${rows.length} server${rows.length === 1 ? '' : 's'}, timeout: ${timeoutSeconds}s)`); + const headers = ['Server', 'Status', 'Latency', 'Tools', 'OAuth', 'Error']; + const renderedRows = rows.map((row) => [ + row.server, + colorStatus(row.status), + row.initialize_ms === null ? '-' : `${row.initialize_ms}ms`, + row.tool_count === null ? '-' : String(row.tool_count), + row.oauth_state, + row.error ?? '', + ]); + const widths = headers.map((header, index) => + Math.max(header.length, ...renderedRows.map((row) => stripAnsi(row[index] ?? '').length)) + ); + + console.log(formatTableRow(headers, widths)); + console.log( + formatTableRow( + widths.map((width) => '-'.repeat(width)), + widths + ) + ); + for (const row of renderedRows) { + console.log(formatTableRow(row, widths)); + } +} + +function colorStatus(status: HealthStatus): string { + if (status === 'ok') { + return status; + } + if (status === 'auth_required') { + return yellowText(status); + } + return redText(status); +} + +function formatTableRow(values: readonly string[], widths: readonly number[]): string { + return values.map((value, index) => padAnsi(value, widths[index] ?? value.length)).join(' '); +} + +function padAnsi(value: string, width: number): string { + return `${value}${' '.repeat(Math.max(0, width - stripAnsi(value).length))}`; +} + +function stripAnsi(value: string): string { + return value.replace(/\u001B\[[0-9;]*m/g, ''); // eslint-disable-line no-control-regex +} + +function parseTimeoutSeconds(raw: string): number { + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error('--timeout must be a positive integer (seconds).'); + } + return parsed * 1000; +} + +function requireFlagValue(flag: string, value: string): string { + if (!value) { + throw new Error(`Flag '${flag}' requires a value.`); + } + return value; +} diff --git a/src/cli/help-output.ts b/src/cli/help-output.ts index 52b6211..a60e9d3 100644 --- a/src/cli/help-output.ts +++ b/src/cli/help-output.ts @@ -52,6 +52,11 @@ function buildCommandSections(colorize: boolean): string[] { summary: 'List configured servers (add --schema for tool docs)', usage: 'mcporter list [name] [--schema] [--json]', }, + { + name: 'health', + summary: 'Check configured servers for status, latency, tool count, and OAuth state', + usage: 'mcporter health [--server ] [--json]', + }, { name: 'call', summary: 'Call a tool by selector (server.tool) or HTTP URL; key=value flags supported', diff --git a/tests/cli-health-command.test.ts b/tests/cli-health-command.test.ts new file mode 100644 index 0000000..9ba158e --- /dev/null +++ b/tests/cli-health-command.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { handleHealth } from '../src/cli/health-command.js'; +import type { ServerDefinition } from '../src/config.js'; +import type { Runtime, ServerToolInfo } from '../src/runtime.js'; + +function buildServerDefinition(name: string, overrides: Partial = {}): ServerDefinition { + return { + name, + command: { kind: 'http', url: new URL(`https://${name}.example.com/mcp`) }, + ...overrides, + }; +} + +function createRuntime( + definitions: ServerDefinition[], + listTools: Runtime['listTools'] +): Runtime & { listTools: ReturnType } { + const listToolsMock = vi.fn(listTools); + return { + listServers: () => definitions.map((entry) => entry.name), + getDefinitions: () => definitions, + getDefinition: (name: string): ServerDefinition => { + const found = definitions.find((entry) => entry.name === name); + if (!found) { + throw new Error(`Unknown MCP server '${name}'.`); + } + return found; + }, + registerDefinition: vi.fn(), + listTools: listToolsMock, + callTool: vi.fn(async () => undefined), + listResources: vi.fn(async () => undefined), + readResource: vi.fn(async () => undefined), + connect: vi.fn(async () => { + throw new Error('connect not implemented'); + }), + close: vi.fn(async () => undefined), + }; +} + +function tools(count: number): ServerToolInfo[] { + return Array.from({ length: count }, (_, index) => ({ name: `tool_${index + 1}` })); +} + +describe('handleHealth', () => { + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it('prints one ok row per reachable server and leaves the exit code successful', async () => { + const definitions = [buildServerDefinition('alpha'), buildServerDefinition('beta')]; + const runtime = createRuntime(definitions, async (server) => tools(server === 'alpha' ? 1 : 2)); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleHealth(runtime, []); + + const output = logSpy.mock.calls.map((call) => call[0]).join('\n'); + expect(output).toContain('alpha'); + expect(output).toContain('beta'); + expect(output).toContain('ok'); + expect(process.exitCode ?? 0).toBe(0); + }); + + it('classifies 401 failures as auth_required and exits non-zero', async () => { + const definitions = [buildServerDefinition('alpha'), buildServerDefinition('linear', { auth: 'oauth' })]; + const runtime = createRuntime(definitions, async (server) => { + if (server === 'linear') { + throw new Error('HTTP error 401: auth required'); + } + return tools(1); + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleHealth(runtime, []); + + const output = logSpy.mock.calls.map((call) => call[0]).join('\n'); + expect(output).toContain('linear'); + expect(output).toContain('auth_required'); + expect(process.exitCode).toBe(1); + }); + + it('classifies a per-server timeout as unreachable with an error preview', async () => { + const definitions = [buildServerDefinition('slow')]; + const runtime = createRuntime( + definitions, + async () => + await new Promise(() => { + // Intentionally unresolved; health's per-server timeout rejects first. + }) + ); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleHealth(runtime, ['--timeout', '1']); + + const output = logSpy.mock.calls.map((call) => call[0]).join('\n'); + expect(output).toContain('slow'); + expect(output).toContain('unreachable'); + expect(output).toContain('Timeout'); + expect(process.exitCode).toBe(1); + }); + + it('emits valid JSON health rows', async () => { + const definitions = [buildServerDefinition('alpha')]; + const runtime = createRuntime(definitions, async () => tools(3)); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleHealth(runtime, ['--json']); + + const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '[]'); + expect(payload).toEqual([ + expect.objectContaining({ + server: 'alpha', + status: 'ok', + tool_count: 3, + oauth_state: 'not_required', + error: null, + }), + ]); + }); + + it('filters checks to --server', async () => { + const definitions = [buildServerDefinition('alpha'), buildServerDefinition('beta')]; + const runtime = createRuntime(definitions, async () => tools(1)); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleHealth(runtime, ['--server', 'beta']); + + expect(runtime.listTools).toHaveBeenCalledTimes(1); + expect(runtime.listTools).toHaveBeenCalledWith('beta', { autoAuthorize: false, allowCachedAuth: true }); + const output = logSpy.mock.calls.map((call) => call[0]).join('\n'); + expect(output).not.toContain('alpha'); + expect(output).toContain('beta'); + }); + + it('enforces --timeout per server', async () => { + const definitions = [buildServerDefinition('blocked')]; + const runtime = createRuntime( + definitions, + async () => + await new Promise(() => { + // Intentionally unresolved; health's per-server timeout rejects first. + }) + ); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const startedAt = Date.now(); + await handleHealth(runtime, ['--timeout', '1']); + + expect(Date.now() - startedAt).toBeLessThan(2_500); + expect(process.exitCode).toBe(1); + expect(logSpy.mock.calls.map((call) => call[0]).join('\n')).toContain('unreachable'); + }); + + it('suppresses output with --quiet but preserves the exit code', async () => { + const definitions = [buildServerDefinition('linear', { auth: 'oauth' })]; + const runtime = createRuntime(definitions, async () => { + throw new Error('HTTP status 401 unauthorized'); + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleHealth(runtime, ['--quiet']); + + expect(logSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); +});