@mcpspec/server 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.
package/README.md CHANGED
@@ -28,7 +28,7 @@ const server = await startServer({
28
28
 
29
29
  - `startServer(options?)` — Start the HTTP server with WebSocket support
30
30
  - `createApp(options)` — Create the Hono app without starting a server (for testing/embedding)
31
- - `Database` — sql.js (WASM SQLite) database for storing servers, collections, runs, and audit results
31
+ - `Database` — sql.js (WASM SQLite) database for storing servers, collections, runs, recordings, and audit results
32
32
  - `WebSocketHandler` — Real-time event broadcasting over WebSocket
33
33
  - `UI_DIST_PATH` — Path to the bundled web UI static files
34
34
 
@@ -58,6 +58,12 @@ const server = await startServer({
58
58
  | POST | `/api/inspect/connect` | Start inspect session |
59
59
  | POST | `/api/inspect/call` | Call a tool in session |
60
60
  | POST | `/api/inspect/disconnect` | End inspect session |
61
+ | POST | `/api/inspect/save-recording` | Save session as recording |
62
+ | GET | `/api/recordings` | List recordings |
63
+ | GET | `/api/recordings/:id` | Get recording |
64
+ | POST | `/api/recordings` | Save a recording |
65
+ | DELETE | `/api/recordings/:id` | Delete recording |
66
+ | POST | `/api/recordings/:id/replay` | Replay recording against server |
61
67
  | POST | `/api/audit` | Start security audit |
62
68
  | POST | `/api/benchmark` | Start benchmark |
63
69
  | POST | `/api/docs/generate` | Generate documentation |
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { SavedServerConnection, SavedCollection, TestRunRecord, TestSummary, TestResult } from '@mcpspec/shared';
2
+ import { SavedServerConnection, SavedCollection, TestRunRecord, TestSummary, TestResult, SavedRecording } from '@mcpspec/shared';
3
3
  import { IncomingMessage } from 'node:http';
4
4
  import { Duplex } from 'node:stream';
5
5
 
@@ -35,8 +35,13 @@ declare class Database {
35
35
  duration?: number;
36
36
  }): TestRunRecord | null;
37
37
  deleteRun(id: string): boolean;
38
+ listRecordings(): SavedRecording[];
39
+ getRecording(id: string): SavedRecording | null;
40
+ createRecording(data: Omit<SavedRecording, 'id' | 'createdAt' | 'updatedAt'>): SavedRecording;
41
+ deleteRecording(id: string): boolean;
38
42
  private rowToServer;
39
43
  private rowToCollection;
44
+ private rowToRecording;
40
45
  private rowToRun;
41
46
  }
42
47
 
package/dist/index.js CHANGED
@@ -257,7 +257,7 @@ function coerceCollection(raw) {
257
257
  }
258
258
 
259
259
  // src/routes/inspect.ts
260
- import { inspectConnectSchema, inspectCallSchema } from "@mcpspec/shared";
260
+ import { inspectConnectSchema, inspectCallSchema, saveInspectRecordingSchema } from "@mcpspec/shared";
261
261
  import { MCPClient as MCPClient2, ProcessManagerImpl as ProcessManagerImpl2 } from "@mcpspec/core";
262
262
  import { randomUUID } from "crypto";
263
263
  var MAX_LOG_ENTRIES = 1e4;
@@ -275,7 +275,7 @@ setInterval(() => {
275
275
  }
276
276
  }
277
277
  }, 6e4);
