mcpspec 1.0.3 → 1.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 (3) hide show
  1. package/README.md +12 -6
  2. package/dist/index.js +280 -2
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -24,10 +24,11 @@
24
24
  ```bash
25
25
  mcpspec test ./collection.yaml # Run tests
26
26
  mcpspec inspect "npx my-server" # Interactive REPL
27
- mcpspec audit "npx my-server" # Security scan
27
+ mcpspec audit "npx my-server" # Security scan (8 rules)
28
28
  mcpspec bench "npx my-server" # Performance benchmark
29
29
  mcpspec score "npx my-server" # Quality rating (0-100)
30
30
  mcpspec docs "npx my-server" # Auto-generate documentation
31
+ mcpspec record start "npx my-server" # Record & replay sessions
31
32
  mcpspec ui # Launch web dashboard
32
33
  ```
33
34
 
@@ -50,9 +51,10 @@ mcpspec test
50
51
  |---|---|---|
51
52
  | **Test Collections** | YAML-based test suites with 10 assertion types, environments, variables, tags, retries, and parallel execution |
52
53
  | **Interactive Inspector** | Connect to any MCP server and explore tools, resources, and schemas in a live REPL |
53
- | **Security Audit** | Scan for path traversal, injection, auth bypass, resource exhaustion, and info disclosure. Safety filter auto-skips destructive tools; `--dry-run` previews targets |
54
+ | **Security Audit** | 8 rules: path traversal, injection, auth bypass, resource exhaustion, info disclosure, **tool poisoning** (LLM prompt injection), and **excessive agency** (overly broad tools). Safety filter auto-skips destructive tools; `--dry-run` previews targets |
55
+ | **Recording & Replay** | Record inspector sessions, save them, and replay against the same or different server versions. Diff output highlights regressions — matched, changed, added, removed steps |
54
56
  | **Benchmarks** | Measure min/max/mean/median/P95/P99 latency and throughput across hundreds of iterations |
55
- | **MCP Score** | 0-100 quality rating across documentation, schema quality, error handling, responsiveness, and security |
57
+ | **MCP Score** | 0-100 quality rating across documentation, schema quality (opinionated linting: property types, descriptions, constraints, naming conventions), error handling, responsiveness, and security |
56
58
  | **Doc Generator** | Auto-generate Markdown or HTML documentation from server introspection |
57
59
  | **Web Dashboard** | Full React UI with server management, test runner, audit viewer, and dark mode |
58
60
  | **CI/CD Ready** | JUnit/JSON/TAP reporters, deterministic exit codes, `--ci` mode, GitHub Actions compatible |
@@ -69,6 +71,10 @@ mcpspec test
69
71
  | `mcpspec docs <server>` | Generate docs — `--format markdown\|html`, `--output <dir>` |
70
72
  | `mcpspec compare` | Compare test runs or `--baseline <name>` |
71
73
  | `mcpspec baseline save <name>` | Save/list baselines for regression detection |
74
+ | `mcpspec record start <server>` | Record an inspector session — `.call`, `.save`, `.steps` |
75
+ | `mcpspec record replay <name> <server>` | Replay a recording and diff against original |
76
+ | `mcpspec record list` | List saved recordings |
77
+ | `mcpspec record delete <name>` | Delete a saved recording |
72
78
  | `mcpspec init [dir]` | Scaffold project — `--template minimal\|standard\|full` |
73
79
  | `mcpspec ui` | Launch web dashboard on `localhost:6274` |
74
80
 
