mcp-vitals 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +222 -0
  4. package/bin/mcp-vitals.js +12 -0
  5. package/dist/args.d.ts +9 -0
  6. package/dist/args.js +50 -0
  7. package/dist/assertions/loader.d.ts +9 -0
  8. package/dist/assertions/loader.js +75 -0
  9. package/dist/assertions/run.d.ts +19 -0
  10. package/dist/assertions/run.js +154 -0
  11. package/dist/assertions/schema.d.ts +147 -0
  12. package/dist/assertions/schema.js +72 -0
  13. package/dist/bench/engine.d.ts +7 -0
  14. package/dist/bench/engine.js +121 -0
  15. package/dist/cli.d.ts +3 -0
  16. package/dist/cli.js +137 -0
  17. package/dist/commands/bench.d.ts +13 -0
  18. package/dist/commands/bench.js +129 -0
  19. package/dist/commands/call.d.ts +8 -0
  20. package/dist/commands/call.js +84 -0
  21. package/dist/commands/check.d.ts +13 -0
  22. package/dist/commands/check.js +140 -0
  23. package/dist/commands/inspect.d.ts +10 -0
  24. package/dist/commands/inspect.js +129 -0
  25. package/dist/commands/ping.d.ts +6 -0
  26. package/dist/commands/ping.js +55 -0
  27. package/dist/context.d.ts +30 -0
  28. package/dist/context.js +114 -0
  29. package/dist/errors.d.ts +33 -0
  30. package/dist/errors.js +52 -0
  31. package/dist/glob.d.ts +3 -0
  32. package/dist/glob.js +40 -0
  33. package/dist/index.d.ts +12 -0
  34. package/dist/index.js +11 -0
  35. package/dist/mcpClient.d.ts +38 -0
  36. package/dist/mcpClient.js +192 -0
  37. package/dist/output.d.ts +2 -0
  38. package/dist/output.js +8 -0
  39. package/dist/renderers/colors.d.ts +9 -0
  40. package/dist/renderers/colors.js +16 -0
  41. package/dist/renderers/json.d.ts +2 -0
  42. package/dist/renderers/json.js +5 -0
  43. package/dist/renderers/junit.d.ts +3 -0
  44. package/dist/renderers/junit.js +32 -0
  45. package/dist/renderers/progress.d.ts +16 -0
  46. package/dist/renderers/progress.js +29 -0
  47. package/dist/renderers/table.d.ts +14 -0
  48. package/dist/renderers/table.js +43 -0
  49. package/dist/schema.d.ts +12 -0
  50. package/dist/schema.js +47 -0
  51. package/dist/stats.d.ts +12 -0
  52. package/dist/stats.js +54 -0
  53. package/dist/thresholds.d.ts +21 -0
  54. package/dist/thresholds.js +109 -0
  55. package/dist/transport.d.ts +8 -0
  56. package/dist/transport.js +68 -0
  57. package/dist/types.d.ts +180 -0
  58. package/dist/types.js +2 -0
  59. package/package.json +83 -0
  60. package/schema/assertions.schema.json +177 -0
