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,147 @@
1
+ export declare const ASSERTIONS_SCHEMA: {
2
+ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
3
+ readonly $id: "https://unpkg.com/mcp-vitals/schema/assertions.schema.json";
4
+ readonly title: "mcp-vitals assertions";
5
+ readonly type: "object";
6
+ readonly additionalProperties: false;
7
+ readonly required: readonly ["server"];
8
+ readonly properties: {
9
+ readonly $schema: {
10
+ readonly type: "string";
11
+ };
12
+ readonly server: {
13
+ readonly type: "object";
14
+ readonly additionalProperties: false;
15
+ readonly properties: {
16
+ readonly command: {
17
+ readonly type: "string";
18
+ };
19
+ readonly args: {
20
+ readonly type: "array";
21
+ readonly items: {
22
+ readonly type: "string";
23
+ };
24
+ };
25
+ readonly env: {
26
+ readonly type: "object";
27
+ readonly additionalProperties: {
28
+ readonly type: "string";
29
+ };
30
+ };
31
+ readonly url: {
32
+ readonly type: "string";
33
+ };
34
+ readonly headers: {
35
+ readonly type: "object";
36
+ readonly additionalProperties: {
37
+ readonly type: "string";
38
+ };
39
+ };
40
+ readonly transport: {
41
+ readonly enum: readonly ["stdio", "http", "sse"];
42
+ };
43
+ readonly connectTimeoutMs: {
44
+ readonly type: "number";
45
+ };
46
+ readonly timeoutMs: {
47
+ readonly type: "number";
48
+ };
49
+ };
50
+ };
51
+ readonly defaults: {
52
+ readonly type: "object";
53
+ readonly additionalProperties: false;
54
+ readonly properties: {
55
+ readonly iterations: {
56
+ readonly type: "number";
57
+ };
58
+ readonly warmup: {
59
+ readonly type: "number";
60
+ };
61
+ readonly concurrency: {
62
+ readonly type: "number";
63
+ };
64
+ readonly timeoutMs: {
65
+ readonly type: "number";
66
+ };
67
+ };
68
+ };
69
+ readonly expect: {
70
+ readonly type: "object";
71
+ readonly additionalProperties: false;
72
+ readonly properties: {
73
+ readonly tools: {
74
+ readonly type: "array";
75
+ readonly items: {
76
+ readonly type: "string";
77
+ };
78
+ };
79
+ readonly resources: {
80
+ readonly type: "array";
81
+ readonly items: {
82
+ readonly type: "string";
83
+ };
84
+ };
85
+ readonly prompts: {
86
+ readonly type: "array";
87
+ readonly items: {
88
+ readonly type: "string";
89
+ };
90
+ };
91
+ readonly schemasValid: {
92
+ readonly type: "boolean";
93
+ };
94
+ };
95
+ };
96
+ readonly latency: {
97
+ readonly type: "array";
98
+ readonly items: {
99
+ readonly type: "object";
100
+ readonly additionalProperties: false;
101
+ readonly required: readonly ["id"];
102
+ readonly properties: {
103
+ readonly id: {
104
+ readonly type: "string";
105
+ };
106
+ readonly tool: {
107
+ readonly type: "string";
108
+ };
109
+ readonly probe: {
110
+ readonly enum: readonly ["listTools", "listResources", "listPrompts"];
111
+ };
112
+ readonly args: {};
113
+ readonly iterations: {
114
+ readonly type: "number";
115
+ };
116
+ readonly warmup: {
117
+ readonly type: "number";
118
+ };
119
+ readonly concurrency: {
120
+ readonly type: "number";
121
+ };
122
+ readonly p50: {
123
+ type: string[];
124
+ };
125
+ readonly p90: {
126
+ type: string[];
127
+ };
128
+ readonly p95: {
129
+ type: string[];
130
+ };
131
+ readonly p99: {
132
+ type: string[];
133
+ };
134
+ readonly max: {
135
+ type: string[];
136
+ };
137
+ readonly mean: {
138
+ type: string[];
139
+ };
140
+ readonly errorRate: {
141
+ readonly type: "number";
142
+ };
143
+ };
144
+ };
145
+ };
146
+ };
147
+ };
@@ -0,0 +1,72 @@
1
+ // JSON Schema (Draft 2020-12) for mcp-vitals.{yaml,yml,json}.
2
+ // Source of truth; schema/assertions.schema.json is a published copy of this object.
3
+ const threshold = { type: ['string', 'number'] };
4
+ export const ASSERTIONS_SCHEMA = {
5
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
6
+ $id: 'https://unpkg.com/mcp-vitals/schema/assertions.schema.json',
7
+ title: 'mcp-vitals assertions',
8
+ type: 'object',
9
+ additionalProperties: false,
10
+ required: ['server'],
11
+ properties: {
12
+ $schema: { type: 'string' },
13
+ server: {
14
+ type: 'object',
15
+ additionalProperties: false,
16
+ properties: {
17
+ command: { type: 'string' },
18
+ args: { type: 'array', items: { type: 'string' } },
19
+ env: { type: 'object', additionalProperties: { type: 'string' } },
20
+ url: { type: 'string' },
21
+ headers: { type: 'object', additionalProperties: { type: 'string' } },
22
+ transport: { enum: ['stdio', 'http', 'sse'] },
23
+ connectTimeoutMs: { type: 'number' },
24
+ timeoutMs: { type: 'number' },
25
+ },
26
+ },
27
+ defaults: {
28
+ type: 'object',
29
+ additionalProperties: false,
30
+ properties: {
31
+ iterations: { type: 'number' },
32
+ warmup: { type: 'number' },
33
+ concurrency: { type: 'number' },
34
+ timeoutMs: { type: 'number' },
35
+ },
36
+ },
37
+ expect: {
38
+ type: 'object',
39
+ additionalProperties: false,
40
+ properties: {
41
+ tools: { type: 'array', items: { type: 'string' } },
42
+ resources: { type: 'array', items: { type: 'string' } },
43
+ prompts: { type: 'array', items: { type: 'string' } },
44
+ schemasValid: { type: 'boolean' },
45
+ },
46
+ },
47
+ latency: {
48
+ type: 'array',
49
+ items: {
50
+ type: 'object',
51
+ additionalProperties: false,
52
+ required: ['id'],
53
+ properties: {
54
+ id: { type: 'string' },
55
+ tool: { type: 'string' },
56
+ probe: { enum: ['listTools', 'listResources', 'listPrompts'] },
57
+ args: {},
58
+ iterations: { type: 'number' },
59
+ warmup: { type: 'number' },
60
+ concurrency: { type: 'number' },
61
+ p50: threshold,
62
+ p90: threshold,
63
+ p95: threshold,
64
+ p99: threshold,
65
+ max: threshold,
66
+ mean: threshold,
67
+ errorRate: { type: 'number' },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ };
@@ -0,0 +1,7 @@
1
+ import type { Connection } from '../mcpClient.js';
2
+ import type { BenchConfig, BenchResult } from '../types.js';
3
+ export interface BenchHooks {
4
+ /** Called after each measured iteration with the running completed count. */
5
+ onTick?: (completed: number) => void;
6
+ }
7
+ export declare function runBench(conn: Connection, config: BenchConfig, timeoutMs: number, hooks?: BenchHooks): Promise<BenchResult>;
@@ -0,0 +1,121 @@
1
+ import { computeStats } from '../stats.js';
2
+ import { UsageError } from '../errors.js';
3
+ function buildOp(conn, config, timeoutMs) {
4
+ if (config.targetKind === 'tool') {
5
+ const name = config.targetName;
6
+ return async () => {
7
+ const r = await conn.callTool(name, config.args, timeoutMs);
8
+ return { toolError: r.isError };
9
+ };
10
+ }
11
+ const probe = config.targetName;
12
+ return async () => {
13
+ await conn.probeOnce(probe, timeoutMs);
14
+ return { toolError: false };
15
+ };
16
+ }
17
+ async function runOnce(op) {
18
+ const start = process.hrtime.bigint();
19
+ try {
20
+ const r = await op();
21
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
22
+ return { ms, threw: false, toolError: r.toolError };
23
+ }
24
+ catch {
25
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
26
+ return { ms, threw: true, toolError: false };
27
+ }
28
+ }
29
+ function delay(ms) {
30
+ return new Promise((resolve) => setTimeout(resolve, ms));
31
+ }
32
+ /** Closed model: keep `concurrency` operations in flight until the budget is exhausted. */
33
+ async function runClosed(op, concurrency, shouldContinue, onOutcome) {
34
+ const worker = async () => {
35
+ while (shouldContinue()) {
36
+ onOutcome(await runOnce(op));
37
+ }
38
+ };
39
+ const workers = [];
40
+ for (let i = 0; i < concurrency; i++)
41
+ workers.push(worker());
42
+ await Promise.all(workers);
43
+ }
44
+ /** Open model: dispatch at a target arrival rate regardless of in-flight count. */
45
+ async function runOpen(op, rps, shouldDispatch, onOutcome) {
46
+ const interval = 1000 / rps;
47
+ const inflight = [];
48
+ // Pace against an absolute clock so dispatches don't drift slow from
49
+ // cumulative setTimeout overshoot, and so there's no wasted trailing delay.
50
+ const t0 = performance.now();
51
+ let i = 0;
52
+ while (shouldDispatch()) {
53
+ if (i > 0)
54
+ await delay(Math.max(0, t0 + i * interval - performance.now()));
55
+ inflight.push(runOnce(op).then(onOutcome));
56
+ i++;
57
+ }
58
+ await Promise.all(inflight);
59
+ }
60
+ export async function runBench(conn, config, timeoutMs, hooks = {}) {
61
+ if (config.iterations !== undefined && config.durationMs !== undefined) {
62
+ throw new UsageError('cannot combine --iterations and --duration');
63
+ }
64
+ const op = buildOp(conn, config, timeoutMs);
65
+ // --- warmup (sequential, excluded from warm stats) ---
66
+ let coldStartMs = null;
67
+ for (let i = 0; i < config.warmup; i++) {
68
+ const o = await runOnce(op);
69
+ if (i === 0)
70
+ coldStartMs = o.ms;
71
+ }
72
+ // --- measured phase ---
73
+ const raw = [];
74
+ const latencySamples = [];
75
+ let toolErrors = 0;
76
+ let threws = 0;
77
+ const onOutcome = (o) => {
78
+ raw.push({ ms: o.ms, error: o.threw || o.toolError });
79
+ if (!o.threw)
80
+ latencySamples.push(o.ms); // throws have no meaningful latency
81
+ if (o.toolError)
82
+ toolErrors++;
83
+ if (o.threw)
84
+ threws++;
85
+ hooks.onTick?.(raw.length);
86
+ };
87
+ const byCount = config.iterations !== undefined;
88
+ const targetCount = config.iterations ?? 0;
89
+ const deadline = config.durationMs !== undefined ? performance.now() + config.durationMs : 0;
90
+ // For closed-count mode we must not over-dispatch past targetCount.
91
+ let dispatched = 0;
92
+ const wall0 = process.hrtime.bigint();
93
+ if (config.rps !== undefined) {
94
+ const shouldDispatch = byCount
95
+ ? () => dispatched++ < targetCount
96
+ : () => performance.now() < deadline;
97
+ await runOpen(op, config.rps, shouldDispatch, onOutcome);
98
+ }
99
+ else {
100
+ const shouldContinue = byCount
101
+ ? () => dispatched++ < targetCount
102
+ : () => performance.now() < deadline;
103
+ await runClosed(op, Math.max(1, config.concurrency), shouldContinue, onOutcome);
104
+ }
105
+ const wallMs = Number(process.hrtime.bigint() - wall0) / 1e6;
106
+ const completed = raw.length;
107
+ const errors = toolErrors + threws;
108
+ const throughput = {
109
+ rps: wallMs > 0 ? (completed / wallMs) * 1000 : 0,
110
+ completed,
111
+ errors,
112
+ errorRate: completed > 0 ? errors / completed : 0,
113
+ };
114
+ return {
115
+ coldStartMs,
116
+ warm: computeStats(latencySamples),
117
+ throughput,
118
+ samples: latencySamples,
119
+ raw,
120
+ };
121
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ export declare function buildProgram(): Command;
3
+ export declare function main(argv: string[]): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,137 @@
1
+ import { createRequire } from 'node:module';
2
+ import { Command, CommanderError } from 'commander';
3
+ import { addConnectionOptions, addOutputOptions } from './context.js';
4
+ import { CliError, EXIT_CODES, toExitCode, UsageError } from './errors.js';
5
+ import { writeErr } from './output.js';
6
+ import { runInspect } from './commands/inspect.js';
7
+ import { runPing } from './commands/ping.js';
8
+ import { runBenchCommand } from './commands/bench.js';
9
+ import { runCall } from './commands/call.js';
10
+ import { runCheck } from './commands/check.js';
11
+ const require = createRequire(import.meta.url);
12
+ const pkg = require('../package.json');
13
+ // Validating numeric parsers: a bad value throws a UsageError (exit 4) instead
14
+ // of yielding NaN, which would otherwise produce a silent zero-sample run.
15
+ const posInt = (label, min = 1) => (v) => {
16
+ const n = Number(v);
17
+ if (!Number.isInteger(n) || n < min) {
18
+ throw new UsageError(`--${label} must be an integer >= ${min} (got "${v}")`);
19
+ }
20
+ return n;
21
+ };
22
+ const posFloat = (label) => (v) => {
23
+ const n = Number(v);
24
+ if (!Number.isFinite(n) || n <= 0) {
25
+ throw new UsageError(`--${label} must be a number > 0 (got "${v}")`);
26
+ }
27
+ return n;
28
+ };
29
+ const collect = (v, prev) => prev.concat([v]);
30
+ /** Wrap a command body so its numeric exit code propagates via process.exitCode. */
31
+ function action(fn) {
32
+ return async (server, opts) => {
33
+ const code = await fn(server, opts);
34
+ if (code)
35
+ process.exitCode = code;
36
+ };
37
+ }
38
+ function withCommon(cmd) {
39
+ return addOutputOptions(addConnectionOptions(cmd));
40
+ }
41
+ export function buildProgram() {
42
+ const program = new Command();
43
+ program
44
+ .name('mcp-vitals')
45
+ .description('Vital signs for your MCP server: inspect, benchmark latency, and assert health in CI.')
46
+ .version(pkg.version, '-V, --version')
47
+ .enablePositionalOptions()
48
+ .showHelpAfterError();
49
+ withCommon(program
50
+ .command('inspect')
51
+ .description('discover capabilities, list tools/resources/prompts, validate schemas')
52
+ .argument('[server...]', 'stdio server launch command + argv'))
53
+ .passThroughOptions()
54
+ .option('--tools', 'show only tools')
55
+ .option('--resources', 'show only resources')
56
+ .option('--prompts', 'show only prompts')
57
+ .option('--schema', 'include full inputSchema in --json output')
58
+ .option('--no-validate-schemas', 'skip JSON Schema validation of tool inputSchemas')
59
+ .option('--filter <glob>', 'filter listed names by glob (* and ?)')
60
+ .action(action((server, opts) => runInspect(server, opts)));
61
+ withCommon(program
62
+ .command('ping')
63
+ .description('measure handshake/initialize latency and liveness')
64
+ .argument('[server...]', 'stdio server launch command + argv'))
65
+ .passThroughOptions()
66
+ .option('-n, --count <n>', 'number of independent reconnect cycles', posInt('count'), 1)
67
+ .option('--list', 'also time one tools/list round-trip')
68
+ .action(action((server, opts) => runPing(server, opts)));
69
+ withCommon(program
70
+ .command('bench')
71
+ .description('benchmark tool-call latency: p50/p95/p99, throughput, cold start')
72
+ .argument('[server...]', 'stdio server launch command + argv'))
73
+ .passThroughOptions()
74
+ .option('--tool <name>', 'tool to benchmark')
75
+ .option('--probe <op>', 'no-arg protocol op: listTools | listResources | listPrompts')
76
+ .option('--args <json>', "tool args JSON ('-' = stdin, '@file.json' = file)")
77
+ .option('-n, --iterations <n>', 'measured iterations (default 50)', posInt('iterations'))
78
+ .option('-w, --warmup <n>', 'warmup iterations, reported as cold start', posInt('warmup', 0), 1)
79
+ .option('-c, --concurrency <n>', 'keep N operations in flight (closed model)', posInt('concurrency'), 1)
80
+ .option('--rps <r>', 'drive R requests/sec arrival rate (open model)', posFloat('rps'))
81
+ .option('-d, --duration <ms>', 'run for a wall-clock duration instead of -n', posInt('duration'))
82
+ .option('--fail-on <expr>', "SLA gate, e.g. 'p95<200ms' (repeatable)", collect, [])
83
+ .action(action((server, opts) => runBenchCommand(server, opts)));
84
+ withCommon(program
85
+ .command('call')
86
+ .description('invoke one tool once and print the result + timing')
87
+ .argument('[server...]', 'stdio server launch command + argv'))
88
+ .passThroughOptions()
89
+ .option('--tool <name>', 'tool to call (required)')
90
+ .option('--args <json>', "tool args JSON ('-' = stdin, '@file.json' = file)")
91
+ .option('--raw', 'print only the tool result content (for piping)')
92
+ .option('--expect-error', 'exit 0 only if the call returns an MCP tool error')
93
+ .action(action((server, opts) => runCall(server, opts)));
94
+ withCommon(program
95
+ .command('check')
96
+ .description('run a committed mcp-vitals.yaml assertion suite (the CI gate)')
97
+ .argument('[server...]', 'optional stdio command override for the config server'))
98
+ .passThroughOptions()
99
+ .option('-c, --config <path>', 'assertions file (default: auto-discover mcp-vitals.{yaml,yml,json})')
100
+ .option('--junit <path>', "write JUnit XML to a path ('-' for stdout)")
101
+ .option('--only <glob>', 'run only assertions whose id matches (repeatable)', collect, [])
102
+ .option('--skip <glob>', 'skip assertions whose id matches (repeatable)', collect, [])
103
+ .option('--iterations <n>', 'override bench iterations for all latency assertions', posInt('iterations'))
104
+ .option('--warmup <n>', 'override bench warmup for all latency assertions', posInt('warmup', 0))
105
+ .option('--concurrency <n>', 'override bench concurrency for all latency assertions', posInt('concurrency'))
106
+ .option('--bail', 'stop at the first failed assertion', false)
107
+ .option('--no-latency', 'skip timed latency benches (presence + schema only)')
108
+ .action(action((server, opts) => runCheck(server, opts)));
109
+ return program;
110
+ }
111
+ function reportError(err) {
112
+ if (err instanceof CliError) {
113
+ writeErr(`mcp-vitals: ${err.message}`);
114
+ return err.exitCode;
115
+ }
116
+ writeErr(`mcp-vitals: ${err?.message ?? String(err)}`);
117
+ return toExitCode(err) === 0 ? 1 : toExitCode(err);
118
+ }
119
+ export async function main(argv) {
120
+ const program = buildProgram();
121
+ // exitOverride is per-command — apply to the program AND every subcommand so
122
+ // parse/usage errors throw a CommanderError instead of calling process.exit().
123
+ program.exitOverride();
124
+ for (const sub of program.commands)
125
+ sub.exitOverride();
126
+ try {
127
+ await program.parseAsync(argv);
128
+ }
129
+ catch (err) {
130
+ if (err instanceof CommanderError) {
131
+ // Help/version display exits 0; parse/usage errors map to our usage code.
132
+ return err.exitCode === 0 ? 0 : EXIT_CODES.USAGE;
133
+ }
134
+ return reportError(err);
135
+ }
136
+ return process.exitCode ? Number(process.exitCode) : 0;
137
+ }
@@ -0,0 +1,13 @@
1
+ import type { CommonOpts } from '../context.js';
2
+ export interface BenchOpts extends CommonOpts {
3
+ tool?: string;
4
+ probe?: string;
5
+ args?: string;
6
+ iterations?: number;
7
+ warmup: number;
8
+ concurrency: number;
9
+ rps?: number;
10
+ duration?: number;
11
+ failOn: string[];
12
+ }
13
+ export declare function runBenchCommand(server: string[], opts: BenchOpts): Promise<number>;
@@ -0,0 +1,129 @@
1
+ import { buildContext, buildSpec } from '../context.js';
2
+ import { Connection } from '../mcpClient.js';
3
+ import { runBench } from '../bench/engine.js';
4
+ import { resolveArgs } from '../args.js';
5
+ import { evaluateExpr } from '../thresholds.js';
6
+ import { AssertionFailure, UsageError } from '../errors.js';
7
+ import { makeColors } from '../renderers/colors.js';
8
+ import { emitJson } from '../renderers/json.js';
9
+ import { formatMs, formatPct, renderKeyValues, renderTable } from '../renderers/table.js';
10
+ import { writeOut } from '../output.js';
11
+ import { Progress } from '../renderers/progress.js';
12
+ const PROBES = ['listTools', 'listResources', 'listPrompts'];
13
+ export async function runBenchCommand(server, opts) {
14
+ const ctx = buildContext(opts);
15
+ const spec = buildSpec(opts, server);
16
+ const c = makeColors(ctx.color);
17
+ const progress = new Progress({ quiet: ctx.quiet, json: ctx.json });
18
+ if (opts.tool && opts.probe) {
19
+ throw new UsageError('pass either --tool or --probe, not both');
20
+ }
21
+ if (opts.iterations !== undefined && opts.duration !== undefined) {
22
+ throw new UsageError('pass either --iterations or --duration, not both');
23
+ }
24
+ if (opts.probe && !PROBES.includes(opts.probe)) {
25
+ throw new UsageError(`invalid --probe "${opts.probe}" (expected ${PROBES.join(' | ')})`);
26
+ }
27
+ const targetKind = opts.tool ? 'tool' : 'probe';
28
+ const targetName = opts.tool ?? opts.probe ?? 'listTools';
29
+ const args = targetKind === 'tool' ? await resolveArgs(opts.args) : {};
30
+ const config = {
31
+ targetKind,
32
+ targetName,
33
+ args,
34
+ iterations: opts.duration !== undefined ? undefined : (opts.iterations ?? 50),
35
+ durationMs: opts.duration,
36
+ warmup: opts.warmup,
37
+ concurrency: opts.concurrency,
38
+ rps: opts.rps,
39
+ keepAlive: true,
40
+ };
41
+ progress.note(c.dim('connecting…'));
42
+ const conn = await Connection.connect(spec);
43
+ const target = conn.target;
44
+ const total = config.iterations;
45
+ const result = await runBench(conn, config, spec.requestTimeoutMs, {
46
+ onTick: (n) => {
47
+ if (total)
48
+ progress.status(c.dim(`bench ${n}/${total}…`));
49
+ else
50
+ progress.status(c.dim(`bench ${n}…`));
51
+ },
52
+ });
53
+ progress.clearStatus();
54
+ await conn.close();
55
+ const assertions = opts.failOn.map((expr) => evaluateExpr(expr, result.warm, result.throughput));
56
+ const anyAssertFail = assertions.some((a) => !a.pass);
57
+ const implicitErrorFail = opts.failOn.length === 0 && result.throughput.errors > 0;
58
+ const failed = anyAssertFail || implicitErrorFail;
59
+ if (ctx.json) {
60
+ emitJson({
61
+ ok: !failed,
62
+ target: { kind: targetKind, name: targetName },
63
+ transport: conn.kind,
64
+ config: {
65
+ iterations: config.iterations ?? null,
66
+ durationMs: config.durationMs ?? null,
67
+ concurrency: config.concurrency,
68
+ rps: config.rps ?? null,
69
+ warmup: config.warmup,
70
+ keepAlive: config.keepAlive,
71
+ },
72
+ coldStartMs: result.coldStartMs,
73
+ warm: result.warm,
74
+ throughput: result.throughput,
75
+ assertions: assertions.map((a) => ({ expr: a.expr, actual: a.actual, pass: a.pass })),
76
+ });
77
+ }
78
+ else {
79
+ const load = config.rps !== undefined ? `${config.rps} rps` : `concurrency ${config.concurrency}`;
80
+ const size = config.durationMs !== undefined ? `${config.durationMs} ms` : `${config.iterations} iters`;
81
+ writeOut(renderKeyValues([
82
+ ['Target', `${c.bold(targetName)} ${c.dim('(' + targetKind + ')')}`],
83
+ ['Server', `${target} ${c.dim('via ' + conn.kind)}`],
84
+ ['Load', `${size}, ${load}, warmup ${config.warmup}`],
85
+ ], c));
86
+ writeOut('');
87
+ if (result.coldStartMs !== null) {
88
+ writeOut(`${c.dim('Cold start')} ${c.bold(formatMs(result.coldStartMs))}`);
89
+ }
90
+ if (result.warm) {
91
+ const w = result.warm;
92
+ writeOut('');
93
+ writeOut(renderTable([
94
+ { header: 'min', align: 'right' },
95
+ { header: 'mean', align: 'right' },
96
+ { header: 'p50', align: 'right' },
97
+ { header: 'p90', align: 'right' },
98
+ { header: 'p95', align: 'right' },
99
+ { header: 'p99', align: 'right' },
100
+ { header: 'max', align: 'right' },
101
+ { header: 'stddev', align: 'right' },
102
+ ], [[w.min, w.mean, w.p50, w.p90, w.p95, w.p99, w.max, w.stddev].map((n) => formatMs(n))], c));
103
+ }
104
+ else {
105
+ writeOut(c.red('no successful samples'));
106
+ }
107
+ const tp = result.throughput;
108
+ writeOut('');
109
+ const errPart = tp.errors > 0 ? c.red(`${tp.errors} errors (${formatPct(tp.errorRate)})`) : c.green('0 errors');
110
+ writeOut(c.dim(`${tp.completed} completed · ${tp.rps.toFixed(1)} req/s · `) + errPart);
111
+ if (assertions.length > 0) {
112
+ writeOut('');
113
+ for (const a of assertions) {
114
+ const mark = a.pass ? c.green('PASS') : c.red('FAIL');
115
+ writeOut(` ${mark} ${a.expr} ${c.dim('actual ' + formatMetric(a))}`);
116
+ }
117
+ }
118
+ }
119
+ if (failed) {
120
+ const reason = anyAssertFail ? 'a --fail-on assertion failed' : 'requests errored';
121
+ throw new AssertionFailure(reason);
122
+ }
123
+ return 0;
124
+ }
125
+ function formatMetric(a) {
126
+ if (a.actual === null)
127
+ return '—';
128
+ return a.metric === 'errorRate' ? formatPct(a.actual) : formatMs(a.actual);
129
+ }
@@ -0,0 +1,8 @@
1
+ import type { CommonOpts } from '../context.js';
2
+ export interface CallOpts extends CommonOpts {
3
+ tool?: string;
4
+ args?: string;
5
+ raw?: boolean;
6
+ expectError?: boolean;
7
+ }
8
+ export declare function runCall(server: string[], opts: CallOpts): Promise<number>;