@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/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.3.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