278
- function inspectRoutes(app, wsHandler) {
278
+ function inspectRoutes(app, db, wsHandler) {
279
279
  app.post("/api/inspect/connect", async (c) => {
280
280
  const body = await c.req.json();
281
281
  const parsed = inspectConnectSchema.safeParse(body);
@@ -434,6 +434,78 @@ function inspectRoutes(app, wsHandler) {
434
434
  sessions.delete(sessionId);
435
435
  return c.json({ data: { disconnected: true } });
436
436
  });
437
+ app.post("/api/inspect/save-recording", async (c) => {
438
+ if (!db) {
439
+ return c.json({ error: "unavailable", message: "Database not configured" }, 500);
440
+ }
441
+ const body = await c.req.json();
442
+ const parsed = saveInspectRecordingSchema.safeParse(body);
443
+ if (!parsed.success) {
444
+ return c.json({ error: "validation_error", message: parsed.error.message }, 400);
445
+ }
446
+ const session = sessions.get(parsed.data.sessionId);
447
+ if (!session) {
448
+ return c.json({ error: "not_found", message: "Session not found or expired" }, 404);
449
+ }
450
+ const steps = [];
451
+ const pendingCalls = /* @__PURE__ */ new Map();
452
+ for (const entry of session.protocolLog) {
453
+ if (entry.direction === "outgoing" && entry.method === "tools/call") {
454
+ const msg = entry.message;
455
+ const params = msg.params;
456
+ if (params && entry.jsonrpcId != null) {
457
+ pendingCalls.set(entry.jsonrpcId, {
458
+ tool: params.name,
459
+ input: params.arguments ?? {},
460
+ sentAt: entry.timestamp
461
+ });
462
+ }
463
+ } else if (entry.direction === "incoming" && entry.jsonrpcId != null) {
464
+ const pending = pendingCalls.get(entry.jsonrpcId);
465
+ if (pending) {
466
+ const msg = entry.message;
467
+ const result = msg.result;
468
+ const isError = msg.error !== void 0 || result && result.isError === true;
469
+ const content = result?.content ?? [];
470
+ const durationMs = entry.roundTripMs ?? entry.timestamp - pending.sentAt;
471
+ steps.push({
472
+ tool: pending.tool,
473
+ input: pending.input,
474
+ output: content,
475
+ isError: isError || void 0,
476
+ durationMs
477
+ });
478
+ pendingCalls.delete(entry.jsonrpcId);
479
+ }
480
+ }
481
+ }
482
+ if (steps.length === 0) {
483
+ return c.json({ error: "no_calls", message: "No tool calls found in session to record" }, 400);
484
+ }
485
+ let toolList = [];
486
+ try {
487
+ const tools = await session.client.listTools();
488
+ toolList = tools.map((t) => ({ name: t.name, description: t.description }));
489
+ } catch {
490
+ }
491
+ const serverInfo = session.client.getServerInfo();
492
+ const recording = {
493
+ id: randomUUID(),
494
+ name: parsed.data.name,
495
+ description: parsed.data.description,
496
+ serverName: serverInfo?.name,
497
+ tools: toolList,
498
+ steps,
499
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
500
+ };
501
+ const saved = db.createRecording({
502
+ name: recording.name,
503
+ description: recording.description,
504
+ serverName: recording.serverName,
505
+ data: JSON.stringify(recording)
506
+ });
507
+ return c.json({ data: saved }, 201);
508
+ });
437
509
  }
438
510
 
439
511
  // src/routes/audit.ts
@@ -860,16 +932,93 @@ function scoreRoutes(app, wsHandler) {
860
932
  });
861
933
  }
862
934
 
935
+ // src/routes/recordings.ts
936
+ import { saveRecordingSchema, replayRecordingSchema } from "@mcpspec/shared";
937
+ import { MCPClient as MCPClient7, ProcessManagerImpl as ProcessManagerImpl7, RecordingReplayer, RecordingDiffer } from "@mcpspec/core";
938
+ function recordingsRoutes(app, db) {
939
+ app.get("/api/recordings", (c) => {
940
+ const recordings = db.listRecordings();
941
+ return c.json({ data: recordings, total: recordings.length });
942
+ });
943
+ app.get("/api/recordings/:id", (c) => {
944
+ const recording = db.getRecording(c.req.param("id"));
945
+ if (!recording) {
946
+ return c.json({ error: "not_found", message: "Recording not found" }, 404);
947
+ }
948
+ return c.json({ data: recording });
949
+ });
950
+ app.post("/api/recordings", async (c) => {
951
+ const body = await c.req.json();
952
+ const parsed = saveRecordingSchema.safeParse(body);
953
+ if (!parsed.success) {
954
+ return c.json({ error: "validation_error", message: parsed.error.message }, 400);
955
+ }
956
+ const recording = db.createRecording({
957
+ name: parsed.data.name,
958
+ description: parsed.data.description,
959
+ serverName: parsed.data.serverName,
960
+ data: parsed.data.data
961
+ });
962
+ return c.json({ data: recording }, 201);
963
+ });
964
+ app.delete("/api/recordings/:id", (c) => {
965
+ const deleted = db.deleteRecording(c.req.param("id"));
966
+ if (!deleted) {
967
+ return c.json({ error: "not_found", message: "Recording not found" }, 404);
968
+ }
969
+ return c.json({ data: { deleted: true } });
970
+ });
971
+ app.post("/api/recordings/:id/replay", async (c) => {
972
+ const saved = db.getRecording(c.req.param("id"));
973
+ if (!saved) {
974
+ return c.json({ error: "not_found", message: "Recording not found" }, 404);
975
+ }
976
+ const body = await c.req.json();
977
+ const parsed = replayRecordingSchema.safeParse(body);
978
+ if (!parsed.success) {
979
+ return c.json({ error: "validation_error", message: parsed.error.message }, 400);
980
+ }
981
+ const recording = JSON.parse(saved.data);
982
+ const processManager = new ProcessManagerImpl7();
983
+ const config = {
984
+ transport: parsed.data.transport,
985
+ command: parsed.data.command,
986
+ args: parsed.data.args,
987
+ url: parsed.data.url,
988
+ env: parsed.data.env
989
+ };
990
+ const client = new MCPClient7({ serverConfig: config, processManager });
991
+ try {
992
+ await client.connect();
993
+ const replayer = new RecordingReplayer();
994
+ const result = await replayer.replay(recording, client);
995
+ const differ = new RecordingDiffer();
996
+ const diff = differ.diff(recording, result.replayedSteps, result.replayedAt);
997
+ await client.disconnect();
998
+ await processManager.shutdownAll();
999
+ return c.json({ data: diff });
1000
+ } catch (err) {
1001
+ await client.disconnect().catch(() => {
1002
+ });
1003
+ await processManager.shutdownAll().catch(() => {
1004
+ });
1005
+ const message = err instanceof Error ? err.message : "Replay failed";
1006
+ return c.json({ error: "replay_error", message }, 500);
1007
+ }
1008
+ });
1009
+ }
1010
+
863
1011
  // src/routes/index.ts
