@cocaxcode/api-testing-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -126
- package/dist/index.js +938 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1214,8 +1214,940 @@ function formatSchema(schema, depth = 0) {
|
|
|
1214
1214
|
}
|
|
1215
1215
|
}
|
|
1216
1216
|
|
|
1217
|
+
// src/tools/assert.ts
|
|
1218
|
+
import { z as z5 } from "zod";
|
|
1219
|
+
var AssertionSchema = z5.object({
|
|
1220
|
+
path: z5.string().describe(
|
|
1221
|
+
'JSONPath al valor a validar: "status", "body.data.id", "headers.content-type", "timing.total_ms"'
|
|
1222
|
+
),
|
|
1223
|
+
operator: z5.enum(["eq", "neq", "gt", "gte", "lt", "lte", "contains", "not_contains", "exists", "type"]).describe(
|
|
1224
|
+
"Operador: eq (igual), neq (no igual), gt/gte/lt/lte (num\xE9ricos), contains/not_contains (strings/arrays), exists (campo existe), type (typeof)"
|
|
1225
|
+
),
|
|
1226
|
+
expected: z5.any().optional().describe('Valor esperado (no necesario para "exists")')
|
|
1227
|
+
});
|
|
1228
|
+
function getByPath(obj, path) {
|
|
1229
|
+
const parts = path.split(".");
|
|
1230
|
+
let current = obj;
|
|
1231
|
+
for (const part of parts) {
|
|
1232
|
+
if (current === null || current === void 0) return void 0;
|
|
1233
|
+
if (typeof current === "object") {
|
|
1234
|
+
current = current[part];
|
|
1235
|
+
} else {
|
|
1236
|
+
return void 0;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return current;
|
|
1240
|
+
}
|
|
1241
|
+
function evaluateAssertion(response, assertion) {
|
|
1242
|
+
const actual = getByPath(response, assertion.path);
|
|
1243
|
+
switch (assertion.operator) {
|
|
1244
|
+
case "eq":
|
|
1245
|
+
return {
|
|
1246
|
+
pass: actual === assertion.expected,
|
|
1247
|
+
message: actual === assertion.expected ? `${assertion.path} === ${JSON.stringify(assertion.expected)}` : `${assertion.path}: esperado ${JSON.stringify(assertion.expected)}, recibido ${JSON.stringify(actual)}`
|
|
1248
|
+
};
|
|
1249
|
+
case "neq":
|
|
1250
|
+
return {
|
|
1251
|
+
pass: actual !== assertion.expected,
|
|
1252
|
+
message: actual !== assertion.expected ? `${assertion.path} !== ${JSON.stringify(assertion.expected)}` : `${assertion.path}: no deber\xEDa ser ${JSON.stringify(assertion.expected)}`
|
|
1253
|
+
};
|
|
1254
|
+
case "gt":
|
|
1255
|
+
return {
|
|
1256
|
+
pass: typeof actual === "number" && actual > assertion.expected,
|
|
1257
|
+
message: `${assertion.path}: ${actual} > ${assertion.expected} \u2192 ${typeof actual === "number" && actual > assertion.expected}`
|
|
1258
|
+
};
|
|
1259
|
+
case "gte":
|
|
1260
|
+
return {
|
|
1261
|
+
pass: typeof actual === "number" && actual >= assertion.expected,
|
|
1262
|
+
message: `${assertion.path}: ${actual} >= ${assertion.expected} \u2192 ${typeof actual === "number" && actual >= assertion.expected}`
|
|
1263
|
+
};
|
|
1264
|
+
case "lt":
|
|
1265
|
+
return {
|
|
1266
|
+
pass: typeof actual === "number" && actual < assertion.expected,
|
|
1267
|
+
message: `${assertion.path}: ${actual} < ${assertion.expected} \u2192 ${typeof actual === "number" && actual < assertion.expected}`
|
|
1268
|
+
};
|
|
1269
|
+
case "lte":
|
|
1270
|
+
return {
|
|
1271
|
+
pass: typeof actual === "number" && actual <= assertion.expected,
|
|
1272
|
+
message: `${assertion.path}: ${actual} <= ${assertion.expected} \u2192 ${typeof actual === "number" && actual <= assertion.expected}`
|
|
1273
|
+
};
|
|
1274
|
+
case "contains": {
|
|
1275
|
+
let pass = false;
|
|
1276
|
+
if (typeof actual === "string") {
|
|
1277
|
+
pass = actual.includes(String(assertion.expected));
|
|
1278
|
+
} else if (Array.isArray(actual)) {
|
|
1279
|
+
pass = actual.includes(assertion.expected);
|
|
1280
|
+
}
|
|
1281
|
+
return {
|
|
1282
|
+
pass,
|
|
1283
|
+
message: pass ? `${assertion.path} contiene ${JSON.stringify(assertion.expected)}` : `${assertion.path}: no contiene ${JSON.stringify(assertion.expected)}`
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
case "not_contains": {
|
|
1287
|
+
let pass = true;
|
|
1288
|
+
if (typeof actual === "string") {
|
|
1289
|
+
pass = !actual.includes(String(assertion.expected));
|
|
1290
|
+
} else if (Array.isArray(actual)) {
|
|
1291
|
+
pass = !actual.includes(assertion.expected);
|
|
1292
|
+
}
|
|
1293
|
+
return {
|
|
1294
|
+
pass,
|
|
1295
|
+
message: pass ? `${assertion.path} no contiene ${JSON.stringify(assertion.expected)}` : `${assertion.path}: contiene ${JSON.stringify(assertion.expected)} (no deber\xEDa)`
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
case "exists":
|
|
1299
|
+
return {
|
|
1300
|
+
pass: actual !== void 0 && actual !== null,
|
|
1301
|
+
message: actual !== void 0 && actual !== null ? `${assertion.path} existe` : `${assertion.path}: no existe`
|
|
1302
|
+
};
|
|
1303
|
+
case "type":
|
|
1304
|
+
return {
|
|
1305
|
+
pass: typeof actual === assertion.expected,
|
|
1306
|
+
message: typeof actual === assertion.expected ? `${assertion.path} es tipo ${assertion.expected}` : `${assertion.path}: esperado tipo ${assertion.expected}, recibido ${typeof actual}`
|
|
1307
|
+
};
|
|
1308
|
+
default:
|
|
1309
|
+
return { pass: false, message: `Operador desconocido: ${assertion.operator}` };
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
function registerAssertTool(server, storage) {
|
|
1313
|
+
server.tool(
|
|
1314
|
+
"assert",
|
|
1315
|
+
"Ejecuta un request y valida la respuesta con assertions. Retorna resultado pass/fail por cada assertion.",
|
|
1316
|
+
{
|
|
1317
|
+
method: z5.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).describe("HTTP method"),
|
|
1318
|
+
url: z5.string().describe("URL del endpoint (soporta /relativa y {{variables}})"),
|
|
1319
|
+
headers: z5.record(z5.string()).optional().describe("Headers HTTP"),
|
|
1320
|
+
body: z5.any().optional().describe("Body del request (JSON)"),
|
|
1321
|
+
query: z5.record(z5.string()).optional().describe("Query parameters"),
|
|
1322
|
+
auth: z5.object({
|
|
1323
|
+
type: z5.enum(["bearer", "api-key", "basic"]),
|
|
1324
|
+
token: z5.string().optional(),
|
|
1325
|
+
key: z5.string().optional(),
|
|
1326
|
+
header: z5.string().optional(),
|
|
1327
|
+
username: z5.string().optional(),
|
|
1328
|
+
password: z5.string().optional()
|
|
1329
|
+
}).optional().describe("Autenticaci\xF3n"),
|
|
1330
|
+
assertions: z5.array(AssertionSchema).describe("Lista de assertions a validar contra la respuesta")
|
|
1331
|
+
},
|
|
1332
|
+
async (params) => {
|
|
1333
|
+
try {
|
|
1334
|
+
const variables = await storage.getActiveVariables();
|
|
1335
|
+
let resolvedUrl = params.url;
|
|
1336
|
+
if (resolvedUrl.startsWith("/") && variables.BASE_URL) {
|
|
1337
|
+
const baseUrl = variables.BASE_URL.replace(/\/+$/, "");
|
|
1338
|
+
resolvedUrl = `${baseUrl}${resolvedUrl}`;
|
|
1339
|
+
}
|
|
1340
|
+
const config = {
|
|
1341
|
+
method: params.method,
|
|
1342
|
+
url: resolvedUrl,
|
|
1343
|
+
headers: params.headers,
|
|
1344
|
+
body: params.body,
|
|
1345
|
+
query: params.query,
|
|
1346
|
+
auth: params.auth
|
|
1347
|
+
};
|
|
1348
|
+
const interpolated = interpolateRequest(config, variables);
|
|
1349
|
+
const response = await executeRequest(interpolated);
|
|
1350
|
+
const results = params.assertions.map((assertion) => {
|
|
1351
|
+
const result = evaluateAssertion(response, assertion);
|
|
1352
|
+
return { ...result, assertion };
|
|
1353
|
+
});
|
|
1354
|
+
const passed = results.filter((r) => r.pass).length;
|
|
1355
|
+
const failed = results.filter((r) => !r.pass).length;
|
|
1356
|
+
const allPassed = failed === 0;
|
|
1357
|
+
const lines = [
|
|
1358
|
+
`${allPassed ? "\u2705 PASS" : "\u274C FAIL"} \u2014 ${passed}/${results.length} assertions passed`,
|
|
1359
|
+
`${params.method} ${params.url} \u2192 ${response.status} ${response.statusText} (${response.timing.total_ms}ms)`,
|
|
1360
|
+
""
|
|
1361
|
+
];
|
|
1362
|
+
for (const r of results) {
|
|
1363
|
+
const icon = r.pass ? "\u2705" : "\u274C";
|
|
1364
|
+
lines.push(`${icon} ${r.message}`);
|
|
1365
|
+
}
|
|
1366
|
+
return {
|
|
1367
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1368
|
+
isError: !allPassed
|
|
1369
|
+
};
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1372
|
+
return {
|
|
1373
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1374
|
+
isError: true
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/tools/flow.ts
|
|
1382
|
+
import { z as z6 } from "zod";
|
|
1383
|
+
var FlowStepSchema = z6.object({
|
|
1384
|
+
name: z6.string().describe('Nombre del paso (ej: "login", "crear-post")'),
|
|
1385
|
+
method: z6.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).describe("HTTP method"),
|
|
1386
|
+
url: z6.string().describe("URL del endpoint"),
|
|
1387
|
+
headers: z6.record(z6.string()).optional().describe("Headers HTTP"),
|
|
1388
|
+
body: z6.any().optional().describe("Body del request"),
|
|
1389
|
+
query: z6.record(z6.string()).optional().describe("Query parameters"),
|
|
1390
|
+
auth: z6.object({
|
|
1391
|
+
type: z6.enum(["bearer", "api-key", "basic"]),
|
|
1392
|
+
token: z6.string().optional(),
|
|
1393
|
+
key: z6.string().optional(),
|
|
1394
|
+
header: z6.string().optional(),
|
|
1395
|
+
username: z6.string().optional(),
|
|
1396
|
+
password: z6.string().optional()
|
|
1397
|
+
}).optional().describe("Autenticaci\xF3n"),
|
|
1398
|
+
extract: z6.record(z6.string()).optional().describe(
|
|
1399
|
+
'Variables a extraer de la respuesta para pasos siguientes. Key = nombre variable, value = path (ej: { "TOKEN": "body.token", "USER_ID": "body.data.id" })'
|
|
1400
|
+
)
|
|
1401
|
+
});
|
|
1402
|
+
function getByPath2(obj, path) {
|
|
1403
|
+
const parts = path.split(".");
|
|
1404
|
+
let current = obj;
|
|
1405
|
+
for (const part of parts) {
|
|
1406
|
+
if (current === null || current === void 0) return void 0;
|
|
1407
|
+
if (typeof current === "object") {
|
|
1408
|
+
if (Array.isArray(current) && /^\d+$/.test(part)) {
|
|
1409
|
+
current = current[parseInt(part)];
|
|
1410
|
+
} else {
|
|
1411
|
+
current = current[part];
|
|
1412
|
+
}
|
|
1413
|
+
} else {
|
|
1414
|
+
return void 0;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
return current;
|
|
1418
|
+
}
|
|
1419
|
+
function registerFlowTool(server, storage) {
|
|
1420
|
+
server.tool(
|
|
1421
|
+
"flow_run",
|
|
1422
|
+
"Ejecuta una secuencia de requests en orden. Extrae variables de cada respuesta para usar en pasos siguientes con {{variable}}.",
|
|
1423
|
+
{
|
|
1424
|
+
steps: z6.array(FlowStepSchema).describe("Pasos a ejecutar en orden"),
|
|
1425
|
+
stop_on_error: z6.boolean().optional().describe("Detener al primer error (default: true)")
|
|
1426
|
+
},
|
|
1427
|
+
async (params) => {
|
|
1428
|
+
try {
|
|
1429
|
+
const stopOnError = params.stop_on_error ?? true;
|
|
1430
|
+
const envVariables = await storage.getActiveVariables();
|
|
1431
|
+
const flowVariables = { ...envVariables };
|
|
1432
|
+
const results = [];
|
|
1433
|
+
for (const step of params.steps) {
|
|
1434
|
+
try {
|
|
1435
|
+
let resolvedUrl = step.url;
|
|
1436
|
+
if (resolvedUrl.startsWith("/") && flowVariables.BASE_URL) {
|
|
1437
|
+
const baseUrl = flowVariables.BASE_URL.replace(/\/+$/, "");
|
|
1438
|
+
resolvedUrl = `${baseUrl}${resolvedUrl}`;
|
|
1439
|
+
}
|
|
1440
|
+
const config = {
|
|
1441
|
+
method: step.method,
|
|
1442
|
+
url: resolvedUrl,
|
|
1443
|
+
headers: step.headers,
|
|
1444
|
+
body: step.body,
|
|
1445
|
+
query: step.query,
|
|
1446
|
+
auth: step.auth
|
|
1447
|
+
};
|
|
1448
|
+
const interpolated = interpolateRequest(config, flowVariables);
|
|
1449
|
+
const response = await executeRequest(interpolated);
|
|
1450
|
+
const extracted = {};
|
|
1451
|
+
if (step.extract) {
|
|
1452
|
+
for (const [varName, path] of Object.entries(step.extract)) {
|
|
1453
|
+
const value = getByPath2(response, path);
|
|
1454
|
+
if (value !== void 0 && value !== null) {
|
|
1455
|
+
extracted[varName] = String(value);
|
|
1456
|
+
flowVariables[varName] = String(value);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
results.push({
|
|
1461
|
+
name: step.name,
|
|
1462
|
+
status: response.status,
|
|
1463
|
+
timing: response.timing.total_ms,
|
|
1464
|
+
extracted
|
|
1465
|
+
});
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1468
|
+
results.push({
|
|
1469
|
+
name: step.name,
|
|
1470
|
+
status: 0,
|
|
1471
|
+
timing: 0,
|
|
1472
|
+
extracted: {},
|
|
1473
|
+
error: message
|
|
1474
|
+
});
|
|
1475
|
+
if (stopOnError) break;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const allOk = results.every((r) => !r.error && r.status >= 200 && r.status < 400);
|
|
1479
|
+
const lines = [
|
|
1480
|
+
`${allOk ? "\u2705 FLOW COMPLETO" : "\u274C FLOW CON ERRORES"} \u2014 ${results.length}/${params.steps.length} pasos ejecutados`,
|
|
1481
|
+
""
|
|
1482
|
+
];
|
|
1483
|
+
for (let i = 0; i < results.length; i++) {
|
|
1484
|
+
const r = results[i];
|
|
1485
|
+
const icon = r.error ? "\u274C" : r.status >= 200 && r.status < 400 ? "\u2705" : "\u26A0\uFE0F";
|
|
1486
|
+
lines.push(`${icon} Paso ${i + 1}: ${r.name}`);
|
|
1487
|
+
if (r.error) {
|
|
1488
|
+
lines.push(` Error: ${r.error}`);
|
|
1489
|
+
} else {
|
|
1490
|
+
lines.push(` Status: ${r.status} | Tiempo: ${r.timing}ms`);
|
|
1491
|
+
}
|
|
1492
|
+
if (Object.keys(r.extracted).length > 0) {
|
|
1493
|
+
const vars = Object.entries(r.extracted).map(([k, v]) => `${k}=${v.length > 50 ? v.substring(0, 50) + "..." : v}`).join(", ");
|
|
1494
|
+
lines.push(` Extra\xEDdo: ${vars}`);
|
|
1495
|
+
}
|
|
1496
|
+
lines.push("");
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1500
|
+
isError: !allOk
|
|
1501
|
+
};
|
|
1502
|
+
} catch (error) {
|
|
1503
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1504
|
+
return {
|
|
1505
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1506
|
+
isError: true
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// src/tools/utilities.ts
|
|
1514
|
+
import { z as z7 } from "zod";
|
|
1515
|
+
function registerUtilityTools(server, storage) {
|
|
1516
|
+
server.tool(
|
|
1517
|
+
"export_curl",
|
|
1518
|
+
"Genera un comando cURL a partir de un request guardado en la colecci\xF3n. Listo para copiar y pegar.",
|
|
1519
|
+
{
|
|
1520
|
+
name: z7.string().describe("Nombre del request guardado en la colecci\xF3n"),
|
|
1521
|
+
resolve_variables: z7.boolean().optional().describe("Resolver {{variables}} del entorno activo (default: true)")
|
|
1522
|
+
},
|
|
1523
|
+
async (params) => {
|
|
1524
|
+
try {
|
|
1525
|
+
const saved = await storage.getCollection(params.name);
|
|
1526
|
+
if (!saved) {
|
|
1527
|
+
return {
|
|
1528
|
+
content: [
|
|
1529
|
+
{
|
|
1530
|
+
type: "text",
|
|
1531
|
+
text: `Error: Request '${params.name}' no encontrado en la colecci\xF3n.`
|
|
1532
|
+
}
|
|
1533
|
+
],
|
|
1534
|
+
isError: true
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
let config = saved.request;
|
|
1538
|
+
const resolveVars = params.resolve_variables ?? true;
|
|
1539
|
+
if (resolveVars) {
|
|
1540
|
+
const variables = await storage.getActiveVariables();
|
|
1541
|
+
let resolvedUrl = config.url;
|
|
1542
|
+
if (resolvedUrl.startsWith("/") && variables.BASE_URL) {
|
|
1543
|
+
const baseUrl = variables.BASE_URL.replace(/\/+$/, "");
|
|
1544
|
+
resolvedUrl = `${baseUrl}${resolvedUrl}`;
|
|
1545
|
+
}
|
|
1546
|
+
config = { ...config, url: resolvedUrl };
|
|
1547
|
+
config = interpolateRequest(config, variables);
|
|
1548
|
+
}
|
|
1549
|
+
const parts = ["curl"];
|
|
1550
|
+
if (config.method !== "GET") {
|
|
1551
|
+
parts.push(`-X ${config.method}`);
|
|
1552
|
+
}
|
|
1553
|
+
let url = config.url;
|
|
1554
|
+
if (config.query && Object.keys(config.query).length > 0) {
|
|
1555
|
+
const queryStr = Object.entries(config.query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
|
|
1556
|
+
url += (url.includes("?") ? "&" : "?") + queryStr;
|
|
1557
|
+
}
|
|
1558
|
+
parts.push(`'${url}'`);
|
|
1559
|
+
if (config.headers) {
|
|
1560
|
+
for (const [key, value] of Object.entries(config.headers)) {
|
|
1561
|
+
parts.push(`-H '${key}: ${value}'`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
if (config.auth) {
|
|
1565
|
+
switch (config.auth.type) {
|
|
1566
|
+
case "bearer":
|
|
1567
|
+
if (config.auth.token) {
|
|
1568
|
+
parts.push(`-H 'Authorization: Bearer ${config.auth.token}'`);
|
|
1569
|
+
}
|
|
1570
|
+
break;
|
|
1571
|
+
case "api-key":
|
|
1572
|
+
if (config.auth.key) {
|
|
1573
|
+
const header = config.auth.header ?? "X-API-Key";
|
|
1574
|
+
parts.push(`-H '${header}: ${config.auth.key}'`);
|
|
1575
|
+
}
|
|
1576
|
+
break;
|
|
1577
|
+
case "basic":
|
|
1578
|
+
if (config.auth.username && config.auth.password) {
|
|
1579
|
+
parts.push(`-u '${config.auth.username}:${config.auth.password}'`);
|
|
1580
|
+
}
|
|
1581
|
+
break;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
if (config.body !== void 0 && config.body !== null) {
|
|
1585
|
+
const bodyStr = typeof config.body === "string" ? config.body : JSON.stringify(config.body);
|
|
1586
|
+
parts.push(`-H 'Content-Type: application/json'`);
|
|
1587
|
+
parts.push(`-d '${bodyStr}'`);
|
|
1588
|
+
}
|
|
1589
|
+
const curlCommand = parts.join(" \\\n ");
|
|
1590
|
+
return {
|
|
1591
|
+
content: [
|
|
1592
|
+
{
|
|
1593
|
+
type: "text",
|
|
1594
|
+
text: `cURL para '${params.name}':
|
|
1595
|
+
|
|
1596
|
+
${curlCommand}`
|
|
1597
|
+
}
|
|
1598
|
+
]
|
|
1599
|
+
};
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1602
|
+
return {
|
|
1603
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1604
|
+
isError: true
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
);
|
|
1609
|
+
server.tool(
|
|
1610
|
+
"diff_responses",
|
|
1611
|
+
"Ejecuta dos requests y compara sus respuestas. \xDAtil para detectar regresiones o comparar entornos.",
|
|
1612
|
+
{
|
|
1613
|
+
request_a: z7.object({
|
|
1614
|
+
label: z7.string().optional().describe('Etiqueta (ej: "antes", "dev", "v1")'),
|
|
1615
|
+
method: z7.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]),
|
|
1616
|
+
url: z7.string(),
|
|
1617
|
+
headers: z7.record(z7.string()).optional(),
|
|
1618
|
+
body: z7.any().optional(),
|
|
1619
|
+
query: z7.record(z7.string()).optional(),
|
|
1620
|
+
auth: z7.object({
|
|
1621
|
+
type: z7.enum(["bearer", "api-key", "basic"]),
|
|
1622
|
+
token: z7.string().optional(),
|
|
1623
|
+
key: z7.string().optional(),
|
|
1624
|
+
header: z7.string().optional(),
|
|
1625
|
+
username: z7.string().optional(),
|
|
1626
|
+
password: z7.string().optional()
|
|
1627
|
+
}).optional()
|
|
1628
|
+
}).describe("Primer request"),
|
|
1629
|
+
request_b: z7.object({
|
|
1630
|
+
label: z7.string().optional().describe('Etiqueta (ej: "despu\xE9s", "prod", "v2")'),
|
|
1631
|
+
method: z7.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]),
|
|
1632
|
+
url: z7.string(),
|
|
1633
|
+
headers: z7.record(z7.string()).optional(),
|
|
1634
|
+
body: z7.any().optional(),
|
|
1635
|
+
query: z7.record(z7.string()).optional(),
|
|
1636
|
+
auth: z7.object({
|
|
1637
|
+
type: z7.enum(["bearer", "api-key", "basic"]),
|
|
1638
|
+
token: z7.string().optional(),
|
|
1639
|
+
key: z7.string().optional(),
|
|
1640
|
+
header: z7.string().optional(),
|
|
1641
|
+
username: z7.string().optional(),
|
|
1642
|
+
password: z7.string().optional()
|
|
1643
|
+
}).optional()
|
|
1644
|
+
}).describe("Segundo request")
|
|
1645
|
+
},
|
|
1646
|
+
async (params) => {
|
|
1647
|
+
try {
|
|
1648
|
+
const variables = await storage.getActiveVariables();
|
|
1649
|
+
const executeOne = async (req) => {
|
|
1650
|
+
let resolvedUrl = req.url;
|
|
1651
|
+
if (resolvedUrl.startsWith("/") && variables.BASE_URL) {
|
|
1652
|
+
const baseUrl = variables.BASE_URL.replace(/\/+$/, "");
|
|
1653
|
+
resolvedUrl = `${baseUrl}${resolvedUrl}`;
|
|
1654
|
+
}
|
|
1655
|
+
const config = {
|
|
1656
|
+
method: req.method,
|
|
1657
|
+
url: resolvedUrl,
|
|
1658
|
+
headers: req.headers,
|
|
1659
|
+
body: req.body,
|
|
1660
|
+
query: req.query,
|
|
1661
|
+
auth: req.auth
|
|
1662
|
+
};
|
|
1663
|
+
return executeRequest(interpolateRequest(config, variables));
|
|
1664
|
+
};
|
|
1665
|
+
const [responseA, responseB] = await Promise.all([
|
|
1666
|
+
executeOne(params.request_a),
|
|
1667
|
+
executeOne(params.request_b)
|
|
1668
|
+
]);
|
|
1669
|
+
const labelA = params.request_a.label ?? "A";
|
|
1670
|
+
const labelB = params.request_b.label ?? "B";
|
|
1671
|
+
const diffs = [];
|
|
1672
|
+
if (responseA.status !== responseB.status) {
|
|
1673
|
+
diffs.push(
|
|
1674
|
+
`Status: ${labelA}=${responseA.status} vs ${labelB}=${responseB.status}`
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
const timingDiff = Math.abs(responseA.timing.total_ms - responseB.timing.total_ms);
|
|
1678
|
+
if (timingDiff > 100) {
|
|
1679
|
+
diffs.push(
|
|
1680
|
+
`Timing: ${labelA}=${responseA.timing.total_ms}ms vs ${labelB}=${responseB.timing.total_ms}ms (\u0394${Math.round(timingDiff)}ms)`
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
const bodyA = JSON.stringify(responseA.body, null, 2);
|
|
1684
|
+
const bodyB = JSON.stringify(responseB.body, null, 2);
|
|
1685
|
+
if (bodyA !== bodyB) {
|
|
1686
|
+
diffs.push("Body: diferente");
|
|
1687
|
+
if (typeof responseA.body === "object" && typeof responseB.body === "object" && responseA.body && responseB.body) {
|
|
1688
|
+
const keysA = new Set(Object.keys(responseA.body));
|
|
1689
|
+
const keysB = new Set(Object.keys(responseB.body));
|
|
1690
|
+
const onlyInA = [...keysA].filter((k) => !keysB.has(k));
|
|
1691
|
+
const onlyInB = [...keysB].filter((k) => !keysA.has(k));
|
|
1692
|
+
const common = [...keysA].filter((k) => keysB.has(k));
|
|
1693
|
+
if (onlyInA.length > 0) diffs.push(` Solo en ${labelA}: ${onlyInA.join(", ")}`);
|
|
1694
|
+
if (onlyInB.length > 0) diffs.push(` Solo en ${labelB}: ${onlyInB.join(", ")}`);
|
|
1695
|
+
for (const key of common) {
|
|
1696
|
+
const valA = JSON.stringify(
|
|
1697
|
+
responseA.body[key]
|
|
1698
|
+
);
|
|
1699
|
+
const valB = JSON.stringify(
|
|
1700
|
+
responseB.body[key]
|
|
1701
|
+
);
|
|
1702
|
+
if (valA !== valB) {
|
|
1703
|
+
const shortA = valA.length > 50 ? valA.substring(0, 50) + "..." : valA;
|
|
1704
|
+
const shortB = valB.length > 50 ? valB.substring(0, 50) + "..." : valB;
|
|
1705
|
+
diffs.push(` ${key}: ${labelA}=${shortA} vs ${labelB}=${shortB}`);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
const sizeDiff = Math.abs(responseA.size_bytes - responseB.size_bytes);
|
|
1711
|
+
if (sizeDiff > 0) {
|
|
1712
|
+
diffs.push(
|
|
1713
|
+
`Size: ${labelA}=${responseA.size_bytes}B vs ${labelB}=${responseB.size_bytes}B`
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
const identical = diffs.length === 0;
|
|
1717
|
+
const lines = [
|
|
1718
|
+
identical ? "\u2705 ID\xC9NTICAS" : `\u26A0\uFE0F ${diffs.length} DIFERENCIAS ENCONTRADAS`,
|
|
1719
|
+
"",
|
|
1720
|
+
`${labelA}: ${params.request_a.method} ${params.request_a.url} \u2192 ${responseA.status} (${responseA.timing.total_ms}ms)`,
|
|
1721
|
+
`${labelB}: ${params.request_b.method} ${params.request_b.url} \u2192 ${responseB.status} (${responseB.timing.total_ms}ms)`
|
|
1722
|
+
];
|
|
1723
|
+
if (!identical) {
|
|
1724
|
+
lines.push("");
|
|
1725
|
+
lines.push("Diferencias:");
|
|
1726
|
+
for (const diff of diffs) {
|
|
1727
|
+
lines.push(` ${diff}`);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return {
|
|
1731
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
1732
|
+
};
|
|
1733
|
+
} catch (error) {
|
|
1734
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1735
|
+
return {
|
|
1736
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1737
|
+
isError: true
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
);
|
|
1742
|
+
server.tool(
|
|
1743
|
+
"bulk_test",
|
|
1744
|
+
"Ejecuta todos los requests guardados en la colecci\xF3n y reporta resultados. Filtrable por tag.",
|
|
1745
|
+
{
|
|
1746
|
+
tag: z7.string().optional().describe("Filtrar por tag"),
|
|
1747
|
+
expected_status: z7.number().optional().describe("Status HTTP esperado para todos (default: cualquier 2xx)")
|
|
1748
|
+
},
|
|
1749
|
+
async (params) => {
|
|
1750
|
+
try {
|
|
1751
|
+
const collections = await storage.listCollections(params.tag);
|
|
1752
|
+
if (collections.length === 0) {
|
|
1753
|
+
return {
|
|
1754
|
+
content: [
|
|
1755
|
+
{
|
|
1756
|
+
type: "text",
|
|
1757
|
+
text: params.tag ? `No hay requests guardados con tag '${params.tag}'.` : "No hay requests guardados en la colecci\xF3n."
|
|
1758
|
+
}
|
|
1759
|
+
]
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
const variables = await storage.getActiveVariables();
|
|
1763
|
+
const results = [];
|
|
1764
|
+
for (const item of collections) {
|
|
1765
|
+
const saved = await storage.getCollection(item.name);
|
|
1766
|
+
if (!saved) continue;
|
|
1767
|
+
try {
|
|
1768
|
+
let config = saved.request;
|
|
1769
|
+
let resolvedUrl = config.url;
|
|
1770
|
+
if (resolvedUrl.startsWith("/") && variables.BASE_URL) {
|
|
1771
|
+
const baseUrl = variables.BASE_URL.replace(/\/+$/, "");
|
|
1772
|
+
resolvedUrl = `${baseUrl}${resolvedUrl}`;
|
|
1773
|
+
}
|
|
1774
|
+
config = { ...config, url: resolvedUrl };
|
|
1775
|
+
const interpolated = interpolateRequest(config, variables);
|
|
1776
|
+
const response = await executeRequest(interpolated);
|
|
1777
|
+
const pass = params.expected_status ? response.status === params.expected_status : response.status >= 200 && response.status < 300;
|
|
1778
|
+
results.push({
|
|
1779
|
+
name: item.name,
|
|
1780
|
+
method: config.method,
|
|
1781
|
+
url: item.url,
|
|
1782
|
+
status: response.status,
|
|
1783
|
+
timing: response.timing.total_ms,
|
|
1784
|
+
pass
|
|
1785
|
+
});
|
|
1786
|
+
} catch (error) {
|
|
1787
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1788
|
+
results.push({
|
|
1789
|
+
name: item.name,
|
|
1790
|
+
method: item.method,
|
|
1791
|
+
url: item.url,
|
|
1792
|
+
status: 0,
|
|
1793
|
+
timing: 0,
|
|
1794
|
+
pass: false,
|
|
1795
|
+
error: message
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
const passed = results.filter((r) => r.pass).length;
|
|
1800
|
+
const failed = results.filter((r) => !r.pass).length;
|
|
1801
|
+
const totalTime = Math.round(results.reduce((sum, r) => sum + r.timing, 0) * 100) / 100;
|
|
1802
|
+
const lines = [
|
|
1803
|
+
`${failed === 0 ? "\u2705" : "\u274C"} BULK TEST \u2014 ${passed}/${results.length} passed | ${totalTime}ms total`,
|
|
1804
|
+
""
|
|
1805
|
+
];
|
|
1806
|
+
for (const r of results) {
|
|
1807
|
+
const icon = r.pass ? "\u2705" : "\u274C";
|
|
1808
|
+
if (r.error) {
|
|
1809
|
+
lines.push(`${icon} ${r.name} \u2014 ERROR: ${r.error}`);
|
|
1810
|
+
} else {
|
|
1811
|
+
lines.push(`${icon} ${r.name} \u2014 ${r.method} ${r.url} \u2192 ${r.status} (${r.timing}ms)`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
return {
|
|
1815
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
1816
|
+
isError: failed > 0
|
|
1817
|
+
};
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1820
|
+
return {
|
|
1821
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1822
|
+
isError: true
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/tools/mock.ts
|
|
1830
|
+
import { z as z8 } from "zod";
|
|
1831
|
+
function generateMockData(schema, depth = 0) {
|
|
1832
|
+
if (depth > 8) return null;
|
|
1833
|
+
if (schema.example !== void 0) return schema.example;
|
|
1834
|
+
if (schema.enum && schema.enum.length > 0) {
|
|
1835
|
+
return schema.enum[Math.floor(Math.random() * schema.enum.length)];
|
|
1836
|
+
}
|
|
1837
|
+
switch (schema.type) {
|
|
1838
|
+
case "object": {
|
|
1839
|
+
if (!schema.properties) return {};
|
|
1840
|
+
const obj = {};
|
|
1841
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
1842
|
+
obj[key] = generateMockData(propSchema, depth + 1);
|
|
1843
|
+
}
|
|
1844
|
+
return obj;
|
|
1845
|
+
}
|
|
1846
|
+
case "array": {
|
|
1847
|
+
if (!schema.items) return [];
|
|
1848
|
+
const count = Math.floor(Math.random() * 3) + 1;
|
|
1849
|
+
return Array.from(
|
|
1850
|
+
{ length: count },
|
|
1851
|
+
() => generateMockData(schema.items, depth + 1)
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
case "string": {
|
|
1855
|
+
switch (schema.format) {
|
|
1856
|
+
case "date-time":
|
|
1857
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1858
|
+
case "date":
|
|
1859
|
+
return (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1860
|
+
case "email":
|
|
1861
|
+
return `user${Math.floor(Math.random() * 1e3)}@example.com`;
|
|
1862
|
+
case "uri":
|
|
1863
|
+
case "url":
|
|
1864
|
+
return "https://example.com/resource";
|
|
1865
|
+
case "uuid":
|
|
1866
|
+
return crypto.randomUUID();
|
|
1867
|
+
case "ipv4":
|
|
1868
|
+
return `192.168.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`;
|
|
1869
|
+
default: {
|
|
1870
|
+
const desc = (schema.description ?? "").toLowerCase();
|
|
1871
|
+
if (desc.includes("name") || desc.includes("nombre"))
|
|
1872
|
+
return `Test User ${Math.floor(Math.random() * 100)}`;
|
|
1873
|
+
if (desc.includes("title") || desc.includes("t\xEDtulo"))
|
|
1874
|
+
return `Test Title ${Math.floor(Math.random() * 100)}`;
|
|
1875
|
+
if (desc.includes("description") || desc.includes("descripci\xF3n"))
|
|
1876
|
+
return "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
|
|
1877
|
+
if (desc.includes("password") || desc.includes("contrase\xF1a"))
|
|
1878
|
+
return "TestPass123!";
|
|
1879
|
+
if (desc.includes("slug"))
|
|
1880
|
+
return `test-slug-${Math.floor(Math.random() * 1e3)}`;
|
|
1881
|
+
if (desc.includes("phone") || desc.includes("tel\xE9fono"))
|
|
1882
|
+
return "+34612345678";
|
|
1883
|
+
return `mock-string-${Math.floor(Math.random() * 1e4)}`;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
case "number":
|
|
1888
|
+
case "integer":
|
|
1889
|
+
return Math.floor(Math.random() * 1e3);
|
|
1890
|
+
case "boolean":
|
|
1891
|
+
return Math.random() > 0.5;
|
|
1892
|
+
default:
|
|
1893
|
+
return null;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
function registerMockTool(server, storage) {
|
|
1897
|
+
server.tool(
|
|
1898
|
+
"mock",
|
|
1899
|
+
"Genera datos mock/fake para un endpoint bas\xE1ndose en su spec OpenAPI importada. \xDAtil para frontend sin backend.",
|
|
1900
|
+
{
|
|
1901
|
+
name: z8.string().describe("Nombre del API importada"),
|
|
1902
|
+
method: z8.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).describe("M\xE9todo HTTP del endpoint"),
|
|
1903
|
+
path: z8.string().describe('Path del endpoint (ej: "/users", "/blog")'),
|
|
1904
|
+
target: z8.enum(["request", "response"]).optional().describe("Generar mock del body de request o de la response (default: response)"),
|
|
1905
|
+
status: z8.string().optional().describe('Status code de la respuesta a mockear (default: "200" o "201")'),
|
|
1906
|
+
count: z8.number().optional().describe("N\xFAmero de items mock a generar si el schema es un array (default: 3)")
|
|
1907
|
+
},
|
|
1908
|
+
async (params) => {
|
|
1909
|
+
try {
|
|
1910
|
+
const spec = await storage.getSpec(params.name);
|
|
1911
|
+
if (!spec) {
|
|
1912
|
+
return {
|
|
1913
|
+
content: [
|
|
1914
|
+
{
|
|
1915
|
+
type: "text",
|
|
1916
|
+
text: `Error: API '${params.name}' no encontrada. Usa api_import para importarla primero.`
|
|
1917
|
+
}
|
|
1918
|
+
],
|
|
1919
|
+
isError: true
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
const endpoint = spec.endpoints.find(
|
|
1923
|
+
(ep) => ep.method === params.method && ep.path === params.path
|
|
1924
|
+
);
|
|
1925
|
+
if (!endpoint) {
|
|
1926
|
+
return {
|
|
1927
|
+
content: [
|
|
1928
|
+
{
|
|
1929
|
+
type: "text",
|
|
1930
|
+
text: `Error: Endpoint ${params.method} ${params.path} no encontrado en '${params.name}'.`
|
|
1931
|
+
}
|
|
1932
|
+
],
|
|
1933
|
+
isError: true
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
const target = params.target ?? "response";
|
|
1937
|
+
let schema;
|
|
1938
|
+
if (target === "request") {
|
|
1939
|
+
const content = endpoint.requestBody?.content;
|
|
1940
|
+
if (content) {
|
|
1941
|
+
const jsonContent = content["application/json"];
|
|
1942
|
+
schema = jsonContent?.schema;
|
|
1943
|
+
}
|
|
1944
|
+
if (!schema) {
|
|
1945
|
+
return {
|
|
1946
|
+
content: [
|
|
1947
|
+
{
|
|
1948
|
+
type: "text",
|
|
1949
|
+
text: `Error: El endpoint ${params.method} ${params.path} no tiene un body schema definido.`
|
|
1950
|
+
}
|
|
1951
|
+
],
|
|
1952
|
+
isError: true
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
} else {
|
|
1956
|
+
const statusCode = params.status ?? (params.method === "POST" ? "201" : "200");
|
|
1957
|
+
const response = endpoint.responses?.[statusCode];
|
|
1958
|
+
if (!response) {
|
|
1959
|
+
const twoXX = Object.keys(endpoint.responses ?? {}).find((s) => s.startsWith("2"));
|
|
1960
|
+
if (twoXX && endpoint.responses) {
|
|
1961
|
+
const fallbackResp = endpoint.responses[twoXX];
|
|
1962
|
+
const content = fallbackResp?.content;
|
|
1963
|
+
if (content) {
|
|
1964
|
+
const jsonContent = content["application/json"];
|
|
1965
|
+
schema = jsonContent?.schema;
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
} else {
|
|
1969
|
+
const content = response.content;
|
|
1970
|
+
if (content) {
|
|
1971
|
+
const jsonContent = content["application/json"];
|
|
1972
|
+
schema = jsonContent?.schema;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
if (!schema) {
|
|
1976
|
+
return {
|
|
1977
|
+
content: [
|
|
1978
|
+
{
|
|
1979
|
+
type: "text",
|
|
1980
|
+
text: `Error: No se encontr\xF3 un schema de respuesta para ${params.method} ${params.path}.`
|
|
1981
|
+
}
|
|
1982
|
+
],
|
|
1983
|
+
isError: true
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
let mockData;
|
|
1988
|
+
if (schema.type === "array" && params.count) {
|
|
1989
|
+
mockData = Array.from(
|
|
1990
|
+
{ length: params.count },
|
|
1991
|
+
() => generateMockData(schema.items ?? { type: "object" })
|
|
1992
|
+
);
|
|
1993
|
+
} else {
|
|
1994
|
+
mockData = generateMockData(schema);
|
|
1995
|
+
}
|
|
1996
|
+
const label = target === "request" ? "REQUEST BODY" : "RESPONSE";
|
|
1997
|
+
return {
|
|
1998
|
+
content: [
|
|
1999
|
+
{
|
|
2000
|
+
type: "text",
|
|
2001
|
+
text: [
|
|
2002
|
+
`Mock ${label} para ${params.method} ${params.path}:`,
|
|
2003
|
+
"",
|
|
2004
|
+
"```json",
|
|
2005
|
+
JSON.stringify(mockData, null, 2),
|
|
2006
|
+
"```",
|
|
2007
|
+
"",
|
|
2008
|
+
"Datos generados autom\xE1ticamente desde el spec OpenAPI.",
|
|
2009
|
+
target === "request" ? "Puedes usar estos datos directamente en un request." : "Estos son datos de ejemplo que devolver\xEDa el endpoint."
|
|
2010
|
+
].join("\n")
|
|
2011
|
+
}
|
|
2012
|
+
]
|
|
2013
|
+
};
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2016
|
+
return {
|
|
2017
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2018
|
+
isError: true
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// src/tools/load-test.ts
|
|
2026
|
+
import { z as z9 } from "zod";
|
|
2027
|
+
function registerLoadTestTool(server, storage) {
|
|
2028
|
+
server.tool(
|
|
2029
|
+
"load_test",
|
|
2030
|
+
"Lanza N requests concurrentes al mismo endpoint y mide tiempos promedio, percentiles y tasa de errores.",
|
|
2031
|
+
{
|
|
2032
|
+
method: z9.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]).describe("HTTP method"),
|
|
2033
|
+
url: z9.string().describe("URL del endpoint"),
|
|
2034
|
+
headers: z9.record(z9.string()).optional().describe("Headers HTTP"),
|
|
2035
|
+
body: z9.any().optional().describe("Body del request"),
|
|
2036
|
+
query: z9.record(z9.string()).optional().describe("Query parameters"),
|
|
2037
|
+
auth: z9.object({
|
|
2038
|
+
type: z9.enum(["bearer", "api-key", "basic"]),
|
|
2039
|
+
token: z9.string().optional(),
|
|
2040
|
+
key: z9.string().optional(),
|
|
2041
|
+
header: z9.string().optional(),
|
|
2042
|
+
username: z9.string().optional(),
|
|
2043
|
+
password: z9.string().optional()
|
|
2044
|
+
}).optional().describe("Autenticaci\xF3n"),
|
|
2045
|
+
concurrent: z9.number().describe("N\xFAmero de requests concurrentes a lanzar (max: 100)"),
|
|
2046
|
+
timeout: z9.number().optional().describe("Timeout por request en ms (default: 30000)")
|
|
2047
|
+
},
|
|
2048
|
+
async (params) => {
|
|
2049
|
+
try {
|
|
2050
|
+
const concurrentCount = Math.min(Math.max(params.concurrent, 1), 100);
|
|
2051
|
+
const variables = await storage.getActiveVariables();
|
|
2052
|
+
let resolvedUrl = params.url;
|
|
2053
|
+
if (resolvedUrl.startsWith("/") && variables.BASE_URL) {
|
|
2054
|
+
const baseUrl = variables.BASE_URL.replace(/\/+$/, "");
|
|
2055
|
+
resolvedUrl = `${baseUrl}${resolvedUrl}`;
|
|
2056
|
+
}
|
|
2057
|
+
const baseConfig = {
|
|
2058
|
+
method: params.method,
|
|
2059
|
+
url: resolvedUrl,
|
|
2060
|
+
headers: params.headers,
|
|
2061
|
+
body: params.body,
|
|
2062
|
+
query: params.query,
|
|
2063
|
+
auth: params.auth,
|
|
2064
|
+
timeout: params.timeout
|
|
2065
|
+
};
|
|
2066
|
+
const interpolated = interpolateRequest(baseConfig, variables);
|
|
2067
|
+
const startTotal = performance.now();
|
|
2068
|
+
const promises = Array.from(
|
|
2069
|
+
{ length: concurrentCount },
|
|
2070
|
+
() => executeRequest(interpolated).then((response) => ({
|
|
2071
|
+
status: response.status,
|
|
2072
|
+
timing: response.timing.total_ms,
|
|
2073
|
+
error: void 0
|
|
2074
|
+
})).catch((error) => ({
|
|
2075
|
+
status: 0,
|
|
2076
|
+
timing: 0,
|
|
2077
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2078
|
+
}))
|
|
2079
|
+
);
|
|
2080
|
+
const results = await Promise.all(promises);
|
|
2081
|
+
const endTotal = performance.now();
|
|
2082
|
+
const wallTime = Math.round((endTotal - startTotal) * 100) / 100;
|
|
2083
|
+
const successful = results.filter((r) => !r.error);
|
|
2084
|
+
const failed = results.filter((r) => r.error);
|
|
2085
|
+
const timings = successful.map((r) => r.timing).sort((a, b) => a - b);
|
|
2086
|
+
const statusCounts = {};
|
|
2087
|
+
for (const r of successful) {
|
|
2088
|
+
statusCounts[r.status] = (statusCounts[r.status] ?? 0) + 1;
|
|
2089
|
+
}
|
|
2090
|
+
const avg = timings.length > 0 ? Math.round(timings.reduce((s, t) => s + t, 0) / timings.length * 100) / 100 : 0;
|
|
2091
|
+
const min = timings.length > 0 ? timings[0] : 0;
|
|
2092
|
+
const max = timings.length > 0 ? timings[timings.length - 1] : 0;
|
|
2093
|
+
const p50 = timings.length > 0 ? timings[Math.floor(timings.length * 0.5)] : 0;
|
|
2094
|
+
const p95 = timings.length > 0 ? timings[Math.floor(timings.length * 0.95)] : 0;
|
|
2095
|
+
const p99 = timings.length > 0 ? timings[Math.floor(timings.length * 0.99)] : 0;
|
|
2096
|
+
const rps = wallTime > 0 ? Math.round(successful.length / (wallTime / 1e3) * 100) / 100 : 0;
|
|
2097
|
+
const lines = [
|
|
2098
|
+
`\u{1F4CA} LOAD TEST \u2014 ${params.method} ${params.url}`,
|
|
2099
|
+
"",
|
|
2100
|
+
`Requests: ${concurrentCount} concurrentes`,
|
|
2101
|
+
`Exitosos: ${successful.length} | Fallidos: ${failed.length}`,
|
|
2102
|
+
`Tiempo total: ${wallTime}ms`,
|
|
2103
|
+
`Requests/segundo: ${rps}`,
|
|
2104
|
+
"",
|
|
2105
|
+
"\u23F1\uFE0F Tiempos de respuesta:",
|
|
2106
|
+
` Min: ${min}ms`,
|
|
2107
|
+
` Avg: ${avg}ms`,
|
|
2108
|
+
` p50: ${p50}ms`,
|
|
2109
|
+
` p95: ${p95}ms`,
|
|
2110
|
+
` p99: ${p99}ms`,
|
|
2111
|
+
` Max: ${max}ms`
|
|
2112
|
+
];
|
|
2113
|
+
if (Object.keys(statusCounts).length > 0) {
|
|
2114
|
+
lines.push("");
|
|
2115
|
+
lines.push("\u{1F4CB} Status codes:");
|
|
2116
|
+
for (const [status, count] of Object.entries(statusCounts)) {
|
|
2117
|
+
const pct = Math.round(count / concurrentCount * 100);
|
|
2118
|
+
lines.push(` ${status}: ${count} (${pct}%)`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
if (failed.length > 0) {
|
|
2122
|
+
lines.push("");
|
|
2123
|
+
lines.push("\u274C Errores:");
|
|
2124
|
+
const errorCounts = {};
|
|
2125
|
+
for (const r of failed) {
|
|
2126
|
+
const errMsg = r.error ?? "Unknown";
|
|
2127
|
+
errorCounts[errMsg] = (errorCounts[errMsg] ?? 0) + 1;
|
|
2128
|
+
}
|
|
2129
|
+
for (const [err, count] of Object.entries(errorCounts)) {
|
|
2130
|
+
lines.push(` ${err}: ${count}x`);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return {
|
|
2134
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
2135
|
+
isError: failed.length > successful.length
|
|
2136
|
+
// More than 50% failed
|
|
2137
|
+
};
|
|
2138
|
+
} catch (error) {
|
|
2139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2140
|
+
return {
|
|
2141
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2142
|
+
isError: true
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
1217
2149
|
// src/server.ts
|
|
1218
|
-
var VERSION = "0.
|
|
2150
|
+
var VERSION = "0.4.0";
|
|
1219
2151
|
function createServer(storageDir) {
|
|
1220
2152
|
const server = new McpServer({
|
|
1221
2153
|
name: "api-testing-mcp",
|
|
@@ -1226,6 +2158,11 @@ function createServer(storageDir) {
|
|
|
1226
2158
|
registerCollectionTools(server, storage);
|
|
1227
2159
|
registerEnvironmentTools(server, storage);
|
|
1228
2160
|
registerApiSpecTools(server, storage);
|
|
2161
|
+
registerAssertTool(server, storage);
|
|
2162
|
+
registerFlowTool(server, storage);
|
|
2163
|
+
registerUtilityTools(server, storage);
|
|
2164
|
+
registerMockTool(server, storage);
|
|
2165
|
+
registerLoadTestTool(server, storage);
|
|
1229
2166
|
return server;
|
|
1230
2167
|
}
|
|
1231
2168
|
|