@mcpspec/server 1.0.2 → 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,10 +434,82 @@ 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
440
- import { auditStartSchema } from "@mcpspec/shared";
512
+ import { auditStartSchema, auditDryRunSchema } from "@mcpspec/shared";
441
513
  import { MCPClient as MCPClient3, ProcessManagerImpl as ProcessManagerImpl3, SecurityScanner, ScanConfig } from "@mcpspec/core";
442
514
  import { randomUUID as randomUUID2 } from "crypto";
443
515
  var sessions2 = /* @__PURE__ */ new Map();
@@ -473,6 +545,7 @@ function auditRoutes(app, wsHandler) {
473
545
  const scanConfig = new ScanConfig({
474
546
  mode: parsed.data.mode,
475
547
  rules: parsed.data.rules,
548
+ excludeTools: parsed.data.excludeTools,
476
549
  acknowledgeRisk: true
477
550
  // UI handles confirmation client-side
478
551
  });
@@ -521,6 +594,45 @@ function auditRoutes(app, wsHandler) {
521
594
  })();
522
595
  return c.json({ data: { sessionId } });
523
596
  });
597
+ app.post("/api/audit/dry-run", async (c) => {
598
+ const body = await c.req.json();
599
+ const parsed = auditDryRunSchema.safeParse(body);
600
+ if (!parsed.success) {
601
+ return c.json({ error: "validation_error", message: parsed.error.message }, 400);
602
+ }
603
+ const processManager = new ProcessManagerImpl3();
604
+ const config = {
605
+ transport: parsed.data.transport,
606
+ command: parsed.data.command,
607
+ args: parsed.data.args,
608
+ url: parsed.data.url,
609
+ env: parsed.data.env
610
+ };
611
+ const scanConfig = new ScanConfig({
612
+ mode: parsed.data.mode,
613
+ rules: parsed.data.rules,
614
+ excludeTools: parsed.data.excludeTools,
615
+ acknowledgeRisk: true
616
+ });
617
+ const client = new MCPClient3({ serverConfig: config, processManager });
618
+ try {
619
+ await client.connect();
620
+ const scanner = new SecurityScanner();
621
+ const result = await scanner.dryRun(client, scanConfig);
622
+ await client.disconnect();
623
+ await processManager.shutdownAll();
624
+ return c.json({ data: result });
625
+ } catch (err) {
626
+ await client.disconnect().catch(() => {
627
+ });
628
+ await processManager.shutdownAll().catch(() => {
629
+ });
630
+ return c.json(
631
+ { error: "dry_run_failed", message: err instanceof Error ? err.message : "Dry run failed" },
632
+ 500
633
+ );
634
+ }
635
+ });
524
636
  app.get("/api/audit/status/:sessionId", (c) => {
525
637
  const sessionId = c.req.param("sessionId");
526
638
  const session = sessions2.get(sessionId);
@@ -820,16 +932,93 @@ function scoreRoutes(app, wsHandler) {
820
932
  });
821
933
  }
822
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
+
823
1011
  // src/routes/index.ts
824
1012
  function registerRoutes(app, db, wsHandler) {
825
1013
  serversRoutes(app, db);
826
1014
  collectionsRoutes(app, db);
827
1015
  runsRoutes(app, db);
828
- inspectRoutes(app, wsHandler);
1016
+ inspectRoutes(app, db, wsHandler);
829
1017
  auditRoutes(app, wsHandler);
830
1018
  benchmarkRoutes(app, wsHandler);
831
1019
  docsRoutes(app);
832
1020
  scoreRoutes(app, wsHandler);
1021
+ recordingsRoutes(app, db);
833
1022
  }
834
1023
 
835
1024
  // src/app.ts
@@ -1003,6 +1192,21 @@ var MIGRATIONS = [
1003
1192
  )`,
1004
1193
  `INSERT INTO schema_version (version) VALUES (1)`
1005
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
+ ]
1006
1210
  }
1007
1211
  ];
1008
1212
  function getSchemaVersion(db) {
@@ -1225,6 +1429,44 @@ var Database = class {
1225
1429
  this.save();
1226
1430
  return true;
1227
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
+ }
1228
1470
  // --- Row mappers ---
1229
1471
  rowToServer(row) {
1230
1472
  return {
@@ -1249,6 +1491,17 @@ var Database = class {
1249
1491
  updatedAt: row["updated_at"]
1250
1492
  };
1251
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
+ }
1252
1505
  rowToRun(row) {
1253
1506
  return {
1254
1507
  id: row["id"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcpspec/server",
3
- "version": "1.0.2",
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/shared": "1.0.2",
32
- "@mcpspec/core": "1.0.2"
31
+ "@mcpspec/core": "1.1.0",
32
+ "@mcpspec/shared": "1.1.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^22.0.0",