@@ -93,8 +99,8 @@ Pre-built test suites for popular MCP servers in [`examples/collections/servers/
93
99
  | Package | Description |
94
100
  |---------|-------------|
95
101
  | `@mcpspec/shared` | Types, Zod schemas, constants |
96
- | `@mcpspec/core` | MCP client, test runner, assertions, security scanner, profiler, doc generator, scorer |
97
- | `@mcpspec/cli` | 10 CLI commands built with Commander.js |
102
+ | `@mcpspec/core` | MCP client, test runner, assertions, security scanner (8 rules), profiler, doc generator, scorer, recording/replay |
103
+ | `@mcpspec/cli` | 11 CLI commands built with Commander.js |
98
104
  | `@mcpspec/server` | Hono HTTP server with REST API + WebSocket |
99
105
  | `@mcpspec/ui` | React SPA — TanStack Router, TanStack Query, Tailwind, shadcn/ui |
100
106
 
@@ -104,7 +110,7 @@ Pre-built test suites for popular MCP servers in [`examples/collections/servers/
104
110
  git clone https://github.com/light-handle/mcpspec.git
105
111
  cd mcpspec
106
112
  pnpm install && pnpm build
107
- pnpm test # 260 tests across core + server
113
+ pnpm test # 294 tests across core + server
108
114
  ```
109
115
 
110
116
  ## License
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command11 } from "commander";
4
+ import { Command as Command12 } from "commander";
5
5
  import { readFileSync as readFileSync3 } from "fs";
6
6
  import { dirname, join as join2 } from "path";
7
7
  import { fileURLToPath } from "url";
@@ -1116,10 +1116,287 @@ ${COLORS5.bold} MCP Score${COLORS5.reset}`);
1116
1116
  }
1117
1117
  });
1118
1118
 
