@frontmcp/testing 1.8.6 → 1.9.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 (49) hide show
  1. package/auth/mock-oauth-server.d.ts.map +1 -1
  2. package/auth/token-factory.d.ts +2 -2
  3. package/auth/token-factory.d.ts.map +1 -1
  4. package/client/mcp-test-client.d.ts +6 -1
  5. package/client/mcp-test-client.d.ts.map +1 -1
  6. package/esm/fixtures/index.mjs +993 -237
  7. package/esm/index.mjs +1151 -360
  8. package/esm/matchers/index.mjs +3 -0
  9. package/esm/package.json +8 -6
  10. package/esm/perf/index.mjs +874 -280
  11. package/esm/perf/perf-setup.mjs +3 -0
  12. package/esm/playwright/index.mjs +3 -0
  13. package/esm/setup.mjs +3 -0
  14. package/expect.d.ts +12 -11
  15. package/expect.d.ts.map +1 -1
  16. package/fixtures/fixture-types.d.ts +41 -10
  17. package/fixtures/fixture-types.d.ts.map +1 -1
  18. package/fixtures/index.d.ts +1 -1
  19. package/fixtures/index.d.ts.map +1 -1
  20. package/fixtures/index.js +989 -229
  21. package/fixtures/test-fixture.d.ts +6 -5
  22. package/fixtures/test-fixture.d.ts.map +1 -1
  23. package/http-mock/http-mock.d.ts +5 -3
  24. package/http-mock/http-mock.d.ts.map +1 -1
  25. package/http-mock/http-mock.types.d.ts +17 -6
  26. package/http-mock/http-mock.types.d.ts.map +1 -1
  27. package/http-mock/index.d.ts +1 -1
  28. package/http-mock/index.d.ts.map +1 -1
  29. package/index.d.ts +3 -2
  30. package/index.d.ts.map +1 -1
  31. package/index.js +1157 -361
  32. package/package.json +8 -6
  33. package/perf/baseline-store.d.ts +1 -1
  34. package/perf/baseline-store.d.ts.map +1 -1
  35. package/perf/index.js +874 -293
  36. package/perf/perf-test.d.ts.map +1 -1
  37. package/perf/report-generator.d.ts +1 -1
  38. package/perf/report-generator.d.ts.map +1 -1
  39. package/server/port-registry.d.ts +7 -1
  40. package/server/port-registry.d.ts.map +1 -1
  41. package/server/test-server.d.ts +10 -1
  42. package/server/test-server.d.ts.map +1 -1
  43. package/transport/error-utils.d.ts +15 -0
  44. package/transport/error-utils.d.ts.map +1 -0
  45. package/transport/index.d.ts +1 -0
  46. package/transport/index.d.ts.map +1 -1
  47. package/transport/sse.transport.d.ts +80 -0
  48. package/transport/sse.transport.d.ts.map +1 -0
  49. package/transport/streamable-http.transport.d.ts.map +1 -1