864
1012
  function registerRoutes(app, db, wsHandler) {
865
1013
  serversRoutes(app, db);
866
1014
  collectionsRoutes(app, db);
867
1015
  runsRoutes(app, db);
868
- inspectRoutes(app, wsHandler);
1016
+ inspectRoutes(app, db, wsHandler);
869
1017
  auditRoutes(app, wsHandler);
870
1018
  benchmarkRoutes(app, wsHandler);
871
1019
  docsRoutes(app);
872
1020
  scoreRoutes(app, wsHandler);
1021
+ recordingsRoutes(app, db);
873
1022
  }
874
1023
 
875
1024
  // src/app.ts
@@ -1043,6 +1192,21 @@ var MIGRATIONS = [
1043
1192
  )`,
1044
1193
  `INSERT INTO schema_version (version) VALUES (1)`
1045
1194
  ]
1195
+ },
1196
+ {
1197
+ version: 2,
1198
+ up: [
1199
+ `CREATE TABLE IF NOT EXISTS recordings (
1200
+ id TEXT PRIMARY KEY,
1201
+ name TEXT NOT NULL,
1202
+ description TEXT,
1203
+ server_name TEXT,
1204
+ data TEXT NOT NULL,
1205
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1206
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1207
+ )`,
1208
+ `UPDATE schema_version SET version = 2`
1209
+ ]
1046
1210
  }
1047
1211
  ];
1048
1212
  function getSchemaVersion(db) {
@@ -1265,6 +1429,44 @@ var Database = class {
1265
1429
  this.save();
1266
1430
  return true;
1267
1431
  }
1432
+ // --- Recordings ---
1433
+ listRecordings() {
1434
+ const stmt = this.db.prepare("SELECT * FROM recordings ORDER BY updated_at DESC");
1435
+ const rows = [];
1436
+ while (stmt.step()) {
1437
+ rows.push(this.rowToRecording(stmt.getAsObject()));
1438
+ }
1439
+ stmt.free();
1440
+ return rows;
1441
+ }
1442
+ getRecording(id) {
1443
+ const stmt = this.db.prepare("SELECT * FROM recordings WHERE id = ?");
1444
+ stmt.bind([id]);
1445
+ if (!stmt.step()) {
1446
+ stmt.free();
1447
+ return null;
1448
+ }
1449
+ const row = this.rowToRecording(stmt.getAsObject());
1450
+ stmt.free();
1451
+ return row;
1452
+ }
1453
+ createRecording(data) {
1454
+ const id = randomUUID5();
1455
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1456
+ this.db.run(
1457
+ `INSERT INTO recordings (id, name, description, server_name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
1458
+ [id, data.name, data.description ?? null, data.serverName ?? null, data.data, now, now]
1459
+ );
1460
+ this.save();
1461
+ return this.getRecording(id);
1462
+ }
1463
+ deleteRecording(id) {
1464
+ const existing = this.getRecording(id);
1465
+ if (!existing) return false;
1466
+ this.db.run("DELETE FROM recordings WHERE id = ?", [id]);
1467
+ this.save();
1468
+ return true;
1469
+ }
1268
1470
  // --- Row mappers ---
1269
1471
  rowToServer(row) {
1270
1472
  return {
@@ -1289,6 +1491,17 @@ var Database = class {
1289
1491
  updatedAt: row["updated_at"]
1290
1492
  };
1291
1493
  }
1494
+ rowToRecording(row) {
1495
+ return {
1496
+ id: row["id"],
1497
+ name: row["name"],
1498
+ description: row["description"] || void 0,
1499
+ serverName: row["server_name"] || void 0,
1500
+ data: row["data"],
1501
+ createdAt: row["created_at"],
1502
+ updatedAt: row["updated_at"]
1503
+ };
1504
+ }
1292
1505
  rowToRun(row) {
1293
1506
  return {
1294
1507
  id: row["id"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcpspec/server",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -28,8 +28,8 @@
28
28
  "@hono/node-server": "^1.13.0",
29
29
  "sql.js": "^1.11.0",
30
30
  "ws": "^8.18.0",
31
- "@mcpspec/core": "1.0.3",
32
- "@mcpspec/shared": "1.0.3"
31
+ "@mcpspec/core": "1.1.0",
32
+ "@mcpspec/shared": "1.1.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.0.0",