callman-mcp 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +36 -59
  2. package/README.md +18 -24
  3. package/dist/client/api.d.ts +18 -40
  4. package/dist/client/api.d.ts.map +1 -1
  5. package/dist/client/api.js +7 -54
  6. package/dist/client/api.js.map +1 -1
  7. package/dist/client/types.d.ts +19 -44
  8. package/dist/client/types.d.ts.map +1 -1
  9. package/dist/config.d.ts +4 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +5 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/domain/diagram/build.d.ts +17 -17
  14. package/dist/domain/diagram/build.d.ts.map +1 -1
  15. package/dist/domain/diagram/build.js +3 -3
  16. package/dist/domain/diagram/build.js.map +1 -1
  17. package/dist/domain/diagram/layout.d.ts +1 -3
  18. package/dist/domain/diagram/layout.d.ts.map +1 -1
  19. package/dist/domain/diagram/layout.js +3 -9
  20. package/dist/domain/diagram/layout.js.map +1 -1
  21. package/dist/domain/diagram/patch.d.ts +22 -723
  22. package/dist/domain/diagram/patch.d.ts.map +1 -1
  23. package/dist/domain/diagram/patch.js +0 -53
  24. package/dist/domain/diagram/patch.js.map +1 -1
  25. package/dist/domain/diagram/summarize.d.ts +0 -1
  26. package/dist/domain/diagram/summarize.d.ts.map +1 -1
  27. package/dist/domain/diagram/summarize.js +0 -1
  28. package/dist/domain/diagram/summarize.js.map +1 -1
  29. package/dist/domain/requestIntegrations.d.ts +13 -468
  30. package/dist/domain/requestIntegrations.d.ts.map +1 -1
  31. package/dist/domain/requestIntegrations.js +4 -95
  32. package/dist/domain/requestIntegrations.js.map +1 -1
  33. package/dist/domain/requestShape.d.ts +37 -679
  34. package/dist/domain/requestShape.d.ts.map +1 -1
  35. package/dist/domain/requestShape.js +2 -2
  36. package/dist/domain/requestShape.js.map +1 -1
  37. package/dist/domain/scenario/build.d.ts +6 -6
  38. package/dist/domain/scenario/build.d.ts.map +1 -1
  39. package/dist/domain/scenario/build.js +17 -68
  40. package/dist/domain/scenario/build.js.map +1 -1
  41. package/dist/domain/scenario/nodeConfig.d.ts.map +1 -1
  42. package/dist/domain/scenario/nodeConfig.js +16 -0
  43. package/dist/domain/scenario/nodeConfig.js.map +1 -1
  44. package/dist/domain/scenario/summarize.d.ts +0 -1
  45. package/dist/domain/scenario/summarize.d.ts.map +1 -1
  46. package/dist/domain/scenario/summarize.js +6 -1
  47. package/dist/domain/scenario/summarize.js.map +1 -1
  48. package/dist/index.js +16 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/resources/docs/collectionScripts.d.ts +2 -0
  51. package/dist/resources/docs/collectionScripts.d.ts.map +1 -0
  52. package/dist/resources/docs/collectionScripts.js +51 -0
  53. package/dist/resources/docs/collectionScripts.js.map +1 -0
  54. package/dist/resources/docs/diagramSchema.d.ts +1 -2
  55. package/dist/resources/docs/diagramSchema.d.ts.map +1 -1
  56. package/dist/resources/docs/diagramSchema.js +4 -63
  57. package/dist/resources/docs/diagramSchema.js.map +1 -1
  58. package/dist/resources/docs/overview.d.ts +1 -1
  59. package/dist/resources/docs/overview.d.ts.map +1 -1
  60. package/dist/resources/docs/overview.js +13 -34
  61. package/dist/resources/docs/overview.js.map +1 -1
  62. package/dist/resources/docs/requestSchema.d.ts +1 -1
  63. package/dist/resources/docs/requestSchema.d.ts.map +1 -1
  64. package/dist/resources/docs/requestSchema.js +11 -16
  65. package/dist/resources/docs/requestSchema.js.map +1 -1
  66. package/dist/resources/generated/aiPacks.d.ts +1 -1
  67. package/dist/resources/generated/aiPacks.js +6 -6
  68. package/dist/resources/generated/aiPacks.js.map +1 -1
  69. package/dist/resources/index.d.ts.map +1 -1
  70. package/dist/resources/index.js +5 -3
  71. package/dist/resources/index.js.map +1 -1
  72. package/dist/run/executeCollection.d.ts.map +1 -1
  73. package/dist/run/executeCollection.js +19 -3
  74. package/dist/run/executeCollection.js.map +1 -1
  75. package/dist/run/loadBundle.d.ts.map +1 -1
  76. package/dist/run/loadBundle.js +28 -3
  77. package/dist/run/loadBundle.js.map +1 -1
  78. package/dist/run/summarize.d.ts +4 -4
  79. package/dist/run/types.d.ts +5 -1
  80. package/dist/run/types.d.ts.map +1 -1
  81. package/dist/server.d.ts +1 -1
  82. package/dist/server.d.ts.map +1 -1
  83. package/dist/server.js +2 -2
  84. package/dist/server.js.map +1 -1
  85. package/dist/tools/catalog.d.ts.map +1 -1
  86. package/dist/tools/catalog.js +3 -11
  87. package/dist/tools/catalog.js.map +1 -1
  88. package/dist/tools/collections.d.ts.map +1 -1
  89. package/dist/tools/collections.js +137 -16
  90. package/dist/tools/collections.js.map +1 -1
  91. package/dist/tools/contracts.js +2 -2
  92. package/dist/tools/contracts.js.map +1 -1
  93. package/dist/tools/diagrams.d.ts.map +1 -1
  94. package/dist/tools/diagrams.js +42 -331
  95. package/dist/tools/diagrams.js.map +1 -1
  96. package/dist/tools/index.d.ts.map +1 -1
  97. package/dist/tools/index.js +4 -3
  98. package/dist/tools/index.js.map +1 -1
  99. package/dist/tools/loadTest.d.ts +4 -0
  100. package/dist/tools/loadTest.d.ts.map +1 -0
  101. package/dist/tools/loadTest.js +299 -0
  102. package/dist/tools/loadTest.js.map +1 -0
  103. package/dist/tools/lookups.d.ts +1 -5
  104. package/dist/tools/lookups.d.ts.map +1 -1
  105. package/dist/tools/lookups.js +0 -41
  106. package/dist/tools/lookups.js.map +1 -1
  107. package/dist/tools/requests.js +3 -3
  108. package/dist/tools/requests.js.map +1 -1
  109. package/dist/tools/run.d.ts +18 -0
  110. package/dist/tools/run.d.ts.map +1 -1
  111. package/dist/tools/run.js +14 -5
  112. package/dist/tools/run.js.map +1 -1
  113. package/dist/tools/scenarios.d.ts +0 -4
  114. package/dist/tools/scenarios.d.ts.map +1 -1
  115. package/dist/tools/scenarios.js +17 -112
  116. package/dist/tools/scenarios.js.map +1 -1
  117. package/dist-cjs/client/api.js +7 -54
  118. package/dist-cjs/config.js +5 -0
  119. package/dist-cjs/domain/diagram/build.js +3 -3
  120. package/dist-cjs/domain/diagram/layout.js +3 -10
  121. package/dist-cjs/domain/diagram/patch.js +1 -54
  122. package/dist-cjs/domain/diagram/summarize.js +0 -1
  123. package/dist-cjs/domain/requestIntegrations.js +4 -95
  124. package/dist-cjs/domain/requestShape.js +2 -2
  125. package/dist-cjs/domain/scenario/build.js +17 -68
  126. package/dist-cjs/domain/scenario/nodeConfig.js +16 -0
  127. package/dist-cjs/domain/scenario/summarize.js +6 -1
  128. package/dist-cjs/resources/docs/collectionScripts.js +53 -0
  129. package/dist-cjs/resources/docs/diagramSchema.js +5 -65
  130. package/dist-cjs/resources/docs/overview.js +13 -34
  131. package/dist-cjs/resources/docs/requestSchema.js +11 -16
  132. package/dist-cjs/resources/generated/aiPacks.js +6 -6
  133. package/dist-cjs/resources/index.js +4 -2
  134. package/dist-cjs/run/executeCollection.js +18 -2
  135. package/dist-cjs/run/loadBundle.js +28 -3
  136. package/dist-cjs/server.js +2 -2
  137. package/dist-cjs/tools/catalog.js +3 -11
  138. package/dist-cjs/tools/collections.js +137 -16
  139. package/dist-cjs/tools/contracts.js +2 -2
  140. package/dist-cjs/tools/diagrams.js +37 -326
  141. package/dist-cjs/tools/index.js +4 -3
  142. package/dist-cjs/tools/loadTest.js +335 -0
  143. package/dist-cjs/tools/lookups.js +1 -46
  144. package/dist-cjs/tools/requests.js +3 -3
  145. package/dist-cjs/tools/run.js +17 -7
  146. package/dist-cjs/tools/scenarios.js +18 -114
  147. package/package.json +3 -3
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.registerLoadTestTool = void 0;
37
+ const zod_1 = require("zod");
38
+ const callman_core_1 = require("callman-core");
39
+ const errors_js_1 = require("../client/errors.js");
40
+ const redact_js_1 = require("../lib/redact.js");
41
+ const resolveTemplate_js_1 = require("../run/resolveTemplate.js");
42
+ const run_js_1 = require("./run.js");
43
+ const lookups_js_1 = require("./lookups.js");
44
+ const shared_js_1 = require("./shared.js");
45
+ // Load / stress testing from an AI tool. Same engine as the desktop's Load test
46
+ // tab and `callme perf run` (callman-core/loadtest), so the numbers agree.
47
+ //
48
+ // Generating load is the one thing here that can hurt a third party, so it is
49
+ // two steps: without `confirm: true` the tool only PREVIEWS (which hosts, which
50
+ // requests, how much traffic, hard limits); the run starts on the second call.
51
+ // Hard caps (users, rate, duration) come from the server config, because a
52
+ // blocking tool call also has to finish inside the client's timeout.
53
+ const PRESETS = ["smoke", "load", "stress", "spike", "breakpoint"];
54
+ const rampable = (executor, rampMs) => {
55
+ if (rampMs <= 0)
56
+ return executor;
57
+ if (executor.type === "constant-vus") {
58
+ const ramp = Math.min(rampMs, Math.max(1000, executor.durationMs - 1000));
59
+ return {
60
+ type: "ramping-vus",
61
+ startVus: 0,
62
+ stages: [
63
+ { durationMs: ramp, target: executor.vus },
64
+ { durationMs: executor.durationMs - ramp, target: executor.vus },
65
+ ],
66
+ };
67
+ }
68
+ if (executor.type === "constant-arrival-rate") {
69
+ const ramp = Math.min(rampMs, Math.max(1000, executor.durationMs - 1000));
70
+ return {
71
+ type: "ramping-arrival-rate",
72
+ startRate: 1,
73
+ stages: [
74
+ { durationMs: ramp, target: executor.rate },
75
+ { durationMs: executor.durationMs - ramp, target: executor.rate },
76
+ ],
77
+ ...(executor.maxVus ? { maxVus: executor.maxVus } : {}),
78
+ };
79
+ }
80
+ return executor;
81
+ };
82
+ const round = (value, digits = 2) => Math.round(value * 10 ** digits) / 10 ** digits;
83
+ const summarize = (result, redact) => {
84
+ const summary = result.summary;
85
+ const failedRate = summary.totalRequests === 0 ? 0 : summary.failedCount / summary.totalRequests;
86
+ const verdict = result.abortedByThreshold
87
+ ? "aborted"
88
+ : result.thresholds.some((entry) => !entry.passed)
89
+ ? "failed"
90
+ : result.stoppedByUser
91
+ ? "stopped"
92
+ : "passed";
93
+ return {
94
+ verdict,
95
+ passed: verdict === "passed",
96
+ durationSeconds: round(summary.durationMs / 1000, 1),
97
+ requests: summary.totalRequests,
98
+ iterations: summary.totalIterations,
99
+ droppedIterations: summary.droppedIterations,
100
+ throughputPerSecond: { average: round(summary.avgRps, 1), peak: round(summary.peakRps, 1) },
101
+ failedRate: round(failedRate, 4),
102
+ latencyMs: {
103
+ average: round(result.duration.avg),
104
+ median: round(result.duration.p50),
105
+ p90: round(result.duration.p90),
106
+ p95: round(result.duration.p95),
107
+ p99: round(result.duration.p99),
108
+ max: round(result.duration.max),
109
+ },
110
+ ...(result.correctedDuration
111
+ ? { latencyFromIntendedStartMs: { p95: round(result.correctedDuration.p95), p99: round(result.correctedDuration.p99) } }
112
+ : {}),
113
+ peakConcurrency: summary.peakVus,
114
+ checks: summary.checksPassed + summary.checksFailed > 0 ? { passed: summary.checksPassed, failed: summary.checksFailed } : undefined,
115
+ steps: result.steps.map((step) => ({
116
+ name: step.name,
117
+ requests: step.requests,
118
+ failedRate: round(step.failedRate, 4),
119
+ p95Ms: round(step.duration.p95),
120
+ throughputPerSecond: round(step.rps, 1),
121
+ })),
122
+ thresholds: result.thresholds.map((entry) => ({
123
+ expression: entry.threshold.expression,
124
+ passed: entry.passed,
125
+ actual: entry.actual === null ? null : round(entry.actual, 3),
126
+ ...(entry.aborted ? { stoppedTheRun: true } : {}),
127
+ })),
128
+ errors: result.errors.slice(0, 5).map((error) => ({
129
+ kind: error.errorClass,
130
+ causedBy: error.blame === "generator" ? "the load generator (this machine)" : error.blame === "server" ? "the target or the network" : error.blame === "http" ? "an HTTP error status" : "the request setup",
131
+ count: error.count,
132
+ example: redact(error.example),
133
+ })),
134
+ loadGenerator: {
135
+ workers: result.generator.workers,
136
+ saturated: result.generator.saturated,
137
+ maxEventLoopLagMs: result.generator.maxLoopLagMs,
138
+ ...(result.generator.saturated
139
+ ? { warning: "The machine generating the load was saturated, so latencies are likely overstated. Lower the load." }
140
+ : {}),
141
+ },
142
+ };
143
+ };
144
+ const registerLoadTestTool = (server, ctx) => {
145
+ const { loadTestMaxSeconds, loadTestMaxVus, loadTestMaxRate } = ctx.config;
146
+ server.registerTool("run_load_test", {
147
+ title: "Load / stress test a collection",
148
+ description: `Generate load against the requests of a collection from THIS machine and judge it against pass criteria. Same engine as the desktop's Load test and \`callme perf run\`. The requests of the collection (or one folder, or the ones named in \`only\`) run in order as one flow per iteration, with the environment's variables.\n` +
149
+ `Two steps: without \`confirm: true\` it only PREVIEWS (target hosts, requests, planned traffic, limits) — show that to the user, then call again with confirm: true. Only test systems the user owns or may load-test.\n` +
150
+ `Pattern: \`mode: "vus"\` (a closed crowd of virtual users looping the flow; slow servers slow them down) or \`mode: "rate"\` (an open model: iterations START at \`rate\` per second whatever the server does; iterations that cannot start are reported as dropped). \`rampUpSeconds\` ramps to the level first. Or pick a \`preset\` (smoke, load, stress, spike, breakpoint) tuned by vus / rate / durationSeconds.\n` +
151
+ `Pass criteria: \`thresholds\` like "p95<500", "fail<1%", "checks>99%", "rps>100" or "http_req_duration{step:Login}:p(95)<300"; \`abortOn\` takes the same but stops the run when one breaks (use it to find the breaking point).\n` +
152
+ `Returns the verdict, throughput, latency percentiles, failures by cause (and whether the load generator or the target is to blame), per-step numbers and threshold results. Scripts, DB and Redis steps are not run. Limits here: ${loadTestMaxVus} users, ${loadTestMaxRate} iterations/s, ${loadTestMaxSeconds} s per run. Needs requests:run.`,
153
+ inputSchema: {
154
+ workspace: shared_js_1.workspaceArg,
155
+ collection: (0, shared_js_1.refArg)("Collection"),
156
+ folder: zod_1.z.string().max(300).optional().describe("Only this folder (id, name or path), subfolders included"),
157
+ only: zod_1.z.array(zod_1.z.string().min(1).max(500)).max(50).optional().describe("Only these requests (ids or names), in this order"),
158
+ environment: zod_1.z.string().max(200).optional(),
159
+ mode: zod_1.z.enum(["vus", "rate"]).optional().describe('"vus" (default) = virtual users; "rate" = iterations per second'),
160
+ vus: zod_1.z.number().int().min(1).optional().describe(`Virtual users (max ${loadTestMaxVus}); the preset's intensity in vus mode`),
161
+ rate: zod_1.z.number().int().min(1).optional().describe(`Iterations per second (max ${loadTestMaxRate}) for mode "rate"`),
162
+ maxVus: zod_1.z.number().int().min(1).optional().describe("Rate mode: most iterations in flight before new ones are dropped"),
163
+ durationSeconds: zod_1.z.number().int().min(5).optional().describe(`How long (5–${loadTestMaxSeconds}; default 30)`),
164
+ rampUpSeconds: zod_1.z.number().int().min(0).optional().describe("Ramp to the level over this many seconds first"),
165
+ preset: zod_1.z.enum(PRESETS).optional().describe("A ready-made pattern; vus / rate / durationSeconds tune it"),
166
+ thresholds: zod_1.z.array(zod_1.z.string().min(1).max(200)).max(20).optional().describe('Pass criteria, e.g. ["p95<500", "fail<1%"]'),
167
+ abortOn: zod_1.z.array(zod_1.z.string().min(1).max(200)).max(10).optional().describe("Criteria that stop the run when violated"),
168
+ thinkTimeMinMs: zod_1.z.number().int().min(0).max(60_000).optional(),
169
+ thinkTimeMaxMs: zod_1.z.number().int().min(0).max(60_000).optional(),
170
+ confirm: zod_1.z.boolean().optional().describe("true = actually generate the load. Omit for the preview."),
171
+ },
172
+ annotations: shared_js_1.RUN,
173
+ }, (0, shared_js_1.guarded)(async (args) => {
174
+ const { ws, value: resolved } = await (0, lookups_js_1.findCollection)(ctx, args.workspace, args.collection);
175
+ const prepared = await (0, run_js_1.prepareRun)(ctx, ws.id, resolved.item.id, args.environment);
176
+ // ── Which requests ─────────────────────────────────────────────────
177
+ let folderIds = null;
178
+ if (args.folder) {
179
+ const target = await (0, lookups_js_1.resolveFolder)(ctx, ws.id, resolved.item.id, args.folder);
180
+ if (!target)
181
+ throw new errors_js_1.ToolInputError("REF_REQUIRED", "folder is required.");
182
+ folderIds = (0, lookups_js_1.requestIdsInFolder)(await (0, lookups_js_1.loadTree)(ctx, ws.id, resolved.item.id), target.item.id);
183
+ }
184
+ let onlyIds = null;
185
+ if (args.only && args.only.length > 0) {
186
+ onlyIds = [];
187
+ for (const ref of args.only)
188
+ onlyIds.push((await (0, lookups_js_1.resolveRequest)(ctx, ws.id, ref, resolved.item.id)).request.id);
189
+ }
190
+ const byId = new Map(prepared.collection.requests.map((request) => [request.id, request]));
191
+ const chosen = (onlyIds
192
+ ? onlyIds.filter((id) => !folderIds || folderIds.includes(id)).map((id) => byId.get(id))
193
+ : prepared.collection.requests.filter((request) => request.isEnabledInRunner !== false && (!folderIds || folderIds.includes(request.id)))).filter((request) => Boolean(request));
194
+ if (chosen.length === 0)
195
+ throw new errors_js_1.ToolInputError("NOTHING_TO_RUN", "The selection contains no requests.");
196
+ // ── Pattern ────────────────────────────────────────────────────────
197
+ const durationSeconds = args.durationSeconds ?? 30;
198
+ if (durationSeconds > loadTestMaxSeconds) {
199
+ throw new errors_js_1.ToolInputError("LIMIT_EXCEEDED", `durationSeconds ${durationSeconds} exceeds this server's limit of ${loadTestMaxSeconds} s (CALLMAN_LOADTEST_MAX_SECONDS). Use the desktop app or \`callme perf run\` for longer tests.`);
200
+ }
201
+ const mode = args.mode ?? (args.rate && !args.vus ? "rate" : "vus");
202
+ const vus = args.vus ?? (args.preset ? 20 : 10);
203
+ const rate = args.rate ?? 50;
204
+ if (vus > loadTestMaxVus)
205
+ throw new errors_js_1.ToolInputError("LIMIT_EXCEEDED", `vus ${vus} exceeds this server's limit of ${loadTestMaxVus} (CALLMAN_LOADTEST_MAX_VUS).`);
206
+ if (rate > loadTestMaxRate)
207
+ throw new errors_js_1.ToolInputError("LIMIT_EXCEEDED", `rate ${rate} exceeds this server's limit of ${loadTestMaxRate}/s (CALLMAN_LOADTEST_MAX_RATE).`);
208
+ let executor;
209
+ if (args.preset) {
210
+ executor = (0, callman_core_1.buildPresetExecutor)(args.preset, { vus, rate, durationMs: durationSeconds * 1000 });
211
+ }
212
+ else if (mode === "rate") {
213
+ executor = rampable({ type: "constant-arrival-rate", rate, durationMs: durationSeconds * 1000, maxVus: args.maxVus ?? Math.min(Math.max(rate, 50), loadTestMaxVus * 5) }, (args.rampUpSeconds ?? 0) * 1000);
214
+ }
215
+ else {
216
+ executor = rampable({ type: "constant-vus", vus, durationMs: durationSeconds * 1000 }, (args.rampUpSeconds ?? 0) * 1000);
217
+ }
218
+ // The preset can climb past the caller's number (stress ×2, spike ×3,
219
+ // breakpoint ×10): hold it to the configured ceiling too.
220
+ const peak = executor.type === "constant-vus" ? executor.vus
221
+ : executor.type === "ramping-vus" ? Math.max(executor.startVus, ...executor.stages.map((stage) => stage.target))
222
+ : executor.type === "constant-arrival-rate" ? executor.rate
223
+ : Math.max(executor.startRate, ...executor.stages.map((stage) => stage.target));
224
+ const peakLimit = executor.type === "constant-vus" || executor.type === "ramping-vus" ? loadTestMaxVus : loadTestMaxRate;
225
+ if (peak > peakLimit) {
226
+ throw new errors_js_1.ToolInputError("LIMIT_EXCEEDED", `This pattern peaks at ${peak}, above this server's limit of ${peakLimit}. Lower ${executor.type.includes("rate") ? "rate" : "vus"}${args.preset ? ` (the ${args.preset} preset climbs above the value you give)` : ""}.`);
227
+ }
228
+ const thresholds = [
229
+ ...(args.thresholds ?? []).map((expression, index) => ({ id: `t${index + 1}`, expression })),
230
+ ...(args.abortOn ?? []).map((expression, index) => ({ id: `a${index + 1}`, expression, abortOnFail: true, delayAbortEvalMs: 5000 })),
231
+ ];
232
+ for (const threshold of thresholds) {
233
+ try {
234
+ (0, callman_core_1.parseThreshold)(threshold.expression);
235
+ }
236
+ catch (error) {
237
+ throw new errors_js_1.ToolInputError("INVALID_THRESHOLD", error instanceof Error ? error.message : String(error));
238
+ }
239
+ }
240
+ const environment = {};
241
+ for (const [key, value] of Object.entries({ ...prepared.globals, ...prepared.env })) {
242
+ if (typeof value === "string")
243
+ environment[key] = value;
244
+ }
245
+ const thinkMin = args.thinkTimeMinMs ?? 0;
246
+ const thinkMax = Math.max(args.thinkTimeMaxMs ?? thinkMin, thinkMin);
247
+ const config = {
248
+ runId: `mcp-${Date.now().toString(36)}`,
249
+ name: resolved.item.name,
250
+ executor,
251
+ steps: chosen.map(callman_core_1.requestToLoadStep),
252
+ environment,
253
+ thresholds,
254
+ ...(thinkMax > 0 ? { thinkTimeMs: { min: thinkMin, max: thinkMax } } : {}),
255
+ keepAlive: true,
256
+ verifySsl: true,
257
+ monitor: { enabled: false },
258
+ };
259
+ const issues = (0, callman_core_1.validateLoadTestConfig)(config);
260
+ if ((0, callman_core_1.hasBlockingIssues)(issues)) {
261
+ throw new errors_js_1.ToolInputError("INVALID_LOAD_TEST", issues.filter((issue) => issue.severity === "error").map((issue) => issue.message).join(" "));
262
+ }
263
+ const redactor = (0, redact_js_1.createRedactor)(Object.entries(environment), prepared.redaction);
264
+ const hosts = new Set();
265
+ for (const request of chosen) {
266
+ try {
267
+ hosts.add(new URL(/^[a-z]+:\/\//i.test(request.url) ? (0, resolveTemplate_js_1.resolveTemplate)(request.url, environment) : `http://${(0, resolveTemplate_js_1.resolveTemplate)(request.url, environment)}`).host);
268
+ }
269
+ catch {
270
+ hosts.add(redactor.text((0, resolveTemplate_js_1.resolveTemplate)(request.url, environment)));
271
+ }
272
+ }
273
+ const estimate = (0, callman_core_1.estimateLoad)(config, 250 * chosen.length);
274
+ const notes = chosen
275
+ .map((request) => {
276
+ const features = [];
277
+ if (request.preRequestScript?.trim() || request.postResponseScript?.trim() || request.tests?.trim())
278
+ features.push("scripts and tests");
279
+ if (request.database?.preRequest?.enabled || request.database?.postResponse?.enabled)
280
+ features.push("database steps");
281
+ if (request.redis?.enabled)
282
+ features.push("Redis commands");
283
+ return features.length > 0 ? `${request.name}: ${features.join(", ")} are not run in a load test.` : null;
284
+ })
285
+ .filter((note) => note !== null);
286
+ const warnings = [...prepared.warnings, ...notes, ...issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)];
287
+ if (thresholds.length === 0)
288
+ warnings.push("No pass criteria were given, so the verdict can only be 'passed'. Add thresholds such as [\"p95<500\", \"fail<1%\"].");
289
+ const pattern = {
290
+ type: executor.type,
291
+ ...(executor.type === "constant-vus" ? { vus: executor.vus } : {}),
292
+ ...(executor.type === "constant-arrival-rate" ? { ratePerSecond: executor.rate } : {}),
293
+ durationSeconds,
294
+ ...(args.rampUpSeconds ? { rampUpSeconds: args.rampUpSeconds } : {}),
295
+ ...(args.preset ? { preset: args.preset } : {}),
296
+ };
297
+ if (args.confirm !== true) {
298
+ return (0, shared_js_1.ok)({
299
+ preview: true,
300
+ message: "Nothing was sent. Show this to the user and call run_load_test again with confirm: true to generate the load. Only test systems you own or may load-test.",
301
+ workspace: (0, lookups_js_1.workspaceTag)(ws),
302
+ collection: { id: resolved.item.id, name: resolved.item.name },
303
+ environment: prepared.environment,
304
+ targetHosts: [...hosts],
305
+ flow: chosen.map((request) => ({ name: request.name, method: request.method, url: redactor.text((0, resolveTemplate_js_1.resolveTemplate)(request.url, environment)) })),
306
+ pattern,
307
+ estimate: { requests: estimate.totalRequests, uploadMegabytes: round(estimate.estimatedUploadMb, 1), impactOnThisMachine: estimate.impact },
308
+ limits: { maxVus: loadTestMaxVus, maxRatePerSecond: loadTestMaxRate, maxSeconds: loadTestMaxSeconds },
309
+ thresholds: thresholds.map((threshold) => ({ expression: threshold.expression, ...(threshold.abortOnFail ? { stopsTheRun: true } : {}) })),
310
+ warnings,
311
+ });
312
+ }
313
+ const { startLoadTest } = await Promise.resolve().then(() => __importStar(require("callman-core/loadtest")));
314
+ const result = await ctx.withRunLock(async () => {
315
+ const handle = startLoadTest(config, { onEvent: () => undefined, ...(ctx.config.loadWorkerEntry ? { workerEntry: ctx.config.loadWorkerEntry } : {}) });
316
+ const stopTimer = setTimeout(() => handle.stop(), (durationSeconds + 20) * 1000);
317
+ try {
318
+ return await handle.done;
319
+ }
320
+ finally {
321
+ clearTimeout(stopTimer);
322
+ }
323
+ });
324
+ return (0, shared_js_1.ok)({
325
+ ...summarize(result, (text) => redactor.text(text)),
326
+ workspace: (0, lookups_js_1.workspaceTag)(ws),
327
+ collection: { id: resolved.item.id, name: resolved.item.name },
328
+ environment: prepared.environment,
329
+ targetHosts: [...hosts],
330
+ pattern,
331
+ warnings,
332
+ });
333
+ }));
334
+ };
335
+ exports.registerLoadTestTool = registerLoadTestTool;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.sameName = exports.connectionResolverFor = exports.loadConnections = exports.resolveUiTest = exports.resolveUiTestFolder = exports.uiTestFolderSubtree = exports.loadUiTestFolders = exports.loadUiTests = exports.resolveDiagram = exports.resolveDiagramFolder = exports.diagramFolderPath = exports.diagramFolderRows = exports.loadDiagramFolders = exports.loadDiagrams = exports.resolveScenarioFolder = exports.resolveScenario = exports.loadScenarioCatalog = exports.resolveEnvironment = exports.loadEnvironments = exports.resolveRequest = exports.searchRequests = exports.requestIdsInFolder = exports.resolveFolder = exports.loadTree = exports.resolveCollection = exports.findEnvironment = exports.findRequest = exports.findCollection = exports.findInWorkspaces = exports.workspaceTag = exports.workspacesForListing = exports.isAllWorkspaces = exports.loadCollections = void 0;
3
+ exports.sameName = exports.connectionResolverFor = exports.loadConnections = exports.resolveUiTest = exports.resolveUiTestFolder = exports.uiTestFolderSubtree = exports.loadUiTestFolders = exports.loadUiTests = exports.resolveDiagram = exports.loadDiagrams = exports.resolveScenarioFolder = exports.resolveScenario = exports.loadScenarioCatalog = exports.resolveEnvironment = exports.loadEnvironments = exports.resolveRequest = exports.searchRequests = exports.requestIdsInFolder = exports.resolveFolder = exports.loadTree = exports.resolveCollection = exports.findEnvironment = exports.findRequest = exports.findCollection = exports.findInWorkspaces = exports.workspaceTag = exports.workspacesForListing = exports.isAllWorkspaces = exports.loadCollections = void 0;
4
4
  const errors_js_1 = require("../client/errors.js");
5
5
  const requestIntegrations_js_1 = require("../domain/requestIntegrations.js");
6
6
  const resolve_js_1 = require("../domain/resolve.js");
@@ -240,51 +240,6 @@ const resolveScenarioFolder = async (ctx, ws, ref) => (0, resolve_js_1.resolveRe
240
240
  exports.resolveScenarioFolder = resolveScenarioFolder;
241
241
  const loadDiagrams = (ctx) => ctx.cache.getOrSet("diagrams", () => ctx.api.listDiagrams());
242
242
  exports.loadDiagrams = loadDiagrams;
243
- // ── diagram folders (the token owner's private library tree) ──────────
244
- const loadDiagramFolders = (ctx) => ctx.cache.getOrSet("diagram-folders", () => ctx.api.listDiagramFolders());
245
- exports.loadDiagramFolders = loadDiagramFolders;
246
- // The tree as rows with paths ("Architecture/Payments"), depth-first,
247
- // siblings by name — what a model refers to folders by. An older backend
248
- // without the route yields no rows (every diagram is then at the top level).
249
- const diagramFolderRows = async (ctx) => {
250
- let folders;
251
- try {
252
- folders = await (0, exports.loadDiagramFolders)(ctx);
253
- }
254
- catch (error) {
255
- if (error instanceof errors_js_1.CallmanApiError && (error.status === 404 || error.status === 403))
256
- return [];
257
- throw error;
258
- }
259
- const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) || a.id.localeCompare(b.id);
260
- const out = [];
261
- const seen = new Set();
262
- const walk = (parentId, prefix, depth) => {
263
- for (const folder of folders.filter((entry) => entry.parentId === parentId).sort(byName)) {
264
- if (seen.has(folder.id))
265
- continue; // a corrupt cycle must not recurse forever
266
- seen.add(folder.id);
267
- const path = prefix ? `${prefix}/${folder.name}` : folder.name;
268
- out.push({ id: folder.id, name: folder.name, parentId: folder.parentId, path, depth });
269
- walk(folder.id, path, depth + 1);
270
- }
271
- };
272
- walk(null, "", 0);
273
- return out;
274
- };
275
- exports.diagramFolderRows = diagramFolderRows;
276
- const diagramFolderPath = (rows, folderId) => folderId ? (rows.find((row) => row.id === folderId)?.path ?? null) : null;
277
- exports.diagramFolderPath = diagramFolderPath;
278
- // A folder by id, exact path, exact name (unique) or unique substring.
279
- const resolveDiagramFolder = async (ctx, ref, options = {}) => {
280
- const rows = await (0, exports.diagramFolderRows)(ctx);
281
- return (0, resolve_js_1.resolveRef)("diagram folder", ref, rows, {
282
- describe: (folder) => ({ path: folder.path }),
283
- aliases: (folder) => [folder.path],
284
- exact: options.exact,
285
- });
286
- };
287
- exports.resolveDiagramFolder = resolveDiagramFolder;
288
243
  const resolveDiagram = async (ctx, ref, options = {}) => {
289
244
  if ((0, ids_js_1.isObjectId)(ref))
290
245
  return { item: await ctx.api.getDiagram(ref), matched: "id" };
@@ -45,7 +45,7 @@ const registerRequestTools = (server, ctx) => {
45
45
  }));
