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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/bin/mcp-vitals.js +12 -0
- package/dist/args.d.ts +9 -0
- package/dist/args.js +50 -0
- package/dist/assertions/loader.d.ts +9 -0
- package/dist/assertions/loader.js +75 -0
- package/dist/assertions/run.d.ts +19 -0
- package/dist/assertions/run.js +154 -0
- package/dist/assertions/schema.d.ts +147 -0
- package/dist/assertions/schema.js +72 -0
- package/dist/bench/engine.d.ts +7 -0
- package/dist/bench/engine.js +121 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +137 -0
- package/dist/commands/bench.d.ts +13 -0
- package/dist/commands/bench.js +129 -0
- package/dist/commands/call.d.ts +8 -0
- package/dist/commands/call.js +84 -0
- package/dist/commands/check.d.ts +13 -0
- package/dist/commands/check.js +140 -0
- package/dist/commands/inspect.d.ts +10 -0
- package/dist/commands/inspect.js +129 -0
- package/dist/commands/ping.d.ts +6 -0
- package/dist/commands/ping.js +55 -0
- package/dist/context.d.ts +30 -0
- package/dist/context.js +114 -0
- package/dist/errors.d.ts +33 -0
- package/dist/errors.js +52 -0
- package/dist/glob.d.ts +3 -0
- package/dist/glob.js +40 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/mcpClient.d.ts +38 -0
- package/dist/mcpClient.js +192 -0
- package/dist/output.d.ts +2 -0
- package/dist/output.js +8 -0
- package/dist/renderers/colors.d.ts +9 -0
- package/dist/renderers/colors.js +16 -0
- package/dist/renderers/json.d.ts +2 -0
- package/dist/renderers/json.js +5 -0
- package/dist/renderers/junit.d.ts +3 -0
- package/dist/renderers/junit.js +32 -0
- package/dist/renderers/progress.d.ts +16 -0
- package/dist/renderers/progress.js +29 -0
- package/dist/renderers/table.d.ts +14 -0
- package/dist/renderers/table.js +43 -0
- package/dist/schema.d.ts +12 -0
- package/dist/schema.js +47 -0
- package/dist/stats.d.ts +12 -0
- package/dist/stats.js +54 -0
- package/dist/thresholds.d.ts +21 -0
- package/dist/thresholds.js +109 -0
- package/dist/transport.d.ts +8 -0
- package/dist/transport.js +68 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.js +2 -0
- package/package.json +83 -0
- 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
|
+
[](https://github.com/shaxzodbek-uzb/mcp-vitals/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/mcp-vitals)
|
|
7
|
+
[](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
|
+
}
|