@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.
- package/auth/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts +2 -2
- package/auth/token-factory.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +6 -1
- package/client/mcp-test-client.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +993 -237
- package/esm/index.mjs +1151 -360
- package/esm/matchers/index.mjs +3 -0
- package/esm/package.json +8 -6
- package/esm/perf/index.mjs +874 -280
- package/esm/perf/perf-setup.mjs +3 -0
- package/esm/playwright/index.mjs +3 -0
- package/esm/setup.mjs +3 -0
- package/expect.d.ts +12 -11
- package/expect.d.ts.map +1 -1
- package/fixtures/fixture-types.d.ts +41 -10
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.d.ts +1 -1
- package/fixtures/index.d.ts.map +1 -1
- package/fixtures/index.js +989 -229
- package/fixtures/test-fixture.d.ts +6 -5
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/http-mock/http-mock.d.ts +5 -3
- package/http-mock/http-mock.d.ts.map +1 -1
- package/http-mock/http-mock.types.d.ts +17 -6
- package/http-mock/http-mock.types.d.ts.map +1 -1
- package/http-mock/index.d.ts +1 -1
- package/http-mock/index.d.ts.map +1 -1
- package/index.d.ts +3 -2
- package/index.d.ts.map +1 -1
- package/index.js +1157 -361
- package/package.json +8 -6
- package/perf/baseline-store.d.ts +1 -1
- package/perf/baseline-store.d.ts.map +1 -1
- package/perf/index.js +874 -293
- package/perf/perf-test.d.ts.map +1 -1
- package/perf/report-generator.d.ts +1 -1
- package/perf/report-generator.d.ts.map +1 -1
- package/server/port-registry.d.ts +7 -1
- package/server/port-registry.d.ts.map +1 -1
- package/server/test-server.d.ts +10 -1
- package/server/test-server.d.ts.map +1 -1
- package/transport/error-utils.d.ts +15 -0
- package/transport/error-utils.d.ts.map +1 -0
- package/transport/index.d.ts +1 -0
- package/transport/index.d.ts.map +1 -1
- package/transport/sse.transport.d.ts +80 -0
- package/transport/sse.transport.d.ts.map +1 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
package/esm/perf/index.mjs
CHANGED
|
@@ -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/
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
|
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 } =
|
|
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
|
|
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
|
|
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
|
|
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 ??
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
2188
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3559
|
-
await this.
|
|
3560
|
-
|
|
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.
|
|
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.
|
|
3665
|
-
await this.
|
|
3666
|
-
this.
|
|
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
|
-
|
|
3677
|
-
|
|
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
|
-
|
|
4163
|
-
perfTest
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
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
|
-
|
|
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
|
|
4742
|
-
await
|
|
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() {
|