46
46
  server.registerTool("get_request", {
47
47
  title: "Get a request",
48
- description: "Read one saved request in the SAME shape create_request / update_request accept: url, headers, query, pathVariables, body (a JSON value or { mode, … }), auth ({ type, token | username/password | key/value/location | oauth2 fields }), scripts, isEnabledInRunner, plus which database / Kafka / Redis steps it has. Secrets in auth and sensitive headers are masked (••••ab) unless reveal: true — sending a masked value back unchanged keeps the stored secret. A long body comes back as bodyPreview (body: null) unless full: true.",
48
+ description: "Read one saved request in the SAME shape create_request / update_request accept: url, headers, query, pathVariables, body (a JSON value or { mode, … }), auth ({ type, token | username/password | key/value/location | oauth2 fields }), scripts, isEnabledInRunner, plus which database / Redis steps it has. Secrets in auth and sensitive headers are masked (••••ab) unless reveal: true — sending a masked value back unchanged keeps the stored secret. A long body comes back as bodyPreview (body: null) unless full: true.",
49
49
  inputSchema: {
50
50
  workspace: shared_js_1.workspaceArg,
51
51
  request: (0, shared_js_1.refArg)("Request", "(name, \"METHOD url\" or id)"),
@@ -65,7 +65,7 @@ const registerRequestTools = (server, ctx) => {
65
65
  }));
66
66
  server.registerTool("create_request", {
67
67
  title: "Create a request",
68
- description: "Create one saved request in a collection (optionally inside a folder by id, name or path): method, url, headers, query, path variables, body (a JSON value, a raw string, or { mode: raw|formdata|urlencoded|json }), auth (bearer, basic, apiKey, oauth2), pre-request / post-response / tests scripts, and optional database / Kafka / Redis steps and DB assertions (connections by id or name — list_connections). Use {{variable}} for hosts and secrets and put the values in an environment. Contract checks are added with add_contract_rules / set_data_contract. For many requests use create_requests_bulk (one call, atomic). Reference: callman://docs/request-schema.",
68
+ description: "Create one saved request in a collection (optionally inside a folder by id, name or path): method, url, headers, query, path variables, body (a JSON value, a raw string, or { mode: raw|formdata|urlencoded|json }), auth (bearer, basic, apiKey, oauth2), pre-request / post-response scripts (put pm.test assertions in the post-response script; testsScript is a legacy slot), and optional database / Redis steps and DB assertions (connections by id or name — list_connections). Use {{variable}} for hosts and secrets and put the values in an environment. Contract checks are added with add_contract_rules / set_data_contract. For many requests use create_requests_bulk (one call, atomic). Reference: callman://docs/request-schema.",
69
69
  inputSchema: { workspace: shared_js_1.workspaceArg, collection: (0, shared_js_1.refArg)("Collection"), ...requestShape_js_1.requestInputSchema.omit({ folderKey: true }).shape },
70
70
  annotations: shared_js_1.WRITE,
71
71
  }, (0, shared_js_1.guarded)(async ({ workspace, collection, ...item }) => {
@@ -237,7 +237,7 @@ const registerRequestTools = (server, ctx) => {
237
237
  }));
238
238
  server.registerTool("update_request", {
239
239
  title: "Update a request",
240
- description: "Change fields of a saved request — only the fields you pass change, in the same shape as create_request. `headers` / `query` / `pathVariables` / `dbAssertions` replace the whole list; `url` and `query` are kept in sync (a ?query in the url becomes rows). `database` / `kafka` merge: a phase or field you leave out is kept, null turns it off. A masked secret sent back unchanged keeps the stored value. isEnabledInRunner toggles the runner switch. To move it, use move_requests; contract checks: add_contract_rules / set_data_contract.",
240
+ description: "Change fields of a saved request — only the fields you pass change, in the same shape as create_request. `headers` / `query` / `pathVariables` / `dbAssertions` replace the whole list; `url` and `query` are kept in sync (a ?query in the url becomes rows). `database` merges: a phase or field you leave out is kept, null turns it off. A masked secret sent back unchanged keeps the stored value. isEnabledInRunner toggles the runner switch. To move it, use move_requests; contract checks: add_contract_rules / set_data_contract.",
241
241
  inputSchema: {
242
242
  workspace: shared_js_1.workspaceArg,
243
243
  request: (0, shared_js_1.refArg)("Request", "(name, \"METHOD url\" or id)"),
@@ -33,8 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.registerRunTools = void 0;
36
+ exports.registerRunTools = exports.prepareRun = void 0;
37
37
  const zod_1 = require("zod");
38
+ const callman_core_1 = require("callman-core");
38
39
  const errors_js_1 = require("../client/errors.js");
39
40
  const resolve_js_1 = require("../domain/resolve.js");
40
41
  const redact_js_1 = require("../lib/redact.js");
@@ -51,7 +52,7 @@ const shared_js_1 = require("./shared.js");
51
52
  // {{key}}). One run at a time.
52
53
  // What a local run does NOT do, stated up front so a model does not read a
53
54
  // missing check as a pass.
54
- const LOCAL_RUN_LIMITS = "Local-run limits: Kafka steps are skipped; DB assertions (dbAssertions) are evaluated only by the desktop app; no cookie jar between requests; no client certificates (mTLS); each request times out after 30 s. Database and Redis steps run when the collection's runnerSettings allow them and the connection is usable from this machine.";
55
+ const LOCAL_RUN_LIMITS = "Local-run limits: DB assertions (dbAssertions) are evaluated only by the desktop app; no cookie jar between requests; no client certificates (mTLS); each request times out after 30 s. Database and Redis steps run when the collection's runnerSettings allow them and the connection is usable from this machine.";
55
56
  const maxBodyArg = zod_1.z.number().int().min(256).max(truncate_js_1.LIMITS.RUN_BODY_MAX).optional().describe("Response body preview cap in bytes (default 16 KB)");
56
57
  // apiKey auth names its own header / query parameter — mask those too, and
57
58
  // note the steps a local run skips.
@@ -69,8 +70,6 @@ const inspectBundle = (rawCollection) => {
69
70
  const notes = [];
70
71
  if (Array.isArray(request.dbAssertions) && request.dbAssertions.length > 0)
71
72
  notes.push("DB assertions (desktop only)");
72
- if (request.kafka && (request.kafka.enabled || request.kafka.prePublish?.enabled || request.kafka.postPublish?.enabled))
73
- notes.push("Kafka steps");
74
73
  if (typeof request.id === "string" && notes.length > 0)
75
74
  skipped.set(request.id, notes);
76
75
  }
@@ -112,6 +111,7 @@ const prepareRun = async (ctx, ws, collectionId, environmentRef) => {
112
111
  skipped: skippedSteps,
113
112
  };
114
113
  };
114
+ exports.prepareRun = prepareRun;
115
115
  // Warnings for the requests actually run.
116
116
  const skippedWarnings = (prepared, ids) => {
117
117
  const out = [];
@@ -141,7 +141,7 @@ const registerRunTools = (server, ctx) => {
141
141
  const { ws, value, note } = await (0, lookups_js_1.findRequest)(ctx, workspace, request, collection);
142
142
  const { request: found } = value;
143
143
  const annotation = [value.annotation, note].filter(Boolean).join("; ") || undefined;
144
- const prepared = await prepareRun(ctx, ws.id, found.collectionId, environment);
144
+ const prepared = await (0, exports.prepareRun)(ctx, ws.id, found.collectionId, environment);
145
145
  const runnable = prepared.collection.requests.find((entry) => entry.id === found.id);
146
146
  if (!runnable)
147
147
  throw new errors_js_1.ToolInputError("REQUEST_NOT_IN_BUNDLE", "The request is not part of the collection bundle (was it just deleted?).");
@@ -161,7 +161,17 @@ const registerRunTools = (server, ctx) => {
161
161
  query: redactor.query(Object.fromEntries(Object.entries(runnable.query ?? {}).map(([key, value]) => [key, resolveTemplate(value, env)]))),
162
162
  body: redactor.value(runnable.body ?? null),
163
163
  hasPreRequestScript: Boolean(runnable.preRequestScript),
164
- hasTests: Boolean(runnable.tests),
164
+ hasTests: Boolean(runnable.tests || runnable.postResponseScript),
165
+ // Collection / folder scripts that run around this request.
166
+ inheritedScripts: (0, callman_core_1.buildScriptLayers)({
167
+ collection: {
168
+ ...(prepared.collection.id ? { id: prepared.collection.id } : {}),
169
+ name: prepared.collection.name,
170
+ scripts: prepared.collection.scripts ?? null,
171
+ },
172
+ folders: prepared.collection.folders ?? [],
173
+ folderId: runnable.folderId ?? null,
174
+ }).map(callman_core_1.describeScriptLayer),
165
175
  },
166
176
  environment: prepared.environment,
167
177
  availableVariableKeys: Object.keys(env),
@@ -203,7 +213,7 @@ const registerRunTools = (server, ctx) => {
203
213
  annotations: shared_js_1.RUN,
204
214
  }, (0, shared_js_1.guarded)(async ({ workspace, collection, folder, only, environment, bail, maxBodyBytes }) => {
205
215
  const { ws, value: resolved } = await (0, lookups_js_1.findCollection)(ctx, workspace, collection);
206
- const prepared = await prepareRun(ctx, ws.id, resolved.item.id, environment);
216
+ const prepared = await (0, exports.prepareRun)(ctx, ws.id, resolved.item.id, environment);
207
217
  let selection = null;
208
218
  if (folder) {
209
219
  const target = await (0, lookups_js_1.resolveFolder)(ctx, ws.id, resolved.item.id, folder);