1119
+ // src/commands/record.ts
1120
+ import { Command as Command11 } from "commander";
1121
+ import { createInterface as createInterface2 } from "readline";
1122
+ import { randomUUID } from "crypto";
1123
+ import { EXIT_CODES as EXIT_CODES10 } from "@mcpspec/shared";
1124
+ import {
1125
+ MCPClient as MCPClient6,
1126
+ RecordingStore,
1127
+ RecordingReplayer,
1128
+ RecordingDiffer,
1129
+ formatError as formatError7
1130
+ } from "@mcpspec/core";
1131
+ var COLORS6 = {
1132
+ reset: "\x1B[0m",
1133
+ green: "\x1B[32m",
1134
+ red: "\x1B[31m",
1135
+ yellow: "\x1B[33m",
1136
+ gray: "\x1B[90m",
1137
+ bold: "\x1B[1m",
1138
+ cyan: "\x1B[36m",
1139
+ blue: "\x1B[34m"
1140
+ };
1141
+ var recordCommand = new Command11("record").description("Record, replay, and manage inspector session recordings");
1142
+ recordCommand.command("start").description("Start a recording session (interactive REPL)").argument("<server>", 'Server command (e.g., "npx @modelcontextprotocol/server-filesystem /tmp")').action(async (serverCommand) => {
1143
+ let client = null;
1144
+ const store = new RecordingStore();
1145
+ const steps = [];
1146
+ let toolList = [];
1147
+ try {
1148
+ client = new MCPClient6({ serverConfig: serverCommand });
1149
+ console.log(`${COLORS6.cyan}Connecting to: ${COLORS6.reset}${serverCommand}`);
1150
+ await client.connect();
1151
+ const info = client.getServerInfo();
1152
+ const serverName = info?.name ?? "unknown";
1153
+ console.log(`${COLORS6.green}Connected to ${serverName}${COLORS6.reset}`);
1154
+ const tools = await client.listTools();
1155
+ toolList = tools.map((t) => ({ name: t.name, description: t.description }));
1156
+ console.log(`${COLORS6.gray}${tools.length} tools available${COLORS6.reset}`);
1157
+ console.log(`
1158
+ ${COLORS6.bold}Recording mode.${COLORS6.reset} Type ${COLORS6.bold}.help${COLORS6.reset} for commands.
1159
+ `);
1160
+ const rl = createInterface2({
1161
+ input: process.stdin,
1162
+ output: process.stdout,
1163
+ prompt: `${COLORS6.red}rec>${COLORS6.reset} `
1164
+ });
1165
+ rl.prompt();
1166
+ rl.on("line", async (line) => {
1167
+ const trimmed = line.trim();
1168
+ if (!trimmed) {
1169
+ rl.prompt();
1170
+ return;
1171
+ }
1172
+ try {
1173
+ if (trimmed === ".exit" || trimmed === ".quit") {
1174
+ if (steps.length > 0) {
1175
+ console.log(`${COLORS6.yellow}Warning: ${steps.length} unsaved step(s). Use .save <name> first, or .exit to discard.${COLORS6.reset}`);
1176
+ if (trimmed === ".exit") {
1177
+ await client?.disconnect();
1178
+ rl.close();
1179
+ process.exit(EXIT_CODES10.SUCCESS);
1180
+ }
1181
+ } else {
1182
+ await client?.disconnect();
1183
+ rl.close();
1184
+ process.exit(EXIT_CODES10.SUCCESS);
1185
+ }
1186
+ return;
1187
+ }
1188
+ if (trimmed === ".help") {
1189
+ console.log(`
1190
+ ${COLORS6.bold}Recording commands:${COLORS6.reset}
1191
+ .tools List available tools
1192
+ .call <tool> <json> Call a tool and record the result
1193
+ .steps List recorded steps
1194
+ .save <name> Save recording with given name
1195
+ .exit Disconnect and exit
1196
+ `);
1197
+ rl.prompt();
1198
+ return;
1199
+ }
1200
+ if (trimmed === ".tools") {
1201
+ if (toolList.length === 0) {
1202
+ console.log(`${COLORS6.gray}No tools available${COLORS6.reset}`);
1203
+ } else {
1204
+ console.log(`
1205
+ ${COLORS6.bold}Tools (${toolList.length}):${COLORS6.reset}`);
1206
+ for (const tool of toolList) {
1207
+ console.log(` ${COLORS6.green}${tool.name}${COLORS6.reset}`);
1208
+ if (tool.description) console.log(` ${COLORS6.gray}${tool.description}${COLORS6.reset}`);
1209
+ }
1210
+ console.log("");
1211
+ }
1212
+ rl.prompt();
1213
+ return;
1214
+ }
1215
+ if (trimmed === ".steps") {
1216
+ if (steps.length === 0) {
1217
+ console.log(`${COLORS6.gray}No steps recorded yet${COLORS6.reset}`);
1218
+ } else {
1219
+ console.log(`
1220
+ ${COLORS6.bold}Recorded steps (${steps.length}):${COLORS6.reset}`);
1221
+ for (let i = 0; i < steps.length; i++) {
1222
+ const s = steps[i];
1223
+ const status = s.isError ? `${COLORS6.red}ERROR${COLORS6.reset}` : `${COLORS6.green}OK${COLORS6.reset}`;
1224
+ console.log(` ${i + 1}. ${s.tool} ${COLORS6.gray}${JSON.stringify(s.input)}${COLORS6.reset} [${status}] ${COLORS6.gray}${s.durationMs}ms${COLORS6.reset}`);
1225
+ }
1226
+ console.log("");
1227
+ }
1228
+ rl.prompt();
1229
+ return;
1230
+ }
1231
+ if (trimmed.startsWith(".save ")) {
1232
+ const name = trimmed.slice(6).trim();
1233
+ if (!name) {
1234
+ console.log(`${COLORS6.red}Usage: .save <name>${COLORS6.reset}`);
1235
+ rl.prompt();
1236
+ return;
1237
+ }
1238
+ if (steps.length === 0) {
1239
+ console.log(`${COLORS6.yellow}No steps to save. Use .call first.${COLORS6.reset}`);
1240
+ rl.prompt();
1241
+ return;
1242
+ }
1243
+ const recording = {
1244
+ id: randomUUID(),
1245
+ name,
1246
+ serverName: info?.name,
1247
+ tools: toolList,
1248
+ steps: [...steps],
1249
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1250
+ };
1251
+ const path = store.save(name, recording);
1252
+ console.log(`${COLORS6.green}Saved recording "${name}" (${steps.length} steps) to ${path}${COLORS6.reset}`);
1253
+ rl.prompt();
1254
+ return;
1255
+ }
1256
+ if (trimmed.startsWith(".call ")) {
1257
+ const rest = trimmed.slice(6).trim();
1258
+ const spaceIdx = rest.indexOf(" ");
1259
+ let toolName;
1260
+ let args = {};
1261
+ if (spaceIdx === -1) {
1262
+ toolName = rest;
1263
+ } else {
1264
+ toolName = rest.slice(0, spaceIdx);
1265
+ const jsonStr = rest.slice(spaceIdx + 1).trim();
1266
+ try {
1267
+ args = JSON.parse(jsonStr);
1268
+ } catch {
1269
+ console.log(`${COLORS6.red}Invalid JSON: ${jsonStr}${COLORS6.reset}`);
1270
+ rl.prompt();
1271
+ return;
1272
+ }
1273
+ }
1274
+ console.log(`${COLORS6.gray}Calling ${toolName}...${COLORS6.reset}`);
1275
+ const start = performance.now();
1276
+ let output = [];
1277
+ let isError = false;
1278
+ try {
1279
+ const result = await client.callTool(toolName, args);
1280
+ output = result.content;
1281
+ isError = result.isError === true;
1282
+ } catch (err) {
1283
+ output = [{ type: "text", text: err instanceof Error ? err.message : String(err) }];
1284
+ isError = true;
1285
+ }
1286
+ const durationMs = Math.round(performance.now() - start);
1287
+ steps.push({ tool: toolName, input: args, output, isError, durationMs });
1288
+ const statusLabel = isError ? `${COLORS6.red}ERROR${COLORS6.reset}` : `${COLORS6.green}OK${COLORS6.reset}`;
1289
+ console.log(`[${statusLabel}] ${COLORS6.gray}${durationMs}ms${COLORS6.reset} (step ${steps.length})`);
1290
+ console.log(JSON.stringify(output, null, 2));
1291
+ rl.prompt();
1292
+ return;
1293
+ }
1294
+ console.log(`${COLORS6.yellow}Unknown command. Type .help for available commands.${COLORS6.reset}`);
1295
+ } catch (err) {
1296
+ const formatted = formatError7(err);
1297
+ console.log(`${COLORS6.red}${formatted.title}: ${formatted.description}${COLORS6.reset}`);
1298
+ }
1299
+ rl.prompt();
1300
+ });
1301
+ rl.on("close", async () => {
1302
+ await client?.disconnect();
1303
+ process.exit(EXIT_CODES10.SUCCESS);
1304
+ });
1305
+ } catch (err) {
1306
+ const formatted = formatError7(err);
1307
+ console.error(`
1308
+ ${formatted.title}: ${formatted.description}`);
1309
+ formatted.suggestions.forEach((s) => console.error(` - ${s}`));
1310
+ await client?.disconnect();
1311
+ process.exit(formatted.exitCode);
1312
+ }
1313
+ });
1314
+ recordCommand.command("list").description("List saved recordings").action(() => {
1315
+ const store = new RecordingStore();
1316
+ const recordings = store.list();
1317
+ if (recordings.length === 0) {
1318
+ console.log(`${COLORS6.gray}No recordings found.${COLORS6.reset}`);
1319
+ return;
1320
+ }
1321
+ console.log(`
1322
+ ${COLORS6.bold}Saved recordings (${recordings.length}):${COLORS6.reset}`);
1323
+ for (const name of recordings) {
1324
+ const recording = store.load(name);
1325
+ if (recording) {
1326
+ console.log(` ${COLORS6.green}${name}${COLORS6.reset} ${COLORS6.gray}(${recording.steps.length} steps, ${recording.createdAt})${COLORS6.reset}`);
1327
+ } else {
1328
+ console.log(` ${COLORS6.green}${name}${COLORS6.reset}`);
1329
+ }
1330
+ }
1331
+ console.log("");
1332
+ });
1333
+ recordCommand.command("replay").description("Replay a recording against a server and show diff").argument("<name>", "Recording name").argument("<server>", "Server command").action(async (name, serverCommand) => {
1334
+ const store = new RecordingStore();
1335
+ const recording = store.load(name);
1336
+ if (!recording) {
1337
+ console.error(`${COLORS6.red}Recording "${name}" not found.${COLORS6.reset}`);
1338
+ process.exit(EXIT_CODES10.ERROR);
1339
+ }
1340
+ let client = null;
1341
+ try {
1342
+ client = new MCPClient6({ serverConfig: serverCommand });
1343
+ console.log(`${COLORS6.cyan}Connecting to: ${COLORS6.reset}${serverCommand}`);
1344
+ await client.connect();
1345
+ console.log(`${COLORS6.green}Connected. Replaying ${recording.steps.length} steps...${COLORS6.reset}
1346
+ `);
1347
+ const replayer = new RecordingReplayer();
1348
+ const result = await replayer.replay(recording, client, {
1349
+ onStepStart: (i, step) => {
1350
+ process.stdout.write(` ${i + 1}/${recording.steps.length} ${step.tool}... `);
1351
+ },
1352
+ onStepComplete: (_i, replayed) => {
1353
+ const status = replayed.isError ? `${COLORS6.red}ERROR${COLORS6.reset}` : `${COLORS6.green}OK${COLORS6.reset}`;
1354
+ console.log(`[${status}] ${COLORS6.gray}${replayed.durationMs}ms${COLORS6.reset}`);
1355
+ }
1356
+ });
1357
+ const differ = new RecordingDiffer();
1358
+ const diff = differ.diff(recording, result.replayedSteps, result.replayedAt);
1359
+ console.log(`
1360
+ ${COLORS6.bold}Diff Summary:${COLORS6.reset}`);
1361
+ console.log(` ${COLORS6.green}Matched:${COLORS6.reset} ${diff.summary.matched}`);
1362
+ console.log(` ${COLORS6.yellow}Changed:${COLORS6.reset} ${diff.summary.changed}`);
1363
+ console.log(` ${COLORS6.blue}Added:${COLORS6.reset} ${diff.summary.added}`);
1364
+ console.log(` ${COLORS6.red}Removed:${COLORS6.reset} ${diff.summary.removed}`);
1365
+ if (diff.summary.changed > 0) {
1366
+ console.log(`
1367
+ ${COLORS6.bold}Changed steps:${COLORS6.reset}`);
1368
+ for (const step of diff.steps) {
1369
+ if (step.type === "changed") {
1370
+ console.log(` Step ${step.index + 1} (${step.tool}): ${COLORS6.yellow}${step.outputDiff}${COLORS6.reset}`);
1371
+ }
1372
+ }
1373
+ }
1374
+ await client.disconnect();
1375
+ const exitCode = diff.summary.changed > 0 || diff.summary.removed > 0 ? EXIT_CODES10.TEST_FAILURE : EXIT_CODES10.SUCCESS;
1376
+ process.exit(exitCode);
1377
+ } catch (err) {
1378
+ const formatted = formatError7(err);
1379
+ console.error(`
1380
+ ${formatted.title}: ${formatted.description}`);
1381
+ formatted.suggestions.forEach((s) => console.error(` - ${s}`));
1382
+ await client?.disconnect();
1383
+ process.exit(formatted.exitCode);
1384
+ }
1385
+ });
1386
+ recordCommand.command("delete").description("Delete a saved recording").argument("<name>", "Recording name").action((name) => {
1387
+ const store = new RecordingStore();
1388
+ if (store.delete(name)) {
1389
+ console.log(`${COLORS6.green}Deleted recording "${name}".${COLORS6.reset}`);
1390
+ } else {
1391
+ console.error(`${COLORS6.red}Recording "${name}" not found.${COLORS6.reset}`);
1392
+ process.exit(EXIT_CODES10.ERROR);
1393
+ }
1394
+ });
1395
+
1119
1396
  // src/index.ts