@@ -1,3 +1,6 @@
1
+ var __frontmcpModule = typeof process !== "undefined" && typeof process.getBuiltinModule === "function" ? process.getBuiltinModule("module") : undefined;
2
+ const require = __frontmcpModule ? __frontmcpModule.createRequire(import.meta.url || 'file:///') : undefined;
3
+ // __frontmcpCreateRequire
1
4
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
5
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
6
  }) : x)(function(x) {
@@ -772,6 +775,192 @@ function getMeasurementsForProject(project) {
772
775
  return getGlobalMeasurementsArray().filter((m) => m.project === project);
773
776
  }
774
777
 
778
+ // libs/testing/src/auth/token-factory.ts
779
+ import { exportJWK, generateKeyPair, SignJWT } from "jose";
780
+ var TestTokenFactory = class {
781
+ issuer;
782
+ audience;
783
+ privateKey = null;
784
+ publicKey = null;
785
+ jwk = null;
786
+ keyId;
787
+ /** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
788
+ hmacSecret;
789
+ constructor(options = {}) {
790
+ this.issuer = options.issuer ?? "https://test.frontmcp.local";
791
+ this.audience = options.audience ?? "frontmcp-test";
792
+ this.keyId = `test-key-${Date.now()}`;
793
+ this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
794
+ }
795
+ /** True when this factory signs HS256 with a shared secret (gateway mode). */
796
+ get isHmac() {
797
+ return this.hmacSecret !== null;
798
+ }
799
+ /** The JWS `alg` this factory signs with. */
800
+ get alg() {
801
+ return this.isHmac ? "HS256" : "RS256";
802
+ }
803
+ /**
804
+ * The signing key: the shared HMAC secret in gateway mode, otherwise the
805
+ * generated RSA private key (lazily created via ensureKeys()).
806
+ */
807
+ signingKey() {
808
+ if (this.hmacSecret) return this.hmacSecret;
809
+ if (!this.privateKey) {
810
+ throw new Error("Private key not initialized");
811
+ }
812
+ return this.privateKey;
813
+ }
814
+ /**
815
+ * Initialize the key pair (called automatically on first use). No-op in HS256
816
+ * mode — there is no asymmetric keypair, just the shared secret.
817
+ */
818
+ async ensureKeys() {
819
+ if (this.isHmac) return;
820
+ if (this.privateKey && this.publicKey) return;
821
+ const { publicKey, privateKey } = await generateKeyPair("RS256", {
822
+ extractable: true
823
+ });
824
+ this.privateKey = privateKey;
825
+ this.publicKey = publicKey;
826
+ this.jwk = await exportJWK(publicKey);
827
+ this.jwk.kid = this.keyId;
828
+ this.jwk.use = "sig";
829
+ this.jwk.alg = "RS256";
830
+ }
831
+ /**
832
+ * Create a JWT token with the specified claims
833
+ */
834
+ async createTestToken(options) {
835
+ await this.ensureKeys();
836
+ const nowMs = Date.now();
837
+ const iat = Math.floor(nowMs / 1e3);
838
+ const exp = options.exp ?? 3600;
839
+ const payload = {
840
+ iss: options.iss ?? this.issuer,
841
+ sub: options.sub,
842
+ aud: options.aud ?? this.audience,
843
+ iat,
844
+ exp: Math.ceil(nowMs / 1e3) + exp,
845
+ scope: options.scopes?.join(" "),
846
+ ...options.claims
847
+ };
848
+ const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
849
+ const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
850
+ return token;
851
+ }
852
+ /**
853
+ * Create an admin token with full access
854
+ */
855
+ async createAdminToken(sub = "admin-001") {
856
+ return this.createTestToken({
857
+ sub,
858
+ scopes: ["admin:*", "read", "write", "delete"],
859
+ claims: {
860
+ email: "admin@test.local",
861
+ name: "Test Admin",
862
+ role: "admin"
863
+ }
864
+ });
865
+ }
866
+ /**
867
+ * Create a regular user token
868
+ */
869
+ async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
870
+ return this.createTestToken({
871
+ sub,
872
+ scopes,
873
+ claims: {
874
+ email: "user@test.local",
875
+ name: "Test User",
876
+ role: "user"
877
+ }
878
+ });
879
+ }
880
+ /**
881
+ * Create an anonymous user token
882
+ */
883
+ async createAnonymousToken(expiresIn) {
884
+ return this.createTestToken({
885
+ sub: `anon:${Date.now()}`,
886
+ scopes: ["anonymous"],
887
+ exp: expiresIn,
888
+ claims: {
889
+ name: "Anonymous",
890
+ role: "anonymous"
891
+ }
892
+ });
893
+ }
894
+ /**
895
+ * Create an expired token (for testing token expiration)
896
+ */
897
+ async createExpiredToken(options) {
898
+ await this.ensureKeys();
899
+ const now = Math.floor(Date.now() / 1e3);
900
+ const payload = {
901
+ iss: this.issuer,
902
+ sub: options.sub,
903
+ aud: this.audience,
904
+ iat: now - 7200,
905
+ // 2 hours ago
906
+ exp: now - 3600
907
+ // Expired 1 hour ago
908
+ };
909
+ const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
910
+ const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
911
+ return token;
912
+ }
913
+ /**
914
+ * Create a token with an invalid signature (for testing signature validation)
915
+ */
916
+ createTokenWithInvalidSignature(options) {
917
+ const now = Math.floor(Date.now() / 1e3);
918
+ const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
919
+ const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
920
+ const payload = Buffer.from(
921
+ JSON.stringify({
922
+ iss: this.issuer,
923
+ sub: options.sub,
924
+ aud: this.audience,
925
+ iat: now,
926
+ exp: now + 3600
927
+ })
928
+ ).toString("base64url");
929
+ const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
930
+ return `${header}.${payload}.${signature}`;
931
+ }
932
+ /**
933
+ * Get the public JWKS for verifying tokens.
934
+ *
935
+ * Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
936
+ * the shared secret, not a published public key, so there is no JWKS to serve.
937
+ */
938
+ async getPublicJwks() {
939
+ if (this.isHmac) {
940
+ throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
941
+ }
942
+ await this.ensureKeys();
943
+ if (!this.jwk) {
944
+ throw new Error("JWK not initialized");
945
+ }
946
+ return {
947
+ keys: [this.jwk]
948
+ };
949
+ }
950
+ /**
951
+ * Get the issuer URL
952
+ */
953
+ getIssuer() {
954
+ return this.issuer;
955
+ }
956
+ /**
957
+ * Get the audience
958
+ */
959
+ getAudience() {
960
+ return this.audience;
961
+ }
962
+ };
963
+
775
964
  // libs/testing/src/interceptor/mock-registry.ts
776
965
  var DefaultMockRegistry = class {
777
966
  mocks = [];
@@ -1044,8 +1233,479 @@ function sleep3(ms) {
1044
1233
  return new Promise((resolve) => setTimeout(resolve, ms));
1045
1234
  }
1046
1235
 
1047
- // libs/testing/src/transport/streamable-http.transport.ts
1236
+ // libs/testing/src/transport/error-utils.ts
1237
+ function errorName(err) {
1238
+ if (typeof err !== "object" || err === null) return void 0;
1239
+ const name = err.name;
1240
+ return typeof name === "string" ? name : void 0;
1241
+ }
1242
+ function isAbortError(err) {
1243
+ return errorName(err) === "AbortError";
1244
+ }
1245
+ function errorMessage(err, fallback = "Unknown error") {
1246
+ if (typeof err === "string" && err) return err;
1247
+ if (typeof err === "object" && err !== null) {
1248
+ const message = err.message;
1249
+ if (typeof message === "string" && message) return message;
1250
+ }
1251
+ return fallback;
1252
+ }
1253
+ var interceptedErrors = /* @__PURE__ */ new WeakSet();
1254
+ function markInterceptedError(err) {
1255
+ interceptedErrors.add(err);
1256
+ return err;
1257
+ }
1258
+ function isInterceptedError(err) {
1259
+ return typeof err === "object" && err !== null && interceptedErrors.has(err);
1260
+ }
1261
+
1262
+ // libs/testing/src/transport/sse.transport.ts
1048
1263
  var DEFAULT_TIMEOUT = 3e4;
1264
+ var SseTransport = class {
1265
+ baseUrl;
1266
+ entryPath;
1267
+ timeout;
1268
+ authHeaders;
1269
+ publicMode;
1270
+ debug;
1271
+ clientInfo;
1272
+ notificationHandler;
1273
+ interceptors;
1274
+ elicitationHandler;
1275
+ state = "disconnected";
1276
+ authToken;
1277
+ sessionId;
1278
+ messageEndpoint;
1279
+ stream;
1280
+ pending = /* @__PURE__ */ new Map();
1281
+ connectionCount = 0;
1282
+ reconnectCount = 0;
1283
+ lastRequestHeaders = {};
1284
+ constructor(config) {
1285
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
1286
+ this.entryPath = config.entryPath;
1287
+ this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
1288
+ this.authHeaders = config.auth?.headers ?? {};
1289
+ this.authToken = config.auth?.token;
1290
+ this.publicMode = config.publicMode ?? false;
1291
+ this.debug = config.debug ?? false;
1292
+ this.clientInfo = config.clientInfo;
1293
+ this.notificationHandler = config.notificationHandler;
1294
+ this.interceptors = config.interceptors;
1295
+ this.elicitationHandler = config.elicitationHandler;
1296
+ }
1297
+ async connect() {
1298
+ this.state = "connecting";
1299
+ this.connectionCount++;
1300
+ try {
1301
+ if (!this.publicMode && !this.authToken) {
1302
+ await this.requestAnonymousToken();
1303
+ }
1304
+ await this.openStream();
1305
+ this.state = "connected";
1306
+ this.log(`Connected; messages go to ${this.messageEndpoint}`);
1307
+ } catch (error) {
1308
+ this.state = "error";
1309
+ this.stream?.abort();
1310
+ this.stream = void 0;
1311
+ throw error;
1312
+ }
1313
+ }
1314
+ async request(message) {
1315
+ this.ensureConnected();
1316
+ const startTime = Date.now();
1317
+ if (this.interceptors) {
1318
+ const intercepted = await this.interceptors.processRequest(message, {
1319
+ timestamp: /* @__PURE__ */ new Date(),
1320
+ transport: "sse",
1321
+ sessionId: this.sessionId
1322
+ });
1323
+ switch (intercepted.type) {
1324
+ case "mock":
1325
+ return await this.interceptors.processResponse(
1326
+ message,
1327
+ intercepted.response,
1328
+ Date.now() - startTime
1329
+ );
1330
+ case "error":
1331
+ throw markInterceptedError(intercepted.error);
1332
+ case "continue":
1333
+ message = intercepted.request;
1334
+ break;
1335
+ }
1336
+ }
1337
+ let response;
1338
+ if (message.id === void 0) {
1339
+ await this.post(JSON.stringify(message));
1340
+ response = { jsonrpc: "2.0", id: null, result: void 0 };
1341
+ } else {
1342
+ response = await this.postAndAwait(message.id, JSON.stringify(message));
1343
+ }
1344
+ if (this.interceptors) {
1345
+ response = await this.interceptors.processResponse(message, response, Date.now() - startTime);
1346
+ }
1347
+ return response;
1348
+ }
1349
+ async notify(message) {
1350
+ this.ensureConnected();
1351
+ const result = await this.post(JSON.stringify(message));
1352
+ if (!result.ok) this.log(`HTTP ${result.status} on notification: ${result.body}`);
1353
+ }
1354
+ async sendRaw(data) {
1355
+ this.ensureConnected();
1356
+ let id;
1357
+ try {
1358
+ const parsed = JSON.parse(data);
1359
+ if (typeof parsed.id === "string" || typeof parsed.id === "number") id = parsed.id;
1360
+ } catch {
1361
+ }
1362
+ if (id !== void 0) return this.postAndAwait(id, data);
1363
+ const result = await this.post(data);
1364
+ if (!result.ok) {
1365
+ return { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error", data: result.body } };
1366
+ }
1367
+ return { jsonrpc: "2.0", id: null, result: void 0 };
1368
+ }
1369
+ async close() {
1370
+ this.closeStream("Transport closed");
1371
+ this.state = "disconnected";
1372
+ this.sessionId = void 0;
1373
+ this.messageEndpoint = void 0;
1374
+ this.log("SSE transport closed");
1375
+ }
1376
+ isConnected() {
1377
+ return this.state === "connected";
1378
+ }
1379
+ getState() {
1380
+ return this.state;
1381
+ }
1382
+ getSessionId() {
1383
+ return this.sessionId;
1384
+ }
1385
+ setAuthToken(token) {
1386
+ this.authToken = token;
1387
+ }
1388
+ setTimeout(ms) {
1389
+ this.timeout = ms;
1390
+ }
1391
+ getMessageEndpoint() {
1392
+ return this.messageEndpoint;
1393
+ }
1394
+ getConnectionCount() {
1395
+ return this.connectionCount;
1396
+ }
1397
+ getReconnectCount() {
1398
+ return this.reconnectCount;
1399
+ }
1400
+ getLastRequestHeaders() {
1401
+ return { ...this.lastRequestHeaders };
1402
+ }
1403
+ setInterceptors(interceptors2) {
1404
+ this.interceptors = interceptors2;
1405
+ }
1406
+ getInterceptors() {
1407
+ return this.interceptors;
1408
+ }
1409
+ setElicitationHandler(handler) {
1410
+ this.elicitationHandler = handler;
1411
+ }
1412
+ async simulateDisconnect() {
1413
+ this.closeStream("Simulated disconnect");
1414
+ this.state = "disconnected";
1415
+ this.sessionId = void 0;
1416
+ this.messageEndpoint = void 0;
1417
+ }
1418
+ async waitForReconnect(timeoutMs) {
1419
+ this.reconnectCount++;
1420
+ let timer;
1421
+ try {
1422
+ await Promise.race([
1423
+ this.connect(),
1424
+ new Promise((_, reject) => {
1425
+ timer = setTimeout(() => reject(new Error("Timeout waiting for reconnection")), timeoutMs);
1426
+ })
1427
+ ]);
1428
+ } finally {
1429
+ clearTimeout(timer);
1430
+ }
1431
+ }
1432
+ // ═══════════════════════════════════════════════════════════════════
1433
+ // STREAM
1434
+ // ═══════════════════════════════════════════════════════════════════
1435
+ /** Open `GET <entry>/sse` and wait for the `endpoint` event. */
1436
+ async openStream() {
1437
+ const controller = new AbortController();
1438
+ this.stream = controller;
1439
+ const url = this.sseUrl();
1440
+ const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
1441
+ this.log(`GET ${url}`);
1442
+ const connectTimer = setTimeout(() => controller.abort(), this.timeout);
1443
+ let response;
1444
+ try {
1445
+ response = await fetch(url, {
1446
+ method: "GET",
1447
+ headers: { ...headers, Accept: "text/event-stream" },
1448
+ signal: controller.signal
1449
+ });
1450
+ } catch (error) {
1451
+ clearTimeout(connectTimer);
1452
+ throw new Error(
1453
+ isAbortError(error) ? `SSE connection to ${url} timed out after ${this.timeout}ms` : `SSE connection to ${url} failed: ${errorMessage(error)}`,
1454
+ { cause: error }
1455
+ );
1456
+ }
1457
+ const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
1458
+ if (!response.ok || !response.body || !isEventStream) {
1459
+ clearTimeout(connectTimer);
1460
+ const body = await response.text().catch(() => "");
1461
+ throw new Error(
1462
+ `SSE connection to ${url} failed: HTTP ${response.status} ${response.statusText}${body ? ` \u2014 ${body}` : ""}. The legacy HTTP+SSE transport needs a server that serves it (@FrontMcp({ transport: { protocol: { legacy: true } } })).`
1463
+ );
1464
+ }
1465
+ const endpoint = new Promise((resolve, reject) => {
1466
+ const reader = response.body?.getReader();
1467
+ if (!reader) {
1468
+ reject(new Error(`SSE connection to ${url} has no body`));
1469
+ return;
1470
+ }
1471
+ void this.readStream(controller, reader, resolve, reject);
1472
+ });
1473
+ try {
1474
+ const data = await endpoint;
1475
+ const messageUrl = new URL(data, url);
1476
+ this.messageEndpoint = messageUrl.toString();
1477
+ this.sessionId = messageUrl.searchParams.get("sessionId") ?? void 0;
1478
+ } finally {
1479
+ clearTimeout(connectTimer);
1480
+ }
1481
+ }
1482
+ async readStream(controller, reader, onEndpoint, onEndpointError) {
1483
+ const decoder = new TextDecoder();
1484
+ let buffer = "";
1485
+ let endpointSeen = false;
1486
+ try {
1487
+ for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
1488
+ const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
1489
+ buffer = remainder;
1490
+ for (const text of completeEvents) {
1491
+ const event = parseSSEEvent(text);
1492
+ if (!event) continue;
1493
+ if (event.type === "endpoint") {
1494
+ if (!endpointSeen) {
1495
+ endpointSeen = true;
1496
+ onEndpoint(event.data);
1497
+ }
1498
+ continue;
1499
+ }
1500
+ await this.handleMessage(event.data);
1501
+ }
1502
+ }
1503
+ } catch (error) {
1504
+ if (!controller.signal.aborted) this.log("SSE stream failed:", error);
1505
+ } finally {
1506
+ reader.releaseLock();
1507
+ }
1508
+ if (!endpointSeen) {
1509
+ onEndpointError(
1510
+ new Error(
1511
+ controller.signal.aborted ? `SSE stream at ${this.sseUrl()} sent no endpoint event within ${this.timeout}ms` : `SSE stream at ${this.sseUrl()} ended before the server sent its endpoint`
1512
+ )
1513
+ );
1514
+ }
1515
+ if (this.stream === controller) {
1516
+ this.stream = void 0;
1517
+ this.rejectPending("SSE stream closed by the server");
1518
+ if (this.state === "connected") this.state = "disconnected";
1519
+ }
1520
+ }
1521
+ async handleMessage(data) {
1522
+ let message;
1523
+ try {
1524
+ message = JSON.parse(data);
1525
+ } catch {
1526
+ this.log("Ignoring SSE event that is not JSON:", data);
1527
+ return;
1528
+ }
1529
+ if (!message || typeof message !== "object") return;
1530
+ if (typeof message.method === "string") {
1531
+ const request = message;
1532
+ if (request.id === void 0 || request.id === null) {
1533
+ this.notificationHandler?.(request);
1534
+ return;
1535
+ }
1536
+ await this.answerServerRequest(request);
1537
+ return;
1538
+ }
1539
+ if (message.id !== void 0 && message.id !== null) {
1540
+ const pending = this.pending.get(message.id);
1541
+ if (pending) {
1542
+ clearTimeout(pending.timer);
1543
+ this.pending.delete(message.id);
1544
+ pending.resolve(message);
1545
+ }
1546
+ }
1547
+ }
1548
+ /** Server→client requests: elicitation goes to the handler, `ping` is answered, the rest refused. */
1549
+ async answerServerRequest(request) {
1550
+ const id = request.id;
1551
+ let reply;
1552
+ if (request.method === "elicitation/create") {
1553
+ reply = { jsonrpc: "2.0", id, result: await this.elicit(request.params) };
1554
+ } else if (request.method === "ping") {
1555
+ reply = { jsonrpc: "2.0", id, result: {} };
1556
+ } else {
1557
+ reply = { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${request.method}` } };
1558
+ }
1559
+ const result = await this.post(JSON.stringify(reply));
1560
+ if (!result.ok) this.log(`HTTP ${result.status} answering ${request.method}: ${result.body}`);
1561
+ }
1562
+ async elicit(params) {
1563
+ if (!this.elicitationHandler) return { action: "decline" };
1564
+ try {
1565
+ return await this.elicitationHandler(params);
1566
+ } catch (error) {
1567
+ this.log("Elicitation handler error:", error);
1568
+ return { action: "cancel" };
1569
+ }
1570
+ }
1571
+ closeStream(reason) {
1572
+ const controller = this.stream;
1573
+ this.stream = void 0;
1574
+ controller?.abort();
1575
+ this.rejectPending(reason);
1576
+ }
1577
+ rejectPending(reason) {
1578
+ for (const [id, pending] of this.pending) {
1579
+ clearTimeout(pending.timer);
1580
+ pending.resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: reason } });
1581
+ }
1582
+ this.pending.clear();
1583
+ }
1584
+ // ═══════════════════════════════════════════════════════════════════
1585
+ // POSTING
1586
+ // ═══════════════════════════════════════════════════════════════════
1587
+ /** POST a message and resolve with the response the stream carries for `id`. */
1588
+ async postAndAwait(id, body) {
1589
+ const answer = new Promise((resolve) => {
1590
+ const timer = setTimeout(() => {
1591
+ this.pending.delete(id);
1592
+ resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: `Request timeout after ${this.timeout}ms` } });
1593
+ }, this.timeout);
1594
+ this.pending.set(id, { resolve, timer });
1595
+ });
1596
+ const result = await this.post(body);
1597
+ if (!result.ok) {
1598
+ const pending = this.pending.get(id);
1599
+ if (pending) {
1600
+ clearTimeout(pending.timer);
1601
+ this.pending.delete(id);
1602
+ }
1603
+ return {
1604
+ jsonrpc: "2.0",
1605
+ id,
1606
+ error: { code: -32e3, message: `HTTP ${result.status}: ${result.statusText}`, data: result.body }
1607
+ };
1608
+ }
1609
+ return answer;
1610
+ }
1611
+ async post(body) {
1612
+ const endpoint = this.messageEndpoint;
1613
+ if (!endpoint) throw new Error("SSE transport has no message endpoint. Call connect() first.");
1614
+ const headers = this.buildHeaders();
1615
+ this.lastRequestHeaders = headers;
1616
+ this.log(`POST ${endpoint}`, body);
1617
+ const controller = new AbortController();
1618
+ const timer = setTimeout(() => controller.abort(), this.timeout);
1619
+ try {
1620
+ const response = await fetch(endpoint, { method: "POST", headers, body, signal: controller.signal });
1621
+ const text = await response.text();
1622
+ return { ok: response.ok, status: response.status, statusText: response.statusText, body: text };
1623
+ } catch (error) {
1624
+ if (isAbortError(error)) {
1625
+ return { ok: false, status: 0, statusText: `timeout after ${this.timeout}ms`, body: "" };
1626
+ }
1627
+ throw error;
1628
+ } finally {
1629
+ clearTimeout(timer);
1630
+ }
1631
+ }
1632
+ // ═══════════════════════════════════════════════════════════════════
1633
+ // HELPERS
1634
+ // ═══════════════════════════════════════════════════════════════════
1635
+ async requestAnonymousToken() {
1636
+ const url = new URL(this.baseUrl);
1637
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/oauth/token`;
1638
+ url.search = "";
1639
+ const tokenUrl = url.toString();
1640
+ try {
1641
+ const response = await fetch(tokenUrl, {
1642
+ method: "POST",
1643
+ headers: { "Content-Type": "application/json" },
1644
+ body: JSON.stringify({ grant_type: "anonymous", client_id: crypto.randomUUID(), resource: this.baseUrl })
1645
+ });
1646
+ if (!response.ok) {
1647
+ this.log(`No anonymous token (HTTP ${response.status})`);
1648
+ return;
1649
+ }
1650
+ const token = await response.json();
1651
+ if (token.access_token) this.authToken = token.access_token;
1652
+ } catch (error) {
1653
+ this.log("Error requesting anonymous token:", error);
1654
+ }
1655
+ }
1656
+ /** `<baseUrl><entryPath>/sse`, built from URL parts so query params on `baseUrl` survive. */
1657
+ sseUrl() {
1658
+ const url = new URL(this.baseUrl);
1659
+ const entry = (this.entryPath ?? "").replace(/^\/+|\/+$/g, "");
1660
+ const basePath = url.pathname.replace(/\/+$/, "");
1661
+ url.pathname = entry ? `${basePath}/${entry}/sse` : `${basePath}/sse`;
1662
+ return url.toString();
1663
+ }
1664
+ buildHeaders() {
1665
+ const headers = {
1666
+ "Content-Type": "application/json",
1667
+ Accept: "application/json, text/event-stream"
1668
+ };
1669
+ if (this.clientInfo) headers["User-Agent"] = `${this.clientInfo.name}/${this.clientInfo.version}`;
1670
+ if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
1671
+ if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
1672
+ Object.assign(headers, this.authHeaders);
1673
+ return headers;
1674
+ }
1675
+ ensureConnected() {
1676
+ if (this.state !== "connected") {
1677
+ throw new Error("Transport not connected. Call connect() first.");
1678
+ }
1679
+ }
1680
+ log(message, data) {
1681
+ if (this.debug) {
1682
+ console.log(`[SSE] ${message}`, data ?? "");
1683
+ }
1684
+ }
1685
+ };
1686
+ function splitSSEEvents(buffer) {
1687
+ const endsWithCarriageReturn = buffer.endsWith("\r");
1688
+ const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
1689
+ const completeEvents = normalized.split("\n\n");
1690
+ const remainder = completeEvents.pop() ?? "";
1691
+ return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
1692
+ }
1693
+ function parseSSEEvent(text) {
1694
+ let type = "message";
1695
+ const data = [];
1696
+ for (const line of text.split("\n")) {
1697
+ if (line.startsWith(":")) continue;
1698
+ const colon = line.indexOf(":");
1699
+ const field = colon === -1 ? line : line.slice(0, colon);
1700
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
1701
+ if (field === "event") type = value;
1702
+ else if (field === "data") data.push(value);
1703
+ }
1704
+ return data.length > 0 ? { type, data: data.join("\n") } : void 0;
1705
+ }
1706
+
1707
+ // libs/testing/src/transport/streamable-http.transport.ts
1708
+ var DEFAULT_TIMEOUT2 = 3e4;
1049
1709
  var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
1050
1710
  var StreamableHttpTransport = class {
1051
1711
  config;
@@ -1067,7 +1727,7 @@ var StreamableHttpTransport = class {
1067
1727
  baseUrl: config.baseUrl.replace(/\/$/, ""),
1068
1728
  // Remove trailing slash
1069
1729
  entryPath: config.entryPath,
1070
- timeout: config.timeout ?? DEFAULT_TIMEOUT,
1730
+ timeout: config.timeout ?? DEFAULT_TIMEOUT2,
1071
1731
  auth: config.auth ?? {},
1072
1732
  publicMode: config.publicMode ?? false,
1073
1733
  debug: config.debug ?? false,
@@ -1152,7 +1812,7 @@ var StreamableHttpTransport = class {
1152
1812
  return mockResponse2;
1153
1813
  }
1154
1814
  case "error":
1155
- throw interceptResult.error;
1815
+ throw markInterceptedError(interceptResult.error);
1156
1816
  case "continue":
1157
1817
  message = interceptResult.request;
1158
1818
  break;
@@ -1213,7 +1873,7 @@ var StreamableHttpTransport = class {
1213
1873
  return jsonResponse;
1214
1874
  } catch (error) {
1215
1875
  clearTimeout(timeoutId);
1216
- if (error instanceof Error && error.name === "AbortError") {
1876
+ if (isAbortError(error)) {
1217
1877
  return {
1218
1878
  jsonrpc: "2.0",
1219
1879
  id: message.id ?? null,
@@ -1252,7 +1912,7 @@ var StreamableHttpTransport = class {
1252
1912
  }
1253
1913
  } catch (error) {
1254
1914
  clearTimeout(timeoutId);
1255
- if (error instanceof Error && error.name !== "AbortError") {
1915
+ if (!isAbortError(error)) {
1256
1916
  throw error;
1257
1917
  }
1258
1918
  }
@@ -1293,7 +1953,7 @@ var StreamableHttpTransport = class {
1293
1953
  error: {
1294
1954
  code: -32700,
1295
1955
  message: "Parse error",
1296
- data: error instanceof Error ? error.message : "Unknown error"
1956
+ data: errorMessage(error)
1297
1957
  }
1298
1958
  };
1299
1959
  }
@@ -1407,7 +2067,7 @@ var StreamableHttpTransport = class {
1407
2067
  let buffer = "";
1408
2068
  try {
1409
2069
  for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
1410
- const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
2070
+ const { completeEvents, remainder } = splitSSEEvents2(buffer + decoder.decode(chunk.value, { stream: true }));
1411
2071
  buffer = remainder;
1412
2072
  for (const eventText of completeEvents) {
1413
2073
  for (const event of this.parseSSEEvents(eventText, void 0).events) {
@@ -1663,7 +2323,7 @@ var StreamableHttpTransport = class {
1663
2323
  if (this.config.clientInfo) {
1664
2324
  headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
1665
2325
  }
1666
- if (this.authToken && !this.publicMode) {
2326
+ if (this.authToken) {
1667
2327
  headers["Authorization"] = `Bearer ${this.authToken}`;
1668
2328
  }
1669
2329
  if (this.sessionId) {
@@ -1741,7 +2401,7 @@ var StreamableHttpTransport = class {
1741
2401
  };
1742
2402
  }
1743
2403
  };
1744
- function splitSSEEvents(buffer) {
2404
+ function splitSSEEvents2(buffer) {
1745
2405
  const endsWithCarriageReturn = buffer.endsWith("\r");
1746
2406
  const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
1747
2407
  const completeEvents = normalized.split("\n\n");
@@ -1979,7 +2639,7 @@ var McpTestClientBuilder = class {
1979
2639
  };
1980
2640
 
1981
2641
  // libs/testing/src/client/mcp-test-client.ts
1982
- var DEFAULT_TIMEOUT2 = 3e4;
2642
+ var DEFAULT_TIMEOUT3 = 3e4;
1983
2643
  var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
1984
2644
  var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
1985
2645
  var DEFAULT_CLIENT_INFO = {
@@ -1987,6 +2647,15 @@ var DEFAULT_CLIENT_INFO = {
1987
2647
  version: "0.4.0"
1988
2648
  };
1989
2649
  var MAX_LIST_PAGES = 1e3;
2650
+ function isAuthFailure(error) {
2651
+ return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
2652
+ }
2653
+ function describeInitializeError(error) {
2654
+ const message = error?.message ?? "Unknown error";
2655
+ const data = error?.data;
2656
+ const detail = typeof data === "string" ? data.trim() : "";
2657
+ return detail ? `${message} - ${detail.slice(0, 500)}` : message;
2658
+ }
1990
2659
  function extractEntryPaths(data) {
1991
2660
  let payload = data;
1992
2661
  if (typeof payload === "string") {
@@ -2040,7 +2709,7 @@ var McpTestClient = class {
2040
2709
  transport: config.transport ?? "streamable-http",
2041
2710
  auth: config.auth ?? {},
2042
2711
  publicMode: config.publicMode ?? false,
2043
- timeout: config.timeout ?? DEFAULT_TIMEOUT2,
2712
+ timeout: config.timeout ?? DEFAULT_TIMEOUT3,
2044
2713
  debug: config.debug ?? false,
2045
2714
  protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
2046
2715
  clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
@@ -2073,19 +2742,22 @@ var McpTestClient = class {
2073
2742
  async connect() {
2074
2743
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
2075
2744
  this.triedDiscoveredEntryPath = false;
2745
+ this.initResult = null;
2076
2746
  this.transport = this.createTransport();
2077
2747
  await this.transport.connect();
2078
2748
  const maxRetries = 3;
2079
2749
  const retryDelayMs = 500;
2080
2750
  let lastError;
2081
2751
  let reportedEntryPaths;
2752
+ let attemptsMade = 0;
2082
2753
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
2754
+ attemptsMade = attempt;
2083
2755
  const initResponse = await this.initialize();
2084
2756
  if (initResponse.success && initResponse.data) {
2085
2757
  this.initResult = initResponse.data;
2086
2758
  break;
2087
2759
  }
2088
- lastError = initResponse.error?.message ?? "Unknown error";
2760
+ lastError = describeInitializeError(initResponse.error);
2089
2761
  reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
2090
2762
  if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
2091
2763
  this.triedDiscoveredEntryPath = true;
@@ -2097,6 +2769,7 @@ var McpTestClient = class {
2097
2769
  await this.transport.connect();
2098
2770
  continue;
2099
2771
  }
2772
+ if (isAuthFailure(initResponse.error)) break;
2100
2773
  if (attempt < maxRetries) {
2101
2774
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
2102
2775
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
@@ -2105,7 +2778,7 @@ var McpTestClient = class {
2105
2778
  if (!this.initResult) {
2106
2779
  const served = reportedEntryPaths?.length ? ` The server reports it serves MCP at ${reportedEntryPaths.join(", ")}; set \`entryPath\` in test.use() (or \`http.entryPath\` on the server) to match.` : "";
2107
2780
  throw new Error(
2108
- `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
2781
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
2109
2782
  );
2110
2783
  }
2111
2784
  this._sessionId = this.transport.getSessionId();
@@ -2175,18 +2848,47 @@ var McpTestClient = class {
2175
2848
  return this._authState;
2176
2849
  }
2177
2850
  /**
2178
- * Authenticate with a token
2851
+ * Authenticate with a token.
2852
+ *
2853
+ * When connected, this opens a NEW session with the token (the previous session was created
2854
+ * for the previous identity) and rejects if the server refuses it — for example because the
2855
+ * token is expired or badly signed. On rejection the client keeps its previous identity and
2856
+ * session. The token is kept in the client config, so `reconnect()` keeps using it.
2179
2857
  */
2180
2858
  async authenticate(token) {
2859
+ if (typeof token !== "string" || token.trim() === "") {
2860
+ throw new Error("authenticate() requires a non-empty token");
2861
+ }
2862
+ const previousAuthState = this._authState;
2863
+ const previousConfigAuth = this.config.auth;
2864
+ this.config.auth = { ...previousConfigAuth, token };
2181
2865
  this._authState = {
2182
2866
  isAnonymous: false,
2183
2867
  token,
2184
2868
  scopes: this.parseScopesFromToken(token),
2185
2869
  user: this.parseUserFromToken(token)
2186
2870
  };
2187
- if (this.transport) {
2188
- this.transport.setAuthToken(token);
2871
+ const previousTransport = this.transport;
2872
+ if (!previousTransport) {
2873
+ this.log("debug", "Authentication updated (not connected yet)");
2874
+ return;
2189
2875
  }
2876
+ const previousInit = this.initResult;
2877
+ const previousSessionId = this._sessionId;
2878
+ const previousSessionInfo = this._sessionInfo;
2879
+ try {
2880
+ await this.connect();
2881
+ } catch (err) {
2882
+ await this.transport?.close().catch(() => void 0);
2883
+ this.transport = previousTransport;
2884
+ this.initResult = previousInit;
2885
+ this._sessionId = previousSessionId;
2886
+ this._sessionInfo = previousSessionInfo;
2887
+ this.config.auth = previousConfigAuth;
2888
+ this._authState = previousAuthState;
2889
+ throw err;
2890
+ }
2891
+ await previousTransport.close().catch(() => void 0);
2190
2892
  this.log("debug", "Authentication updated");
2191
2893
  }
2192
2894
  // ═══════════════════════════════════════════════════════════════════
@@ -2772,8 +3474,19 @@ var McpTestClient = class {
2772
3474
  elicitationHandler: this._elicitationHandler,
2773
3475
  notificationHandler: this.recordNotification
2774
3476
  });
2775
- case "sse":
2776
- throw new Error("SSE transport not yet implemented");
3477
+ case "sse":
3478
+ return new SseTransport({
3479
+ baseUrl,
3480
+ entryPath: this.config.entryPath,
3481
+ timeout: this.config.timeout,
3482
+ auth: this.config.auth,
3483
+ publicMode: this.config.publicMode,
3484
+ debug: this.config.debug,
3485
+ interceptors: this._interceptors,
3486
+ clientInfo: this.config.clientInfo,
3487
+ elicitationHandler: this._elicitationHandler,
3488
+ notificationHandler: this.recordNotification
3489
+ });
2777
3490
  default:
2778
3491
  throw new Error(`Unknown transport type: ${this.config.transport}`);
2779
3492
  }
@@ -2810,10 +3523,11 @@ var McpTestClient = class {
2810
3523
  requestId: id
2811
3524
  };
2812
3525
  } catch (err) {
3526
+ if (isInterceptedError(err)) throw err;
2813
3527
  const durationMs = Date.now() - start;
2814
3528
  const error = {
2815
3529
  code: -32603,
2816
- message: err instanceof Error ? err.message : "Unknown error"
3530
+ message: errorMessage(err)
2817
3531
  };
2818
3532
  return {
2819
3533
  success: false,
@@ -3040,192 +3754,8 @@ var ProgressCollector = class {
3040
3754
  }
3041
3755
  };
3042
3756
 
3043
- // libs/testing/src/auth/token-factory.ts
3044
- import { exportJWK, generateKeyPair, SignJWT } from "jose";
3045
- var TestTokenFactory = class {
3046
- issuer;
3047
- audience;
3048
- privateKey = null;
3049
- publicKey = null;
3050
- jwk = null;
3051
- keyId;
3052
- /** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
3053
- hmacSecret;
3054
- constructor(options = {}) {
3055
- this.issuer = options.issuer ?? "https://test.frontmcp.local";
3056
- this.audience = options.audience ?? "frontmcp-test";
3057
- this.keyId = `test-key-${Date.now()}`;
3058
- this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
3059
- }
3060
- /** True when this factory signs HS256 with a shared secret (gateway mode). */
3061
- get isHmac() {
3062
- return this.hmacSecret !== null;
3063
- }
3064
- /** The JWS `alg` this factory signs with. */
3065
- get alg() {
3066
- return this.isHmac ? "HS256" : "RS256";
3067
- }
3068
- /**
3069
- * The signing key: the shared HMAC secret in gateway mode, otherwise the
3070
- * generated RSA private key (lazily created via ensureKeys()).
3071
- */
3072
- signingKey() {
3073
- if (this.hmacSecret) return this.hmacSecret;
3074
- if (!this.privateKey) {
3075
- throw new Error("Private key not initialized");
3076
- }
3077
- return this.privateKey;
3078
- }
3079
- /**
3080
- * Initialize the key pair (called automatically on first use). No-op in HS256
3081
- * mode — there is no asymmetric keypair, just the shared secret.
3082
- */
3083
- async ensureKeys() {
3084
- if (this.isHmac) return;
3085
- if (this.privateKey && this.publicKey) return;
3086
- const { publicKey, privateKey } = await generateKeyPair("RS256", {
3087
- extractable: true
3088
- });
3089
- this.privateKey = privateKey;
3090
- this.publicKey = publicKey;
3091
- this.jwk = await exportJWK(publicKey);
3092
- this.jwk.kid = this.keyId;
3093
- this.jwk.use = "sig";
3094
- this.jwk.alg = "RS256";
3095
- }
3096
- /**
3097
- * Create a JWT token with the specified claims
3098
- */
3099
- async createTestToken(options) {
3100
- await this.ensureKeys();
3101
- const now = Math.floor(Date.now() / 1e3);
3102
- const exp = options.exp ?? 3600;
3103
- const payload = {
3104
- iss: options.iss ?? this.issuer,
3105
- sub: options.sub,
3106
- aud: options.aud ?? this.audience,
3107
- iat: now,
3108
- exp: now + exp,
3109
- scope: options.scopes?.join(" "),
3110
- ...options.claims
3111
- };
3112
- const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
3113
- const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
3114
- return token;
3115
- }
3116
- /**
3117
- * Create an admin token with full access
3118
- */
3119
- async createAdminToken(sub = "admin-001") {
3120
- return this.createTestToken({
3121
- sub,
3122
- scopes: ["admin:*", "read", "write", "delete"],
3123
- claims: {
3124
- email: "admin@test.local",
3125
- name: "Test Admin",
3126
- role: "admin"
3127
- }
3128
- });
3129
- }
3130
- /**
3131
- * Create a regular user token
3132
- */
3133
- async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
3134
- return this.createTestToken({
3135
- sub,
3136
- scopes,
3137
- claims: {
3138
- email: "user@test.local",
3139
- name: "Test User",
3140
- role: "user"
3141
- }
3142
- });
3143
- }
3144
- /**
3145
- * Create an anonymous user token
3146
- */
3147
- async createAnonymousToken() {
3148
- return this.createTestToken({
3149
- sub: `anon:${Date.now()}`,
3150
- scopes: ["anonymous"],
3151
- claims: {
3152
- name: "Anonymous",
3153
- role: "anonymous"
3154
- }
3155
- });
3156
- }
3157
- /**
3158
- * Create an expired token (for testing token expiration)
3159
- */
3160
- async createExpiredToken(options) {
3161
- await this.ensureKeys();
3162
- const now = Math.floor(Date.now() / 1e3);
3163
- const payload = {
3164
- iss: this.issuer,
3165
- sub: options.sub,
3166
- aud: this.audience,
3167
- iat: now - 7200,
3168
- // 2 hours ago
3169
- exp: now - 3600
3170
- // Expired 1 hour ago
3171
- };
3172
- const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
3173
- const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
3174
- return token;
3175
- }
3176
- /**
3177
- * Create a token with an invalid signature (for testing signature validation)
3178
- */
3179
- createTokenWithInvalidSignature(options) {
3180
- const now = Math.floor(Date.now() / 1e3);
3181
- const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
3182
- const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
3183
- const payload = Buffer.from(
3184
- JSON.stringify({
3185
- iss: this.issuer,
3186
- sub: options.sub,
3187
- aud: this.audience,
3188
- iat: now,
3189
- exp: now + 3600
3190
- })
3191
- ).toString("base64url");
3192
- const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
3193
- return `${header}.${payload}.${signature}`;
3194
- }
3195
- /**
3196
- * Get the public JWKS for verifying tokens.
3197
- *
3198
- * Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
3199
- * the shared secret, not a published public key, so there is no JWKS to serve.
3200
- */
3201
- async getPublicJwks() {
3202
- if (this.isHmac) {
3203
- throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
3204
- }
3205
- await this.ensureKeys();
3206
- if (!this.jwk) {
3207
- throw new Error("JWK not initialized");
3208
- }
3209
- return {
3210
- keys: [this.jwk]
3211
- };
3212
- }
3213
- /**
3214
- * Get the issuer URL
3215
- */
3216
- getIssuer() {
3217
- return this.issuer;
3218
- }
3219
- /**
3220
- * Get the audience
3221
- */
3222
- getAudience() {
3223
- return this.audience;
3224
- }
3225
- };
3226
-
3227
3757
  // libs/testing/src/server/test-server.ts
3228
- import { spawn } from "child_process";
3758
+ import { execFile, spawn } from "child_process";
3229
3759
  import { sha256Hex } from "@frontmcp/utils";
3230
3760
 
3231
3761
  // libs/testing/src/errors/index.ts
@@ -3296,6 +3826,8 @@ var E2E_PORT_RANGES = {
3296
3826
  "demo-e2e-distributed": { start: 50440, size: 20 },
3297
3827
  // Protocol revision E2E tests (50460-50479)
3298
3828
  "demo-e2e-protocol-20260728": { start: 50460, size: 20 },
3829
+ // Testing library E2E tests (50480-50499)
3830
+ "demo-e2e-testing": { start: 50480, size: 20 },
3299
3831
  // Mock servers and utilities (50900-50999)
3300
3832
  "mock-oauth": { start: 50900, size: 10 },
3301
3833
  "mock-api": { start: 50910, size: 10 },
@@ -3304,6 +3836,66 @@ var E2E_PORT_RANGES = {
3304
3836
  default: { start: 51e3, size: 100 }
3305
3837
  };
3306
3838
  var reservedPorts = /* @__PURE__ */ new Map();
3839
+ var lockedPorts = /* @__PURE__ */ new Set();
3840
+ var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
3841
+ function lockDir() {
3842
+ try {
3843
+ const os = __require("node:os");
3844
+ const path = __require("node:path");
3845
+ return path.join(os.tmpdir(), "frontmcp-testing-ports");
3846
+ } catch {
3847
+ return void 0;
3848
+ }
3849
+ }
3850
+ function pidIsAlive(pid) {
3851
+ try {
3852
+ process.kill(pid, 0);
3853
+ return true;
3854
+ } catch (error) {
3855
+ return error.code === "EPERM";
3856
+ }
3857
+ }
3858
+ function acquirePortLock(port) {
3859
+ if (lockedPorts.has(port)) return false;
3860
+ let fs;
3861
+ let file;
3862
+ try {
3863
+ fs = __require("node:fs");
3864
+ const dir = lockDir();
3865
+ if (!dir) return true;
3866
+ fs.mkdirSync(dir, { recursive: true });
3867
+ file = __require("node:path").join(dir, `${port}.lock`);
3868
+ } catch {
3869
+ return true;
3870
+ }
3871
+ const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
3872
+ for (let attempt = 0; attempt < 2; attempt++) {
3873
+ try {
3874
+ fs.writeFileSync(file, payload, { flag: "wx" });
3875
+ lockedPorts.add(port);
3876
+ return true;
3877
+ } catch (error) {
3878
+ if (error.code !== "EEXIST") return true;
3879
+ try {
3880
+ const held = JSON.parse(fs.readFileSync(file, "utf8"));
3881
+ const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
3882
+ if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
3883
+ fs.unlinkSync(file);
3884
+ } catch {
3885
+ }
3886
+ }
3887
+ }
3888
+ return false;
3889
+ }
3890
+ function releasePortLock(port) {
3891
+ if (!lockedPorts.delete(port)) return;
3892
+ try {
3893
+ const fs = __require("node:fs");
3894
+ const dir = lockDir();
3895
+ if (dir) fs.unlinkSync(__require("node:path").join(dir, `${port}.lock`));
3896
+ } catch {
3897
+ }
3898
+ }
3307
3899
  var projectPortIndex = /* @__PURE__ */ new Map();
3308
3900
  function getPortRange(project) {
3309
3901
  const key = project;
@@ -3314,15 +3906,22 @@ function getPortRange(project) {
3314
3906
  }
3315
3907
  async function reservePort(project, preferredPort) {
3316
3908
  const range = getPortRange(project);
3317
- if (preferredPort !== void 0) {
3909
+ const handle = (port) => ({
3910
+ port,
3911
+ // Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
3912
+ releaseSocket: async () => {
3913
+ await releasePort(port);
3914
+ },
3915
+ release: async () => {
3916
+ await releasePort(port);
3917
+ releasePortLock(port);
3918
+ }
3919
+ });
3920
+ const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
3921
+ if (wantsSpecificPort) {
3318
3922
  const reservation = await tryReservePort(preferredPort, project);
3319
3923
  if (reservation) {
3320
- return {
3321
- port: preferredPort,
3322
- release: async () => {
3323
- await releasePort(preferredPort);
3324
- }
3325
- };
3924
+ return handle(preferredPort);
3326
3925
  }
3327
3926
  console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
3328
3927
  }
@@ -3331,30 +3930,19 @@ async function reservePort(project, preferredPort) {
3331
3930
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
3332
3931
  const port = range.start + index % range.size;
3333
3932
  index = (index + 1) % range.size;
3334
- if (reservedPorts.has(port)) {
3933
+ if (reservedPorts.has(port) || lockedPorts.has(port)) {
3335
3934
  continue;
3336
3935
  }
3337
3936
  const reservation = await tryReservePort(port, project);
3338
3937
  if (reservation) {
3339
3938
  projectPortIndex.set(project, index);
3340
- return {
3341
- port,
3342
- release: async () => {
3343
- await releasePort(port);
3344
- }
3345
- };
3939
+ return handle(port);
3346
3940
  }
3347
3941
  }
3348
- const dynamicPort = await findAvailablePortInRange(51e3, 52e3);
3349
- if (dynamicPort) {
3350
- const reservation = await tryReservePort(dynamicPort, project);
3351
- if (reservation) {
3352
- return {
3353
- port: dynamicPort,
3354
- release: async () => {
3355
- await releasePort(dynamicPort);
3356
- }
3357
- };
3942
+ for (let port = 51e3; port < 52e3; port++) {
3943
+ if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
3944
+ if (await tryReservePort(port, project)) {
3945
+ return handle(port);
3358
3946
  }
3359
3947
  }
3360
3948
  throw new Error(
@@ -3362,9 +3950,13 @@ async function reservePort(project, preferredPort) {
3362
3950
  );
3363
3951
  }
3364
3952
  async function tryReservePort(port, project) {
3953
+ if (!acquirePortLock(port)) {
3954
+ return false;
3955
+ }
3365
3956
  return new Promise((resolve) => {
3366
3957
  const server = createServer();
3367
3958
  server.once("error", () => {
3959
+ releasePortLock(port);
3368
3960
  resolve(false);
3369
3961
  });
3370
3962
  server.listen(port, () => {
@@ -3390,31 +3982,6 @@ async function releasePort(port) {
3390
3982
  });
3391
3983
  });
3392
3984
  }
3393
- async function findAvailablePortInRange(start, end) {
3394
- for (let port = start; port < end; port++) {
3395
- if (reservedPorts.has(port)) {
3396
- continue;
3397
- }
3398
- const available = await isPortAvailable(port);
3399
- if (available) {
3400
- return port;
3401
- }
3402
- }
3403
- return null;
3404
- }
3405
- async function isPortAvailable(port) {
3406
- return new Promise((resolve) => {
3407
- const server = createServer();
3408
- server.once("error", () => {
3409
- resolve(false);
3410
- });
3411
- server.listen(port, () => {
3412
- server.close(() => {
3413
- resolve(true);
3414
- });
3415
- });
3416
- });
3417
- }
3418
3985
 
3419
3986
  // libs/testing/src/server/test-server.ts
3420
3987
  var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
@@ -3423,8 +3990,11 @@ var TestServer = class _TestServer {
3423
3990
  options;
3424
3991
  _info;
3425
3992
  logs = [];
3993
+ /** Frees the port reservation (socket and cross-process lock); held until stop() */
3426
3994
  portRelease = null;
3427
- constructor(options, port, portRelease) {
3995
+ /** Frees only the reservation socket, just before the server is spawned */
3996
+ portReleaseSocket = null;
3997
+ constructor(options, port, portRelease, portReleaseSocket) {
3428
3998
  this.options = {
3429
3999
  port,
3430
4000
  project: options.project,
@@ -3436,6 +4006,7 @@ var TestServer = class _TestServer {
3436
4006
  debug: options.debug ?? DEBUG_SERVER
3437
4007
  };
3438
4008
  this.portRelease = portRelease ?? null;
4009
+ this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
3439
4010
  this._info = {
3440
4011
  baseUrl: `http://localhost:${port}`,
3441
4012
  port
@@ -3448,8 +4019,8 @@ var TestServer = class _TestServer {
3448
4019
  const project = options.project ?? "default";
3449
4020
  const maxAttempts = 3;
3450
4021
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3451
- const { port, release } = await reservePort(project, options.port);
3452
- const server = new _TestServer(options, port, release);
4022
+ const { port, release, releaseSocket } = await reservePort(project, options.port);
4023
+ const server = new _TestServer(options, port, release, releaseSocket);
3453
4024
  try {
3454
4025
  await server.startProcess();
3455
4026
  return server;
@@ -3489,7 +4060,7 @@ var TestServer = class _TestServer {
3489
4060
  }
3490
4061
  const maxAttempts = 3;
3491
4062
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3492
- const { port, release } = await reservePort(project, options.port);
4063
+ const { port, release, releaseSocket } = await reservePort(project, options.port);
3493
4064
  const serverOptions = {
3494
4065
  ...options,
3495
4066
  port,
@@ -3497,7 +4068,7 @@ var TestServer = class _TestServer {
3497
4068
  command: `npx nx serve ${project} --port ${port}`,
3498
4069
  cwd: options.cwd ?? process.cwd()
3499
4070
  };
3500
- const server = new _TestServer(serverOptions, port, release);
4071
+ const server = new _TestServer(serverOptions, port, release, releaseSocket);
3501
4072
  try {
3502
4073
  await server.startProcess();
3503
4074
  return server;
@@ -3555,10 +4126,17 @@ var TestServer = class _TestServer {
3555
4126
  * Stop the test server
3556
4127
  */
3557
4128
  async stop() {
3558
- if (this.portRelease) {
3559
- await this.portRelease();
3560
- this.portRelease = null;
4129
+ try {
4130
+ await this.stopProcess();
4131
+ } finally {
4132
+ if (this.portRelease) {
4133
+ await this.portRelease();
4134
+ this.portRelease = null;
4135
+ }
4136
+ this.portReleaseSocket = null;
3561
4137
  }
4138
+ }
4139
+ async stopProcess() {
3562
4140
  if (this.process) {
3563
4141
  this.log("Stopping server...");
3564
4142
  if (this.process.exitCode !== null || this.process.signalCode !== null) {
@@ -3631,7 +4209,7 @@ var TestServer = class _TestServer {
3631
4209
  * Restart the server
3632
4210
  */
3633
4211
  async restart() {
3634
- await this.stop();
4212
+ await this.stopProcess();
3635
4213
  await this.startProcess();
3636
4214
  }
3637
4215
  /**
@@ -3661,9 +4239,9 @@ var TestServer = class _TestServer {
3661
4239
  PORT: String(this.options.port)
3662
4240
  };
3663
4241
  const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
3664
- if (this.portRelease) {
3665
- await this.portRelease();
3666
- this.portRelease = null;
4242
+ if (this.portReleaseSocket) {
4243
+ await this.portReleaseSocket();
4244
+ this.portReleaseSocket = null;
3667
4245
  await sleep4(300);
3668
4246
  }
3669
4247
  this.process = spawn(this.options.command, [], {
@@ -3673,9 +4251,8 @@ var TestServer = class _TestServer {
3673
4251
  detached: true,
3674
4252
  stdio: ["pipe", "pipe", "pipe"]
3675
4253
  });
3676
- if (this.process.pid !== void 0) {
3677
- this._info.pid = this.process.pid;
3678
- }
4254
+ this._info.pid = this.process.pid;
4255
+ this._info.shellPid = this.process.pid;
3679
4256
  let processExited = false;
3680
4257
  let exitCode = null;
3681
4258
  let exitError = null;
@@ -3778,6 +4355,8 @@ ${allLogs}`);
3778
4355
  });
3779
4356
  if (response.ok || response.status === 404) {
3780
4357
  this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
4358
+ const listeningPid = await findListeningPid(this.options.port);
4359
+ if (listeningPid !== void 0) this._info.pid = listeningPid;
3781
4360
  return;
3782
4361
  }
3783
4362
  lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
@@ -3822,6 +4401,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
3822
4401
  function sleep4(ms) {
3823
4402
  return new Promise((resolve) => setTimeout(resolve, ms));
3824
4403
  }
4404
+ function findListeningPid(port) {
4405
+ if (process.platform === "win32") return Promise.resolve(void 0);
4406
+ return new Promise((resolve) => {
4407
+ execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
4408
+ if (error) {
4409
+ resolve(void 0);
4410
+ return;
4411
+ }
4412
+ const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
4413
+ resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
4414
+ });
4415
+ });
4416
+ }
3825
4417
  function withWorkspaceProtocolFallback(env, cwd) {
3826
4418
  if (findInstalledProtocolPackageDir(cwd)) {
3827
4419
  return env;
@@ -3969,7 +4561,8 @@ Server entry: ${currentConfig.server}
3969
4561
  Project: ${currentConfig.project ?? "default"}
3970
4562
  Command: ${serverCommand}
3971
4563
 
3972
- Error: ${errMsg}`
4564
+ Error: ${errMsg}`,
4565
+ { cause: error }
3973
4566
  );
3974
4567
  }
3975
4568
  } else {
@@ -4159,16 +4752,19 @@ function todo(name) {
4159
4752
  }
4160
4753
  var perfTest = perfTestWithFixtures;
4161
4754
  perfTest.use = use;
4162
- perfTest.describe = describe;
4163
- perfTest.beforeAll = beforeAll;
4164
- perfTest.beforeEach = beforeEach;
4165
- perfTest.afterEach = afterEach;
4166
- perfTest.afterAll = afterAll;
4755
+ for (const hook of ["describe", "beforeAll", "beforeEach", "afterEach", "afterAll"]) {
4756
+ Object.defineProperty(perfTest, hook, {
4757
+ get: () => globalThis[hook],
4758
+ enumerable: true,
4759
+ configurable: true
4760
+ });
4761
+ }
4167
4762
  perfTest.skip = skip;
4168
4763
  perfTest.only = only;
4169
4764
  perfTest.todo = todo;
4170
4765
 
4171
4766
  // libs/testing/src/perf/baseline-store.ts
4767
+ import { ensureDir, fileExists, readFile, writeFile } from "@frontmcp/utils";
4172
4768
  var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
4173
4769
  var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
4174
4770
  var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
@@ -4183,7 +4779,6 @@ var BaselineStore = class {
4183
4779
  */
4184
4780
  async load() {
4185
4781
  try {
4186
- const { readFile, fileExists } = await import("@frontmcp/utils");
4187
4782
  if (!await fileExists(this.baselinePath)) {
4188
4783
  return null;
4189
4784
  }
@@ -4198,7 +4793,6 @@ var BaselineStore = class {
4198
4793
  * Save baseline to local file.
4199
4794
  */
4200
4795
  async save(baseline) {
4201
- const { writeFile, ensureDir } = await import("@frontmcp/utils");
4202
4796
  const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
4203
4797
  await ensureDir(dir);
4204
4798
  await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
@@ -4477,6 +5071,7 @@ function resetRegressionDetector() {
4477
5071
  }
4478
5072
 
4479
5073
  // libs/testing/src/perf/report-generator.ts
5074
+ import { ensureDir as ensureDir2, writeFile as writeFile2 } from "@frontmcp/utils";
4480
5075
  var ReportGenerator = class {
4481
5076
  detector;
4482
5077
  constructor() {
@@ -4732,14 +5327,13 @@ var ReportGenerator = class {
4732
5327
  }
4733
5328
  };
4734
5329
  async function saveReports(measurements, outputDir, baseline, gitInfo) {
4735
- const { writeFile, ensureDir } = await import("@frontmcp/utils");
4736
- await ensureDir(outputDir);
5330
+ await ensureDir2(outputDir);
4737
5331
  const generator = new ReportGenerator();
4738
5332
  const report = generator.generateReport(measurements, baseline, gitInfo);
4739
5333
  const jsonPath = `${outputDir}/report.json`;
4740
5334
  const markdownPath = `${outputDir}/report.md`;
4741
- await writeFile(jsonPath, generator.generateJsonReport(report));
4742
- await writeFile(markdownPath, generator.generateMarkdownReport(report));
5335
+ await writeFile2(jsonPath, generator.generateJsonReport(report));
5336
+ await writeFile2(markdownPath, generator.generateMarkdownReport(report));
4743
5337
  return { jsonPath, markdownPath };
4744
5338
  }
4745
5339
  function createReportGenerator() {