@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 +7 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +216 -3
- package/package.json +3 -3
- package/ui-dist/assets/index-CMNP6jgH.js +361 -0
- package/ui-dist/assets/index-CMNP6jgH.js.map +1 -0
- package/ui-dist/assets/index-Cx7lB-i0.css +1 -0
- package/ui-dist/index.html +2 -2
- package/ui-dist/assets/index-Bo7hG4mU.css +0 -1
- package/ui-dist/assets/index-DxT3O3nb.js +0 -351
- package/ui-dist/assets/index-DxT3O3nb.js.map +0 -1
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
|
+
"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
|
|
32
|
-
"@mcpspec/shared": "1.0
|
|
31
|
+
"@mcpspec/core": "1.1.0",
|
|
32
|
+
"@mcpspec/shared": "1.1.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^22.0.0",
|