1120
1397
  var __cliDir = dirname(fileURLToPath(import.meta.url));
1121
1398
  var pkg = JSON.parse(readFileSync3(join2(__cliDir, "..", "package.json"), "utf-8"));
1122
- var program = new Command11();
1399
+ var program = new Command12();
1123
1400
  program.name("mcpspec").description("The definitive MCP server testing platform").version(pkg.version);
1124
1401
  program.addCommand(testCommand);
1125
1402
  program.addCommand(inspectCommand);
@@ -1131,4 +1408,5 @@ program.addCommand(auditCommand);
1131
1408
  program.addCommand(benchCommand);
1132
1409
  program.addCommand(docsCommand);
1133
1410
  program.addCommand(scoreCommand);
1411
+ program.addCommand(recordCommand);
1134
1412
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcpspec",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "The definitive MCP server testing platform",
5
5
  "keywords": [
6
6
  "mcp",
@@ -29,9 +29,9 @@
29
29
  "@inquirer/prompts": "^7.0.0",
30
30
  "commander": "^12.1.0",
31
31
  "open": "^10.1.0",
32
- "@mcpspec/core": "1.0.3",
33
- "@mcpspec/shared": "1.0.3",
34
- "@mcpspec/server": "1.0.3"
32
+ "@mcpspec/core": "1.1.0",
33
+ "@mcpspec/shared": "1.1.0",
34
+ "@mcpspec/server": "1.1.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "tsup": "^8.0.0",