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
package/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] — 2026-06-20
10
+
11
+ Initial release.
12
+
13
+ ### Added
14
+
15
+ - `bench` — latency-benchmark a tool or no-arg probe: warmup + N iterations (or
16
+ `--duration`), closed-model concurrency (`-c`) or open-model arrival rate
17
+ (`--rps`), reporting min/mean/p50/p90/p95/p99/max/stddev, cold-start,
18
+ throughput, and error rate. Inline SLA gating via `--fail-on 'p95<200ms'`.
19
+ - `check` — run a committed `mcp-vitals.{yaml,yml,json}` assertion suite
20
+ (capability presence, inputSchema validity, latency SLAs) with JUnit/JSON
21
+ reporters and distinct exit codes — the CI gate.
22
+ - `inspect` — list tools/resources/prompts and validate every tool's
23
+ `inputSchema` is valid JSON Schema.
24
+ - `call` — invoke one tool with timing, `--raw` and `--expect-error` modes.
25
+ - `ping` — handshake/initialize latency and liveness, single or distribution.
26
+ - Transports: stdio, Streamable HTTP, and SSE (with automatic HTTP→SSE
27
+ fallback), header-based auth, and stdio env injection.
28
+ - `--json` single-object output on every command, with strict stdout/stderr
29
+ discipline so `| jq` always works.
30
+ - Published JSON Schema for `mcp-vitals.yaml` editor IntelliSense.
31
+
32
+ [Unreleased]: https://github.com/shaxzodbek-uzb/mcp-vitals/compare/v0.1.0...HEAD
33
+ [0.1.0]: https://github.com/shaxzodbek-uzb/mcp-vitals/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shaxzodbek Sobirov / Blaze
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # mcp-vitals
2
+
3
+ > **Vital signs for your MCP server.** Inspect capabilities, **benchmark tool-call latency (p50/p95/p99)**, and **assert health in CI** — the `ab` / `k6` / `pytest` for [Model Context Protocol](https://modelcontextprotocol.io) servers. Non-interactive, scriptable, no LLM key required.
4
+
5
+ [![CI](https://github.com/shaxzodbek-uzb/mcp-vitals/actions/workflows/ci.yml/badge.svg)](https://github.com/shaxzodbek-uzb/mcp-vitals/actions/workflows/ci.yml)
6
+ [![npm](https://img.shields.io/npm/v/mcp-vitals.svg)](https://www.npmjs.com/package/mcp-vitals)
7
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+
9
+ ```bash
10
+ # Benchmark a tool's latency — no install, no API key
11
+ npx mcp-vitals bench --tool search --args '{"q":"hello"}' npx your-mcp-server
12
+ ```
13
+
14
+ ```
15
+ Target: search (tool)
16
+ Server: npx your-mcp-server via stdio
17
+ Load: 50 iters, concurrency 1, warmup 1
18
+
19
+ Cold start 41.8 ms
20
+
21
+ min mean p50 p90 p95 p99 max stddev
22
+ 6.1 ms 8.0 ms 7.6 ms 9.9 ms 11.4 ms 18.7 ms 22.0 ms 2.4 ms
23
+
24
+ 50 completed · 124.6 req/s · 0 errors
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Why mcp-vitals?
30
+
31
+ There are great tools for *exploring* an MCP server interactively. There is nothing for **measuring** one and **gating CI** on the result.
32
+
33
+ | | [Inspector](https://github.com/modelcontextprotocol/inspector) | [mcpjam](https://github.com/MCPJam/inspector) | [mcp-probe](https://github.com/conikeec/mcp-probe) | **mcp-vitals** |
34
+ |---|:---:|:---:|:---:|:---:|
35
+ | Inspect tools / resources / prompts | ✅ | ✅ | ✅ | ✅ |
36
+ | One-shot tool call | ✅ | ✅ | ✅ | ✅ |
37
+ | **Latency percentiles (p50/p95/p99)** | ❌ | ❌ | ❌ | **✅** |
38
+ | **Concurrency / throughput load** | ❌ | ❌ | ❌ | **✅** |
39
+ | **Gate a PR on a latency budget** | ❌ | ❌ | ❌ | **✅** |
40
+ | LLM-free (no API key) | ✅ | ❌ | ✅ | ✅ |
41
+ | `npx`-installable | ✅ | ✅ | cargo | ✅ |
42
+ | JUnit + JSON reporters | ❌ | ✅ | partial | ✅ |
43
+
44
+ mcp-vitals measures the **real `tools/call` round-trip** with `process.hrtime` — pure protocol latency, no model inference mixed in — and lets you commit a `mcp-vitals.yaml` that fails the build when a tool goes missing, ships an invalid schema, or blows its latency budget.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ npx mcp-vitals --help # zero-install
50
+ npm i -g mcp-vitals # or install globally
51
+ ```
52
+
53
+ Requires Node.js ≥ 20.
54
+
55
+ ## Commands
56
+
57
+ mcp-vitals connects to any MCP server over **stdio** (a launch command), **Streamable HTTP**, or **SSE** (`--url`).
58
+
59
+ > Put mcp-vitals options *before* the server command: `mcp-vitals bench --tool x -n 100 npx your-server`.
60
+
61
+ ### `bench` — latency benchmark (the headline)
62
+
63
+ ```bash
64
+ # 200 iterations, 5 warmup, fail the command if p95 exceeds 200ms
65
+ mcp-vitals bench --tool search --args '{"q":"test"}' \
66
+ -n 200 -w 5 --fail-on 'p95<200ms' --fail-on 'errorRate<=0' \
67
+ npx your-mcp-server
68
+
69
+ # Load test: keep 10 calls in flight
70
+ mcp-vitals bench --tool search -c 10 -n 500 npx your-mcp-server
71
+
72
+ # Baseline raw transport overhead with a no-arg probe
73
+ mcp-vitals bench --probe listTools npx your-mcp-server
74
+ ```
75
+
76
+ Reports `min / mean / p50 / p90 / p95 / p99 / max / stddev`, cold-start, throughput, and error rate. `--json` emits the full distribution for dashboards.
77
+
78
+ ### `check` — the CI gate
79
+
80
+ Commit a `mcp-vitals.yaml` next to your server and run one line in CI:
81
+
82
+ ```bash
83
+ mcp-vitals check --junit report.xml
84
+ ```
85
+
86
+ ```
87
+ Capabilities
88
+ CHECK EXPECTED ACTUAL STATUS
89
+ tools/search tools present present PASS
90
+
91
+ Latency SLAs
92
+ CHECK EXPECTED ACTUAL STATUS
93
+ search/p95 p95 <= 200 ms 182 ms PASS
94
+ search/errorRate errorRate <= 0% 0.0% PASS
95
+
96
+ 3 passed, 0 failed, 0 skipped in 4.21s
97
+ ```
98
+
99
+ See [the assertions file format](#assertions-file-mcp-vitalsyaml) below.
100
+
101
+ ### `inspect` — discover & validate
102
+
103
+ ```bash
104
+ mcp-vitals inspect npx your-mcp-server # capabilities + tools/resources/prompts
105
+ mcp-vitals inspect --json npx your-mcp-server # machine-readable
106
+ ```
107
+
108
+ Lists everything the server exposes and **validates every tool's `inputSchema`** is valid JSON Schema (exit 2 on any invalid).
109
+
110
+ ### `call` — invoke one tool
111
+
112
+ ```bash
113
+ mcp-vitals call --tool search --args '{"q":"hello"}' npx your-mcp-server
114
+ mcp-vitals call --tool search --args @query.json --raw npx your-mcp-server | jq
115
+ ```
116
+
117
+ ### `ping` — handshake latency / liveness
118
+
119
+ ```bash
120
+ mcp-vitals ping npx your-mcp-server # connected in 142 ms
121
+ mcp-vitals ping -n 20 npx your-mcp-server # distribution over 20 handshakes
122
+ ```
123
+
124
+ ## Transports & auth
125
+
126
+ ```bash
127
+ # stdio (default): the trailing command launches the server
128
+ mcp-vitals inspect node dist/server.js
129
+
130
+ # Streamable HTTP / SSE (auto-falls back to SSE)
131
+ mcp-vitals inspect --url https://api.example.com/mcp \
132
+ --header "Authorization: Bearer $TOKEN"
133
+
134
+ # inject env into a stdio child (allowlisted by default)
135
+ mcp-vitals bench --tool search --env API_KEY=$API_KEY npx your-mcp-server
136
+ ```
137
+
138
+ ## Assertions file (`mcp-vitals.yaml`)
139
+
140
+ Auto-discovered as `mcp-vitals.{yaml,yml,json}` in the working directory. `${VAR}` values expand from the environment, so secrets stay out of the file.
141
+
142
+ ```yaml
143
+ $schema: https://unpkg.com/mcp-vitals/schema/assertions.schema.json
144
+
145
+ server:
146
+ command: node
147
+ args: ["dist/server.js"]
148
+ # url: https://api.example.com/mcp
149
+ # headers: { Authorization: "Bearer ${MCP_TOKEN}" }
150
+
151
+ defaults:
152
+ iterations: 100
153
+ warmup: 3
154
+
155
+ expect:
156
+ tools: [search, fetch-url]
157
+ schemasValid: true # every listed tool's inputSchema must be valid
158
+
159
+ latency:
160
+ - id: search-fast
161
+ tool: search
162
+ args: { q: "ping" }
163
+ p95: 200ms # bare numbers are ms; '1.5s' also works
164
+ p99: 500ms
165
+ errorRate: 0 # 0 = no tool errors allowed
166
+
167
+ - id: handshake
168
+ probe: listTools
169
+ p95: 100ms
170
+ ```
171
+
172
+ Add it to CI — see [`examples/github-actions.yml`](examples/github-actions.yml).
173
+
174
+ ## Exit codes
175
+
176
+ mcp-vitals uses **distinct exit codes** so a pipeline can branch on the failure class:
177
+
178
+ | Code | Meaning |
179
+ |:---:|---|
180
+ | `0` | success — all assertions passed |
181
+ | `2` | **health/assertion failed** — SLA exceeded, tool errored, or invalid schema (the "red build") |
182
+ | `3` | **connection failed** — couldn't connect / handshake (infra, distinct from a bad server) |
183
+ | `4` | usage error — bad/conflicting flags or malformed `--args` |
184
+ | `5` | tool error — a single `call` returned `isError` (without `--expect-error`) |
185
+ | `6` | config error — assertions file missing / unparseable / invalid |
186
+
187
+ ## JSON & scripting
188
+
189
+ Every command supports `--json` for a single, self-contained object on stdout (everything else goes to stderr, so `| jq` always works):
190
+
191
+ ```bash
192
+ mcp-vitals bench --tool search -n 100 --json npx your-mcp-server | jq '.warm.p95'
193
+ ```
194
+
195
+ ## Library use
196
+
197
+ ```ts
198
+ import { Connection, runBench, computeStats } from 'mcp-vitals';
199
+
200
+ const conn = await Connection.connect({
201
+ command: 'npx', args: ['your-mcp-server'], headers: {}, env: {},
202
+ inheritEnv: false, connectTimeoutMs: 10_000, requestTimeoutMs: 10_000,
203
+ });
204
+ const result = await runBench(conn, {
205
+ targetKind: 'tool', targetName: 'search', args: { q: 'hi' },
206
+ iterations: 100, warmup: 3, concurrency: 1, keepAlive: true,
207
+ }, 10_000);
208
+ console.log(result.warm?.p95);
209
+ await conn.close();
210
+ ```
211
+
212
+ ## Notes on benchmarking
213
+
214
+ Latency numbers are only as stable as the host. Run benchmarks on a quiet machine, always keep a warmup (cold-start is reported separately), and watch `stddev` to spot a noisy environment. mcp-vitals' own test suite gates on **relative** ordering, not absolute milliseconds — a good practice for your CI thresholds too (set budgets with headroom).
215
+
216
+ ## Contributing
217
+
218
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome.
219
+
220
+ ## License
221
+
222
+ [MIT](LICENSE) © Shaxzodbek Sobirov / Blaze
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../dist/cli.js';
3
+
4
+ main(process.argv)
5
+ .then((code) => {
6
+ process.exitCode = code;
7
+ })
8
+ .catch((err) => {
9
+ // Last-resort guard; commands map their own errors to exit codes.
10
+ process.stderr.write(`mcp-vitals: ${err?.stack ?? err}\n`);
11
+ process.exitCode = 1;
12
+ });
package/dist/args.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Resolve a `--args` value into a parsed JSON object.
3
+ * - undefined / empty => `{}`
4
+ * - `-` => read JSON from stdin
5
+ * - `@path` => read JSON from a file
6
+ * - otherwise => inline JSON
7
+ * Malformed JSON throws a UsageError (exit 4).
8
+ */
9
+ export declare function resolveArgs(input: string | undefined): Promise<unknown>;
package/dist/args.js ADDED
@@ -0,0 +1,50 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { UsageError } from './errors.js';
3
+ async function readStdin() {
4
+ const chunks = [];
5
+ for await (const chunk of process.stdin) {
6
+ chunks.push(chunk);
7
+ }
8
+ return Buffer.concat(chunks).toString('utf8');
9
+ }
10
+ /**
11
+ * Resolve a `--args` value into a parsed JSON object.
12
+ * - undefined / empty => `{}`
13
+ * - `-` => read JSON from stdin
14
+ * - `@path` => read JSON from a file
15
+ * - otherwise => inline JSON
16
+ * Malformed JSON throws a UsageError (exit 4).
17
+ */
18
+ export async function resolveArgs(input) {
19
+ if (input === undefined || input === '')
20
+ return {};
21
+ let text;
22
+ let source;
23
+ if (input === '-') {
24
+ text = await readStdin();
25
+ source = 'stdin';
26
+ }
27
+ else if (input.startsWith('@')) {
28
+ const path = input.slice(1);
29
+ try {
30
+ text = await readFile(path, 'utf8');
31
+ }
32
+ catch (err) {
33
+ throw new UsageError(`cannot read --args file "${path}": ${err.message}`);
34
+ }
35
+ source = `file "${path}"`;
36
+ }
37
+ else {
38
+ text = input;
39
+ source = 'inline JSON';
40
+ }
41
+ const trimmed = text.trim();
42
+ if (trimmed === '')
43
+ return {};
44
+ try {
45
+ return JSON.parse(trimmed);
46
+ }
47
+ catch (err) {
48
+ throw new UsageError(`invalid JSON from ${source}: ${err.message}`);
49
+ }
50
+ }
@@ -0,0 +1,9 @@
1
+ import type { AssertionsConfig } from '../types.js';
2
+ /** Find the first mcp-vitals config in `cwd`, or undefined. */
3
+ export declare function discoverConfig(cwd?: string): string | undefined;
4
+ /** Expand ${VAR} from process.env in every string value, recursively. */
5
+ export declare function expandEnv<T>(value: T): T;
6
+ export declare function loadConfig(explicitPath?: string): Promise<{
7
+ path: string;
8
+ config: AssertionsConfig;
9
+ }>;
@@ -0,0 +1,75 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { ConfigError } from '../errors.js';
6
+ import { validateData } from '../schema.js';
7
+ import { ASSERTIONS_SCHEMA } from './schema.js';
8
+ const DISCOVERY_ORDER = ['mcp-vitals.yaml', 'mcp-vitals.yml', 'mcp-vitals.json'];
9
+ /** Find the first mcp-vitals config in `cwd`, or undefined. */
10
+ export function discoverConfig(cwd = process.cwd()) {
11
+ for (const name of DISCOVERY_ORDER) {
12
+ const path = resolve(cwd, name);
13
+ if (existsSync(path))
14
+ return path;
15
+ }
16
+ return undefined;
17
+ }
18
+ /** Expand ${VAR} from process.env in every string value, recursively. */
19
+ export function expandEnv(value) {
20
+ if (typeof value === 'string') {
21
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
22
+ return process.env[name] ?? '';
23
+ });
24
+ }
25
+ if (Array.isArray(value)) {
26
+ return value.map((v) => expandEnv(v));
27
+ }
28
+ if (value !== null && typeof value === 'object') {
29
+ const out = {};
30
+ for (const [k, v] of Object.entries(value))
31
+ out[k] = expandEnv(v);
32
+ return out;
33
+ }
34
+ return value;
35
+ }
36
+ export async function loadConfig(explicitPath) {
37
+ const path = explicitPath ? resolve(explicitPath) : discoverConfig();
38
+ if (!path) {
39
+ throw new ConfigError('no assertions file found (looked for mcp-vitals.yaml/yml/json) — pass one with -c <path>');
40
+ }
41
+ if (!existsSync(path)) {
42
+ throw new ConfigError(`assertions file not found: ${path}`);
43
+ }
44
+ let text;
45
+ try {
46
+ text = await readFile(path, 'utf8');
47
+ }
48
+ catch (err) {
49
+ throw new ConfigError(`cannot read ${path}: ${err.message}`);
50
+ }
51
+ let data;
52
+ try {
53
+ data = parseYaml(text); // YAML is a superset of JSON, so this handles both.
54
+ }
55
+ catch (err) {
56
+ throw new ConfigError(`cannot parse ${path}: ${err.message}`);
57
+ }
58
+ const expanded = expandEnv(data);
59
+ const check = validateData(ASSERTIONS_SCHEMA, expanded);
60
+ if (!check.valid) {
61
+ const first = check.errors[0];
62
+ const detail = first ? `${first.path} ${first.message}` : 'schema validation failed';
63
+ throw new ConfigError(`invalid assertions file ${path}: ${detail}`);
64
+ }
65
+ const config = expanded;
66
+ if (!config.server.command && !config.server.url) {
67
+ throw new ConfigError(`invalid assertions file ${path}: server must set "command" or "url"`);
68
+ }
69
+ for (const a of config.latency ?? []) {
70
+ if (!a.tool && !a.probe) {
71
+ throw new ConfigError(`invalid assertions file ${path}: latency assertion "${a.id}" must set "tool" or "probe"`);
72
+ }
73
+ }
74
+ return { path, config };
75
+ }
@@ -0,0 +1,19 @@
1
+ import type { Connection } from '../mcpClient.js';
2
+ import type { AssertionsConfig, CheckRow, CheckSummary, LatencyAssertion } from '../types.js';
3
+ export interface RunOptions {
4
+ noLatency: boolean;
5
+ only: string[];
6
+ skip: string[];
7
+ bail: boolean;
8
+ iterations?: number;
9
+ warmup?: number;
10
+ concurrency?: number;
11
+ timeoutMs: number;
12
+ }
13
+ declare const LATENCY_METRICS: readonly ["p50", "p90", "p95", "p99", "max", "mean", "errorRate"];
14
+ type LatencyMetric = (typeof LATENCY_METRICS)[number];
15
+ export declare function runChecks(conn: Connection, config: AssertionsConfig, options: RunOptions): Promise<{
16
+ rows: CheckRow[];
17
+ summary: CheckSummary;
18
+ }>;
19
+ export type { LatencyAssertion, LatencyMetric };
@@ -0,0 +1,154 @@
1
+ import { runBench } from '../bench/engine.js';
2
+ import { evaluate } from '../thresholds.js';
3
+ import { parseBound } from '../thresholds.js';
4
+ import { validateJsonSchema } from '../schema.js';
5
+ import { matchesGlob } from '../glob.js';
6
+ import { formatMs, formatPct } from '../renderers/table.js';
7
+ const LATENCY_METRICS = ['p50', 'p90', 'p95', 'p99', 'max', 'mean', 'errorRate'];
8
+ function included(id, only, skip) {
9
+ const passOnly = only.length === 0 || only.some((g) => matchesGlob(id, g));
10
+ const blocked = skip.length > 0 && skip.some((g) => matchesGlob(id, g));
11
+ return passOnly && !blocked;
12
+ }
13
+ function displayBound(metric, bound) {
14
+ return metric === 'errorRate' ? formatPct(bound) : formatMs(bound);
15
+ }
16
+ function displayActual(metric, actual) {
17
+ if (actual === null)
18
+ return '—';
19
+ return metric === 'errorRate' ? formatPct(actual) : formatMs(actual);
20
+ }
21
+ export async function runChecks(conn, config, options) {
22
+ const start = process.hrtime.bigint();
23
+ const rows = [];
24
+ let bailed = false;
25
+ const add = (row) => {
26
+ rows.push(row);
27
+ if (options.bail && row.status === 'fail')
28
+ bailed = true;
29
+ };
30
+ const tools = await conn.listTools();
31
+ const toolByName = new Map(tools.map((t) => [t.name, t]));
32
+ const resources = await conn.listResources();
33
+ const prompts = await conn.listPrompts();
34
+ const resourceNames = new Set(resources.map((r) => r.name).filter((n) => !!n));
35
+ const promptNames = new Set(prompts.map((p) => p.name));
36
+ const expect = config.expect ?? {};
37
+ // ---- presence suite ----
38
+ const presence = [
39
+ ['tools', expect.tools ?? [], (n) => toolByName.has(n)],
40
+ ['resources', expect.resources ?? [], (n) => resourceNames.has(n)],
41
+ ['prompts', expect.prompts ?? [], (n) => promptNames.has(n)],
42
+ ];
43
+ for (const [kind, names, has] of presence) {
44
+ for (const name of names) {
45
+ if (bailed)
46
+ break;
47
+ const id = `${kind}/${name}`;
48
+ if (!included(id, options.only, options.skip)) {
49
+ add({ id, kind: 'presence', target: name, expected: 'present', actual: 'skipped', status: 'skip' });
50
+ continue;
51
+ }
52
+ const ok = has(name);
53
+ add({
54
+ id,
55
+ kind: 'presence',
56
+ target: name,
57
+ expected: `${kind} present`,
58
+ actual: ok ? 'present' : 'MISSING',
59
+ status: ok ? 'pass' : 'fail',
60
+ });
61
+ }
62
+ }
63
+ // ---- schema suite ----
64
+ if (expect.schemasValid && !bailed) {
65
+ const targets = (expect.tools && expect.tools.length > 0 ? expect.tools : tools.map((t) => t.name));
66
+ for (const name of targets) {
67
+ if (bailed)
68
+ break;
69
+ const id = `schema/${name}`;
70
+ if (!included(id, options.only, options.skip)) {
71
+ add({ id, kind: 'schema', target: name, expected: 'valid', actual: 'skipped', status: 'skip' });
72
+ continue;
73
+ }
74
+ const tool = toolByName.get(name);
75
+ if (!tool) {
76
+ add({ id, kind: 'schema', target: name, expected: 'valid inputSchema', actual: 'tool missing', status: 'fail' });
77
+ continue;
78
+ }
79
+ const res = validateJsonSchema(tool.inputSchema);
80
+ add({
81
+ id,
82
+ kind: 'schema',
83
+ target: name,
84
+ expected: 'valid inputSchema',
85
+ actual: res.valid ? 'valid' : (res.errors[0]?.message ?? 'invalid'),
86
+ status: res.valid ? 'pass' : 'fail',
87
+ });
88
+ }
89
+ }
90
+ // ---- latency suite ----
91
+ for (const assertion of config.latency ?? []) {
92
+ if (bailed)
93
+ break;
94
+ const thresholds = LATENCY_METRICS.filter((m) => assertion[m] !== undefined);
95
+ if (thresholds.length === 0)
96
+ continue;
97
+ const targetName = assertion.tool ?? assertion.probe ?? 'listTools';
98
+ const targetKind = assertion.tool ? 'tool' : 'probe';
99
+ if (options.noLatency) {
100
+ for (const metric of thresholds) {
101
+ const id = `${assertion.id}/${metric}`;
102
+ add({ id, kind: 'latency', target: targetName, expected: 'latency SLA', actual: 'skipped', status: 'skip' });
103
+ }
104
+ continue;
105
+ }
106
+ // Determine which thresholds are actually selected before running the bench.
107
+ const selected = thresholds.filter((m) => included(`${assertion.id}/${m}`, options.only, options.skip));
108
+ if (selected.length === 0) {
109
+ for (const metric of thresholds) {
110
+ const id = `${assertion.id}/${metric}`;
111
+ add({ id, kind: 'latency', target: targetName, expected: 'latency SLA', actual: 'skipped', status: 'skip' });
112
+ }
113
+ continue;
114
+ }
115
+ const benchConfig = {
116
+ targetKind,
117
+ targetName,
118
+ args: assertion.args ?? {},
119
+ iterations: options.iterations ?? assertion.iterations ?? config.defaults?.iterations ?? 50,
120
+ warmup: options.warmup ?? assertion.warmup ?? config.defaults?.warmup ?? 1,
121
+ concurrency: options.concurrency ?? assertion.concurrency ?? config.defaults?.concurrency ?? 1,
122
+ keepAlive: true,
123
+ };
124
+ const result = await runBench(conn, benchConfig, options.timeoutMs);
125
+ for (const metric of thresholds) {
126
+ const id = `${assertion.id}/${metric}`;
127
+ if (!selected.includes(metric)) {
128
+ add({ id, kind: 'latency', target: targetName, expected: 'latency SLA', actual: 'skipped', status: 'skip' });
129
+ continue;
130
+ }
131
+ const bound = parseBound(metric, assertion[metric]);
132
+ const outcome = evaluate({ metric, op: '<=', bound }, result.warm, result.throughput);
133
+ const status = outcome.pass ? 'pass' : 'fail';
134
+ add({
135
+ id,
136
+ kind: 'latency',
137
+ target: `${targetName} ${metric}`,
138
+ expected: `${metric} <= ${displayBound(metric, bound)}`,
139
+ actual: displayActual(metric, outcome.actual),
140
+ status,
141
+ });
142
+ if (bailed)
143
+ break;
144
+ }
145
+ }
146
+ const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
147
+ const summary = {
148
+ passed: rows.filter((r) => r.status === 'pass').length,
149
+ failed: rows.filter((r) => r.status === 'fail').length,
150
+ skipped: rows.filter((r) => r.status === 'skip').length,
151
+ durationMs,
152
+ };
153
+ return { rows, summary };
154
+ }