@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 +7 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +257 -4
- 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-Bet505kH.js +0 -346
- package/ui-dist/assets/index-Bet505kH.js.map +0 -1
- package/ui-dist/assets/index-DHdza0Ve.css +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,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
|
|
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/
|
|
32
|
-
"@mcpspec/
|
|
31
|
+
"@mcpspec/core": "1.1.0",
|
|
32
|
+
"@mcpspec/shared": "1.1.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^22.0.0",
|