@@ -0,0 +1,84 @@
1
+ import { buildContext, buildSpec } from '../context.js';
2
+ import { Connection } from '../mcpClient.js';
3
+ import { resolveArgs } from '../args.js';
4
+ import { AssertionFailure, ToolErrorExit, UsageError } from '../errors.js';
5
+ import { makeColors } from '../renderers/colors.js';
6
+ import { emitJson } from '../renderers/json.js';
7
+ import { formatMs } from '../renderers/table.js';
8
+ import { writeOut } from '../output.js';
9
+ import { Progress } from '../renderers/progress.js';
10
+ function renderContentText(content) {
11
+ const parts = [];
12
+ for (const block of content) {
13
+ if (block.type === 'text')
14
+ parts.push(block.text ?? '');
15
+ else if (block.type === 'image')
16
+ parts.push(`[image ${block.mimeType ?? '?'} ${sizeOf(block.data)}]`);
17
+ else if (block.type === 'audio')
18
+ parts.push(`[audio ${block.mimeType ?? '?'} ${sizeOf(block.data)}]`);
19
+ else if (block.type === 'resource')
20
+ parts.push(`[resource ${block.resource?.uri ?? '?'}]`);
21
+ else
22
+ parts.push(`[${block.type ?? 'unknown'}]`);
23
+ }
24
+ return parts.join('\n');
25
+ }
26
+ function sizeOf(data) {
27
+ if (!data)
28
+ return '0 B';
29
+ const bytes = Math.floor((data.length * 3) / 4);
30
+ return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`;
31
+ }
32
+ export async function runCall(server, opts) {
33
+ if (!opts.tool)
34
+ throw new UsageError('--tool <name> is required');
35
+ const ctx = buildContext(opts);
36
+ const spec = buildSpec(opts, server);
37
+ const c = makeColors(ctx.color);
38
+ const progress = new Progress({ quiet: ctx.quiet, json: ctx.json });
39
+ const args = await resolveArgs(opts.args);
40
+ progress.note(c.dim('connecting…'));
41
+ const conn = await Connection.connect(spec);
42
+ const t0 = process.hrtime.bigint();
43
+ const result = await conn.callTool(opts.tool, args);
44
+ const durationMs = Number(process.hrtime.bigint() - t0) / 1e6;
45
+ await conn.close();
46
+ const text = renderContentText(result.content);
47
+ if (ctx.json) {
48
+ emitJson({
49
+ ok: opts.expectError ? result.isError : !result.isError,
50
+ tool: opts.tool,
51
+ args,
52
+ durationMs,
53
+ isError: result.isError,
54
+ content: result.content,
55
+ structuredContent: result.structuredContent,
56
+ });
57
+ }
58
+ else if (opts.raw) {
59
+ writeOut(text);
60
+ }
61
+ else {
62
+ if (result.isError) {
63
+ writeOut(c.red(text));
64
+ }
65
+ else {
66
+ writeOut(text);
67
+ if (result.structuredContent !== undefined) {
68
+ writeOut('');
69
+ writeOut(c.dim('structured:') + ' ' + JSON.stringify(result.structuredContent));
70
+ }
71
+ }
72
+ progress.note(c.dim(`done in ${formatMs(durationMs)}`));
73
+ }
74
+ if (opts.expectError) {
75
+ if (!result.isError) {
76
+ throw new AssertionFailure(`expected tool "${opts.tool}" to error, but it succeeded`);
77
+ }
78
+ return 0;
79
+ }
80
+ if (result.isError) {
81
+ throw new ToolErrorExit(`tool "${opts.tool}" returned an error`);
82
+ }
83
+ return 0;
84
+ }
@@ -0,0 +1,13 @@
1
+ import type { CommonOpts } from '../context.js';
2
+ export interface CheckOpts extends CommonOpts {
3
+ config?: string;
4
+ junit?: string;
5
+ only: string[];
6
+ skip: string[];
7
+ iterations?: number;
8
+ warmup?: number;
9
+ concurrency?: number;
10
+ bail: boolean;
11
+ latency: boolean;
12
+ }
13
+ export declare function runCheck(server: string[], opts: CheckOpts): Promise<number>;
@@ -0,0 +1,140 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { assertNoSwallowedFlags, buildContext, stripSeparator } from '../context.js';
3
+ import { Connection } from '../mcpClient.js';
4
+ import { loadConfig } from '../assertions/loader.js';
5
+ import { runChecks } from '../assertions/run.js';
6
+ import { AssertionFailure, UsageError } from '../errors.js';
7
+ import { makeColors } from '../renderers/colors.js';
8
+ import { emitJson } from '../renderers/json.js';
9
+ import { renderJUnit } from '../renderers/junit.js';
10
+ import { renderTable } from '../renderers/table.js';
11
+ import { writeOut } from '../output.js';
12
+ import { Progress } from '../renderers/progress.js';
13
+ function parseKv(items, sep, label) {
14
+ const out = {};
15
+ for (const item of items) {
16
+ const i = item.indexOf(sep);
17
+ if (i <= 0)
18
+ throw new UsageError(`invalid ${label} "${item}" (expected K${sep}V)`);
19
+ out[item.slice(0, i).trim()] = item.slice(i + sep.length).trim();
20
+ }
21
+ return out;
22
+ }
23
+ /** Combine the config's server block with any CLI overrides + positional command. */
24
+ function buildCheckSpec(config, opts, server) {
25
+ const s = config.server;
26
+ const cleaned = stripSeparator(server);
27
+ const positional = cleaned[0];
28
+ const cliUrl = opts.url !== undefined && opts.url !== '' ? opts.url : undefined;
29
+ let command;
30
+ let args = [];
31
+ let url;
32
+ if (positional) {
33
+ command = positional;
34
+ args = cleaned.slice(1);
35
+ }
36
+ else if (cliUrl) {
37
+ url = cliUrl;
38
+ }
39
+ else if (s.url) {
40
+ url = s.url;
41
+ }
42
+ else {
43
+ command = s.command;
44
+ args = s.args ?? [];
45
+ }
46
+ const requestTimeoutMs = opts.timeout ?? s.timeoutMs ?? 10_000;
47
+ const connectTimeoutMs = opts.connectTimeout ?? s.connectTimeoutMs ?? requestTimeoutMs;
48
+ const forced = opts.transport ?? s.transport;
49
+ return {
50
+ forced,
51
+ command,
52
+ args,
53
+ url,
54
+ headers: { ...(s.headers ?? {}), ...parseKv(opts.header, ':', '--header') },
55
+ env: { ...(s.env ?? {}), ...parseKv(opts.env, '=', '--env') },
56
+ inheritEnv: opts.inheritEnv === true,
57
+ connectTimeoutMs,
58
+ requestTimeoutMs,
59
+ };
60
+ }
61
+ const GROUPS = [
62
+ ['presence', 'Capabilities'],
63
+ ['schema', 'Schemas'],
64
+ ['latency', 'Latency SLAs'],
65
+ ];
66
+ function statusCell(status, c) {
67
+ if (status === 'pass')
68
+ return c.green('PASS');
69
+ if (status === 'fail')
70
+ return c.red('FAIL');
71
+ return c.dim('SKIP');
72
+ }
73
+ export async function runCheck(server, opts) {
74
+ const ctx = buildContext(opts);
75
+ const c = makeColors(ctx.color);
76
+ const progress = new Progress({ quiet: ctx.quiet, json: ctx.json });
77
+ if (ctx.json && opts.junit === '-') {
78
+ throw new UsageError('--junit - conflicts with --json (both write stdout); give --junit a file path');
79
+ }
80
+ assertNoSwallowedFlags(server);
81
+ const { path, config } = await loadConfig(opts.config);
82
+ progress.note(c.dim(`loaded ${path}`));
83
+ const spec = buildCheckSpec(config, opts, server);
84
+ progress.note(c.dim('connecting…'));
85
+ const conn = await Connection.connect(spec);
86
+ const { rows, summary } = await runChecks(conn, config, {
87
+ noLatency: opts.latency === false,
88
+ only: opts.only,
89
+ skip: opts.skip,
90
+ bail: opts.bail === true,
91
+ iterations: opts.iterations,
92
+ warmup: opts.warmup,
93
+ concurrency: opts.concurrency,
94
+ timeoutMs: spec.requestTimeoutMs,
95
+ });
96
+ await conn.close();
97
+ if (opts.junit) {
98
+ const xml = renderJUnit(rows, summary);
99
+ if (opts.junit === '-')
100
+ process.stdout.write(xml + '\n');
101
+ else
102
+ await writeFile(opts.junit, xml, 'utf8');
103
+ }
104
+ if (ctx.json) {
105
+ emitJson({
106
+ ok: summary.failed === 0,
107
+ config: path,
108
+ suites: rows.map((r) => ({
109
+ id: r.id,
110
+ kind: r.kind,
111
+ target: r.target,
112
+ expected: r.expected,
113
+ actual: r.actual,
114
+ status: r.status,
115
+ })),
116
+ summary,
117
+ });
118
+ }
119
+ else {
120
+ for (const [kind, label] of GROUPS) {
121
+ const groupRows = rows.filter((r) => r.kind === kind);
122
+ if (groupRows.length === 0)
123
+ continue;
124
+ writeOut('');
125
+ writeOut(c.bold(label));
126
+ writeOut(renderTable([{ header: 'CHECK' }, { header: 'EXPECTED' }, { header: 'ACTUAL' }, { header: 'STATUS' }], groupRows.map((r) => [r.id, r.expected, r.actual, statusCell(r.status, c)]), c));
127
+ }
128
+ writeOut('');
129
+ const parts = [
130
+ c.green(`${summary.passed} passed`),
131
+ summary.failed ? c.red(`${summary.failed} failed`) : `${summary.failed} failed`,
132
+ c.dim(`${summary.skipped} skipped`),
133
+ ];
134
+ writeOut(`${parts.join(', ')} ${c.dim(`in ${(summary.durationMs / 1000).toFixed(2)}s`)}`);
135
+ }
136
+ if (summary.failed > 0) {
137
+ throw new AssertionFailure(`${summary.failed} check(s) failed`);
138
+ }
139
+ return 0;
140
+ }
@@ -0,0 +1,10 @@
1
+ import type { CommonOpts } from '../context.js';
2
+ export interface InspectOpts extends CommonOpts {
3
+ tools?: boolean;
4
+ resources?: boolean;
5
+ prompts?: boolean;
6
+ schema?: boolean;
7
+ validateSchemas: boolean;
8
+ filter?: string;
9
+ }
10
+ export declare function runInspect(server: string[], opts: InspectOpts): Promise<number>;
@@ -0,0 +1,129 @@
1
+ import { buildContext, buildSpec } from '../context.js';
2
+ import { Connection } from '../mcpClient.js';
3
+ import { validateJsonSchema } from '../schema.js';
4
+ import { matchesAny } from '../glob.js';
5
+ import { AssertionFailure } from '../errors.js';
6
+ import { makeColors } from '../renderers/colors.js';
7
+ import { emitJson } from '../renderers/json.js';
8
+ import { renderKeyValues, renderTable } from '../renderers/table.js';
9
+ import { writeOut } from '../output.js';
10
+ import { Progress } from '../renderers/progress.js';
11
+ export async function runInspect(server, opts) {
12
+ const ctx = buildContext(opts);
13
+ const spec = buildSpec(opts, server);
14
+ const c = makeColors(ctx.color);
15
+ const progress = new Progress({ quiet: ctx.quiet, json: ctx.json });
16
+ const wantAll = !opts.tools && !opts.resources && !opts.prompts;
17
+ const wantTools = wantAll || opts.tools === true;
18
+ const wantResources = wantAll || opts.resources === true;
19
+ const wantPrompts = wantAll || opts.prompts === true;
20
+ const filter = (name) => matchesAny(name, opts.filter ? [opts.filter] : undefined);
21
+ progress.note(c.dim('connecting…'));
22
+ const conn = await Connection.connect(spec);
23
+ const identity = conn.identity();
24
+ const caps = conn.capabilities();
25
+ let tools = wantTools ? await conn.listTools() : [];
26
+ tools = tools.filter((t) => filter(t.name));
27
+ const resources = (wantResources ? await conn.listResources() : []).filter((r) => filter(r.name ?? r.uri));
28
+ const prompts = (wantPrompts ? await conn.listPrompts() : []).filter((p) => filter(p.name));
29
+ let invalid = 0;
30
+ if (opts.validateSchemas) {
31
+ for (const t of tools) {
32
+ const res = validateJsonSchema(t.inputSchema);
33
+ t.schemaValid = res.valid;
34
+ t.schemaErrors = res.errors;
35
+ if (!res.valid)
36
+ invalid++;
37
+ }
38
+ }
39
+ await conn.close();
40
+ const schemasValid = tools.length - invalid;
41
+ const ok = invalid === 0;
42
+ if (ctx.json) {
43
+ emitJson({
44
+ ok,
45
+ server: identity,
46
+ transport: conn.kind,
47
+ capabilities: { ...caps.raw, tools: caps.tools, resources: caps.resources, prompts: caps.prompts },
48
+ tools: tools.map((t) => ({
49
+ name: t.name,
50
+ title: t.title,
51
+ description: t.description,
52
+ ...(opts.schema ? { inputSchema: t.inputSchema } : {}),
53
+ requiredArgs: t.requiredArgs,
54
+ totalArgs: t.totalArgs,
55
+ schemaValid: t.schemaValid,
56
+ schemaErrors: t.schemaErrors,
57
+ })),
58
+ resources: resources.map((r) => ({ uri: r.uri, name: r.name, mimeType: r.mimeType })),
59
+ prompts: prompts.map((p) => ({ name: p.name, arguments: p.arguments })),
60
+ summary: {
61
+ tools: tools.length,
62
+ resources: resources.length,
63
+ prompts: prompts.length,
64
+ schemasValid,
65
+ schemasInvalid: invalid,
66
+ },
67
+ });
68
+ }
69
+ else {
70
+ progress.clearStatus();
71
+ writeOut(renderKeyValues([
72
+ ['Server', `${c.bold(identity.name)} ${c.dim('v' + identity.version)}`],
73
+ ['Protocol', identity.protocolVersion ?? '—'],
74
+ ['Transport', conn.kind],
75
+ [
76
+ 'Capabilities',
77
+ [
78
+ caps.tools ? c.green('tools') : c.dim('tools'),
79
+ caps.resources ? c.green('resources') : c.dim('resources'),
80
+ caps.prompts ? c.green('prompts') : c.dim('prompts'),
81
+ ].join(' '),
82
+ ],
83
+ ], c));
84
+ if (identity.instructions) {
85
+ writeOut('');
86
+ writeOut(c.dim(identity.instructions));
87
+ }
88
+ if (wantTools) {
89
+ writeOut('');
90
+ writeOut(c.bold(`Tools (${tools.length})`));
91
+ if (tools.length > 0) {
92
+ writeOut(renderTable([
93
+ { header: 'NAME' },
94
+ { header: 'ARGS', align: 'right' },
95
+ { header: 'SCHEMA' },
96
+ { header: 'DESCRIPTION' },
97
+ ], tools.map((t) => [
98
+ t.name,
99
+ `${t.requiredArgs}/${t.totalArgs}`,
100
+ opts.validateSchemas ? (t.schemaValid ? c.green('ok') : c.red('invalid')) : c.dim('—'),
101
+ truncate(t.description ?? '', 48),
102
+ ]), c));
103
+ }
104
+ }
105
+ if (wantResources && resources.length > 0) {
106
+ writeOut('');
107
+ writeOut(c.bold(`Resources (${resources.length})`));
108
+ writeOut(renderTable([{ header: 'URI' }, { header: 'NAME' }, { header: 'TYPE' }], resources.map((r) => [r.uri, r.name ?? '', r.mimeType ?? '']), c));
109
+ }
110
+ if (wantPrompts && prompts.length > 0) {
111
+ writeOut('');
112
+ writeOut(c.bold(`Prompts (${prompts.length})`));
113
+ writeOut(renderTable([{ header: 'NAME' }, { header: 'ARGS' }], prompts.map((p) => [p.name, p.arguments.map((a) => a.name).join(', ')]), c));
114
+ }
115
+ writeOut('');
116
+ const schemaNote = opts.validateSchemas
117
+ ? `schemas: ${c.green(String(schemasValid) + ' ok')}${invalid ? ', ' + c.red(invalid + ' invalid') : ''}`
118
+ : 'schemas: not validated';
119
+ writeOut(c.dim(`${tools.length} tools, ${resources.length} resources, ${prompts.length} prompts — ${schemaNote}`));
120
+ }
121
+ if (opts.validateSchemas && invalid > 0) {
122
+ throw new AssertionFailure(`${invalid} tool inputSchema(s) are invalid`);
123
+ }
124
+ return 0;
125
+ }
126
+ function truncate(s, n) {
127
+ const oneLine = s.replace(/\s+/g, ' ').trim();
128
+ return oneLine.length > n ? oneLine.slice(0, n - 1) + '…' : oneLine;
129
+ }
@@ -0,0 +1,6 @@
1
+ import type { CommonOpts } from '../context.js';
2
+ export interface PingOpts extends CommonOpts {
3
+ count: number;
4
+ list?: boolean;
5
+ }
6
+ export declare function runPing(server: string[], opts: PingOpts): Promise<number>;
@@ -0,0 +1,55 @@
1
+ import { buildContext, buildSpec } from '../context.js';
2
+ import { Connection } from '../mcpClient.js';
3
+ import { computeStats } from '../stats.js';
4
+ import { makeColors } from '../renderers/colors.js';
5
+ import { emitJson } from '../renderers/json.js';
6
+ import { formatMs } from '../renderers/table.js';
7
+ import { writeOut } from '../output.js';
8
+ import { Progress } from '../renderers/progress.js';
9
+ export async function runPing(server, opts) {
10
+ const ctx = buildContext(opts);
11
+ const spec = buildSpec(opts, server);
12
+ const c = makeColors(ctx.color);
13
+ const progress = new Progress({ quiet: ctx.quiet, json: ctx.json });
14
+ const count = Math.max(1, opts.count);
15
+ const handshakes = [];
16
+ let lastListMs = null;
17
+ let target = '';
18
+ for (let i = 0; i < count; i++) {
19
+ progress.status(c.dim(`ping ${i + 1}/${count}…`));
20
+ const conn = await Connection.connect(spec);
21
+ target = conn.target;
22
+ handshakes.push(conn.coldStartMs);
23
+ if (opts.list) {
24
+ const t0 = process.hrtime.bigint();
25
+ await conn.listTools();
26
+ lastListMs = Number(process.hrtime.bigint() - t0) / 1e6;
27
+ }
28
+ await conn.close();
29
+ }
30
+ progress.clearStatus();
31
+ const stats = computeStats(handshakes);
32
+ if (ctx.json) {
33
+ emitJson({
34
+ ok: true,
35
+ target,
36
+ count,
37
+ samples: handshakes,
38
+ handshake: stats
39
+ ? { min: stats.min, mean: stats.mean, p50: stats.p50, p95: stats.p95, max: stats.max }
40
+ : null,
41
+ listToolsMs: lastListMs,
42
+ });
43
+ return 0;
44
+ }
45
+ if (count === 1) {
46
+ const list = lastListMs !== null ? `, tools/list ${formatMs(lastListMs)}` : '';
47
+ writeOut(`${c.green('●')} connected to ${c.bold(target)} in ${c.bold(formatMs(handshakes[0]))}${c.dim(list)}`);
48
+ }
49
+ else if (stats) {
50
+ writeOut(c.bold(`${count} handshakes to ${target}`));
51
+ writeOut(c.dim('min/mean/p50/p95/max ') +
52
+ `${formatMs(stats.min)} / ${formatMs(stats.mean)} / ${formatMs(stats.p50)} / ${formatMs(stats.p95)} / ${formatMs(stats.max)}`);
53
+ }
54
+ return 0;
55
+ }
@@ -0,0 +1,30 @@
1
+ import type { Command } from 'commander';
2
+ import type { RunContext, TransportSpec } from './types.js';
3
+ /** Connection-related options shared by every subcommand. */
4
+ export declare function addConnectionOptions(cmd: Command): Command;
5
+ /** Output-related options shared by every subcommand. */
6
+ export declare function addOutputOptions(cmd: Command): Command;
7
+ export interface CommonOpts {
8
+ url?: string;
9
+ transport?: string;
10
+ header: string[];
11
+ env: string[];
12
+ inheritEnv: boolean;
13
+ timeout?: number;
14
+ connectTimeout?: number;
15
+ json: boolean;
16
+ color: boolean;
17
+ quiet: boolean;
18
+ verbose: boolean;
19
+ }
20
+ /**
21
+ * Reject an mcp-vitals long flag that landed after the server command (and so
22
+ * would be silently passed to the child). Everything after a `--` separator is
23
+ * explicitly the server's and is never flagged.
24
+ */
25
+ export declare function assertNoSwallowedFlags(server: string[]): void;
26
+ export declare function buildContext(opts: CommonOpts): RunContext;
27
+ /** Drop the first `--` separator; everything else is the server command + argv. */
28
+ export declare function stripSeparator(server: string[]): string[];
29
+ /** Build the TransportSpec from common options + the positional stdio command. */
30
+ export declare function buildSpec(opts: CommonOpts, server: string[]): TransportSpec;
@@ -0,0 +1,114 @@
1
+ import { UsageError } from './errors.js';
2
+ import { resolveColor } from './renderers/colors.js';
3
+ const DEFAULT_TIMEOUT_MS = 10_000;
4
+ function collect(value, previous) {
5
+ return previous.concat([value]);
6
+ }
7
+ /** Connection-related options shared by every subcommand. */
8
+ export function addConnectionOptions(cmd) {
9
+ return cmd
10
+ .option('--url <url>', 'connect over HTTP/SSE (mutually exclusive with a stdio command)')
11
+ .option('--transport <kind>', 'force transport: stdio | http | sse')
12
+ .option('--header <k:v>', 'HTTP header for every request (repeatable)', collect, [])
13
+ .option('--env <k=v>', 'env var for the stdio child (repeatable)', collect, [])
14
+ .option('--inherit-env', 'spread process.env into the stdio child before --env vars', false)
15
+ .option('--timeout <ms>', 'per-request timeout in ms (default 10000)', (v) => parseInt(v, 10))
16
+ .option('--connect-timeout <ms>', 'handshake timeout in ms (default = --timeout)', (v) => parseInt(v, 10));
17
+ }
18
+ /** Output-related options shared by every subcommand. */
19
+ export function addOutputOptions(cmd) {
20
+ return cmd
21
+ .option('--json', 'emit a single JSON object on stdout; all else to stderr', false)
22
+ .option('--no-color', 'disable ANSI color')
23
+ .option('-q, --quiet', 'suppress non-error progress on stderr', false)
24
+ .option('-v, --verbose', 'verbose per-request timing + relay server stderr', false);
25
+ }
26
+ function parseKv(items, sep, label) {
27
+ const out = {};
28
+ for (const item of items) {
29
+ const i = item.indexOf(sep);
30
+ if (i <= 0)
31
+ throw new UsageError(`invalid ${label} "${item}" (expected K${sep}V)`);
32
+ out[item.slice(0, i).trim()] = item.slice(i + sep.length).trim();
33
+ }
34
+ return out;
35
+ }
36
+ // Every long option mcp-vitals defines, across all subcommands. Used to catch
37
+ // the passThroughOptions footgun where a vitals flag placed AFTER the server
38
+ // command is silently handed to the child instead of parsed by mcp-vitals.
39
+ const KNOWN_LONG_FLAGS = new Set([
40
+ '--url', '--transport', '--header', '--env', '--inherit-env', '--timeout',
41
+ '--connect-timeout', '--json', '--color', '--no-color', '--quiet', '--verbose',
42
+ '--tools', '--resources', '--prompts', '--schema', '--validate-schemas',
43
+ '--no-validate-schemas', '--filter', '--count', '--list', '--tool', '--probe',
44
+ '--args', '--iterations', '--warmup', '--concurrency', '--rps', '--duration',
45
+ '--fail-on', '--raw', '--expect-error', '--config', '--junit', '--only',
46
+ '--skip', '--bail', '--latency', '--no-latency', '--help', '--version',
47
+ ]);
48
+ /**
49
+ * Reject an mcp-vitals long flag that landed after the server command (and so
50
+ * would be silently passed to the child). Everything after a `--` separator is
51
+ * explicitly the server's and is never flagged.
52
+ */
53
+ export function assertNoSwallowedFlags(server) {
54
+ for (const token of server) {
55
+ if (token === '--')
56
+ return; // explicit handoff to the server
57
+ if (token.startsWith('--')) {
58
+ const base = token.split('=')[0] ?? token;
59
+ if (KNOWN_LONG_FLAGS.has(base)) {
60
+ throw new UsageError(`"${base}" looks like an mcp-vitals option but came after the server command, ` +
61
+ `so it would be passed to the server instead.\n` +
62
+ ` Put mcp-vitals options BEFORE the command, or after "--" to pass it to the server.`);
63
+ }
64
+ }
65
+ }
66
+ }
67
+ function parseTransport(value) {
68
+ if (value === undefined)
69
+ return undefined;
70
+ if (value === 'stdio' || value === 'http' || value === 'sse')
71
+ return value;
72
+ throw new UsageError(`invalid --transport "${value}" (expected stdio | http | sse)`);
73
+ }
74
+ export function buildContext(opts) {
75
+ return {
76
+ json: opts.json === true,
77
+ color: resolveColor(opts.color === false),
78
+ quiet: opts.quiet === true,
79
+ verbose: opts.verbose === true,
80
+ };
81
+ }
82
+ /** Drop the first `--` separator; everything else is the server command + argv. */
83
+ export function stripSeparator(server) {
84
+ const i = server.indexOf('--');
85
+ if (i === -1)
86
+ return server;
87
+ return [...server.slice(0, i), ...server.slice(i + 1)];
88
+ }
89
+ /** Build the TransportSpec from common options + the positional stdio command. */
90
+ export function buildSpec(opts, server) {
91
+ assertNoSwallowedFlags(server);
92
+ const cleaned = stripSeparator(server);
93
+ const command = cleaned[0];
94
+ const hasStdio = command !== undefined;
95
+ const hasUrl = opts.url !== undefined && opts.url !== '';
96
+ if (hasStdio && hasUrl) {
97
+ throw new UsageError('pass either a stdio command OR --url, not both');
98
+ }
99
+ const requestTimeoutMs = opts.timeout !== undefined && Number.isFinite(opts.timeout) ? opts.timeout : DEFAULT_TIMEOUT_MS;
100
+ const connectTimeoutMs = opts.connectTimeout !== undefined && Number.isFinite(opts.connectTimeout)
101
+ ? opts.connectTimeout
102
+ : requestTimeoutMs;
103
+ return {
104
+ forced: parseTransport(opts.transport),
105
+ command,
106
+ args: cleaned.slice(1),
107
+ url: hasUrl ? opts.url : undefined,
108
+ headers: parseKv(opts.header, ':', '--header'),
109
+ env: parseKv(opts.env, '=', '--env'),
110
+ inheritEnv: opts.inheritEnv === true,
111
+ connectTimeoutMs,
112
+ requestTimeoutMs,
113
+ };
114
+ }
@@ -0,0 +1,33 @@
1
+ export declare const EXIT_CODES: {
2
+ readonly SUCCESS: 0;
3
+ readonly ASSERTION: 2;
4
+ readonly CONNECTION: 3;
5
+ readonly USAGE: 4;
6
+ readonly TOOL_ERROR: 5;
7
+ readonly CONFIG: 6;
8
+ };
9
+ export declare class CliError extends Error {
10
+ readonly exitCode: number;
11
+ constructor(message: string, exitCode: number);
12
+ }
13
+ /** Bad/conflicting flags, malformed --args JSON, etc. */
14
+ export declare class UsageError extends CliError {
15
+ constructor(message: string);
16
+ }
17
+ /** Could not connect / handshake / initialize. */
18
+ export declare class ConnectionError extends CliError {
19
+ constructor(message: string);
20
+ }
21
+ /** A `--fail-on` / `check` assertion failed, SLA exceeded, or an invalid schema. */
22
+ export declare class AssertionFailure extends CliError {
23
+ constructor(message: string);
24
+ }
25
+ /** A single `call` returned isError:true without --expect-error. */
26
+ export declare class ToolErrorExit extends CliError {
27
+ constructor(message: string);
28
+ }
29
+ /** check assertions file missing / unparseable / schema-invalid. */
30
+ export declare class ConfigError extends CliError {
31
+ constructor(message: string);
32
+ }
33
+ export declare function toExitCode(err: unknown): number;
package/dist/errors.js ADDED
@@ -0,0 +1,52 @@
1
+ // Typed error classes mapped to stable exit codes (see SPEC §6).
2
+ export const EXIT_CODES = {
3
+ SUCCESS: 0,
4
+ ASSERTION: 2,
5
+ CONNECTION: 3,
6
+ USAGE: 4,
7
+ TOOL_ERROR: 5,
8
+ CONFIG: 6,
9
+ };
10
+ export class CliError extends Error {
11
+ exitCode;
12
+ constructor(message, exitCode) {
13
+ super(message);
14
+ this.name = new.target.name;
15
+ this.exitCode = exitCode;
16
+ }
17
+ }
18
+ /** Bad/conflicting flags, malformed --args JSON, etc. */
19
+ export class UsageError extends CliError {
20
+ constructor(message) {
21
+ super(message, EXIT_CODES.USAGE);
22
+ }
23
+ }
24
+ /** Could not connect / handshake / initialize. */
25
+ export class ConnectionError extends CliError {
26
+ constructor(message) {
27
+ super(message, EXIT_CODES.CONNECTION);
28
+ }
29
+ }
30
+ /** A `--fail-on` / `check` assertion failed, SLA exceeded, or an invalid schema. */
31
+ export class AssertionFailure extends CliError {
32
+ constructor(message) {
33
+ super(message, EXIT_CODES.ASSERTION);
34
+ }
35
+ }
36
+ /** A single `call` returned isError:true without --expect-error. */
37
+ export class ToolErrorExit extends CliError {
38
+ constructor(message) {
39
+ super(message, EXIT_CODES.TOOL_ERROR);
40
+ }
41
+ }
42
+ /** check assertions file missing / unparseable / schema-invalid. */
43
+ export class ConfigError extends CliError {
44
+ constructor(message) {
45
+ super(message, EXIT_CODES.CONFIG);
46
+ }
47
+ }
48
+ export function toExitCode(err) {
49
+ if (err instanceof CliError)
50
+ return err.exitCode;
51
+ return 1;
52
+ }
package/dist/glob.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare function matchesGlob(name: string, glob: string): boolean;
2
+ /** True if `name` matches any of the provided globs (empty list => true). */
3
+ export declare function matchesAny(name: string, globs: string[] | undefined): boolean;