kickload-watcher-mcp 0.1.0 → 0.1.2

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.
@@ -1,254 +1,260 @@
1
- // kickload-client.js
2
- // Single source of truth for ALL Kickload API calls.
3
- // Every endpoint verified against app.py source code.
4
- //
5
- // ⚠️ NEW BASE URL: https://kickload.neeyatai.com/api
6
- // All routes are relative to this base, e.g.:
7
- // POST https://kickload.neeyatai.com/api/generate-test-plan
8
- // POST https://kickload.neeyatai.com/api/run-test/{filename}
9
- // GET https://kickload.neeyatai.com/api/task-status/{id}
10
- // POST https://kickload.neeyatai.com/api/analyzeJTL
11
- // GET https://kickload.neeyatai.com/api/download/{filename}
12
-
13
- import FormData from "form-data";
14
- import fetch from "node-fetch";
15
- import fs from "fs";
16
- import path from "path";
17
-
18
- const POLL_INTERVAL_MS = 5000; // 5s between polls — matches backend
19
- const POLL_TIMEOUT_MS = 600000; // 10 minutes max
20
-
21
- export class KickloadClient {
22
- /**
23
- * @param {string} baseUrl — e.g. "https://kickload.neeyatai.com/api"
24
- * @param {string} apiToken — X-API-Token value
25
- */
26
- constructor(baseUrl, apiToken) {
27
- // Normalize: strip trailing slash
28
- this.baseUrl = baseUrl.replace(/\/$/, "");
29
- this.token = apiToken;
30
- }
31
-
32
- // ─── Private authenticated fetch ─────────────────────────────
33
- async _fetch(method, endpoint, { body, headers = {}, isJson = false } = {}) {
34
- const url = `${this.baseUrl}${endpoint}`;
35
-
36
- const reqHeaders = {
37
- "X-API-Token": this.token,
38
- ...headers,
39
- };
40
-
41
- if (isJson) {
42
- reqHeaders["Content-Type"] = "application/json";
43
- }
44
-
45
- const response = await fetch(url, {
46
- method,
47
- headers: reqHeaders,
48
- body,
49
- signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
50
- });
51
-
52
- const text = await response.text();
53
-
54
- let parsed;
55
- try {
56
- parsed = JSON.parse(text);
57
- } catch {
58
- parsed = { _raw: text };
59
- }
60
-
61
- if (!response.ok) {
62
- const err = new Error(
63
- `Kickload ${method} ${endpoint} → ${response.status} ${response.statusText}\n` +
64
- `URL: ${url}\n` +
65
- `Response: ${text.substring(0, 500)}`
66
- );
67
- err.status = response.status;
68
- err.body = text;
69
- err.parsed = parsed;
70
- throw err;
71
- }
72
-
73
- return parsed;
74
- }
75
-
76
- // ══════════════════════════════════════════════════════════════
77
- // STEP 1 — Generate Test Plan
78
- // POST /generate-test-plan
79
- // Source: app.py line 1260
80
- //
81
- // Send prompt OR JMX file OR both.
82
- // Returns: { status: "success", jmx_filename: "test_plan_xxx.jmx" }
83
- // ══════════════════════════════════════════════════════════════
84
- async generateTestPlan({ prompt, jmxFilePath } = {}) {
85
- if (!prompt && !jmxFilePath) {
86
- throw new Error("generateTestPlan: provide at least a prompt or jmxFilePath");
87
- }
88
-
89
- const form = new FormData();
90
-
91
- if (prompt) {
92
- form.append("prompt", prompt);
93
- }
94
-
95
- if (jmxFilePath) {
96
- const resolved = path.resolve(jmxFilePath);
97
- if (!fs.existsSync(resolved)) {
98
- throw new Error(`JMX file not found: ${resolved}`);
99
- }
100
- form.append("file", fs.createReadStream(resolved), {
101
- filename: path.basename(resolved),
102
- contentType: "application/xml",
103
- });
104
- }
105
-
106
- console.log("\n📋 STEP 1: Generating test plan...");
107
- if (prompt) console.log(` Prompt : ${prompt.substring(0, 100)}`);
108
- if (jmxFilePath) console.log(` JMX file: ${jmxFilePath}`);
109
-
110
- const result = await this._fetch("POST", "/generate-test-plan", {
111
- body: form,
112
- headers: form.getHeaders(),
113
- });
114
-
115
- if (result.status !== "success") {
116
- throw new Error(
117
- `Test plan generation failed: ${result.message || JSON.stringify(result)}`
118
- );
119
- }
120
-
121
- console.log(`✅ STEP 1 done — JMX: ${result.jmx_filename}`);
122
- return result;
123
- }
124
-
125
- // ══════════════════════════════════════════════════════════════
126
- // STEP 2 — Run Test
127
- // POST /run-test/{jmx_filename}
128
- // Source: app.py line 1022
129
- //
130
- // Returns: { status: "started", task_id: "uuid-xxxx" }
131
- // ══════════════════════════════════════════════════════════════
132
- async runTest(jmxFilename, { numThreads, loopCount, rampTime } = {}) {
133
- if (!jmxFilename) {
134
- throw new Error("runTest: jmxFilename is required");
135
- }
136
-
137
- const body = {};
138
- if (numThreads !== undefined) body.num_threads = numThreads;
139
- if (loopCount !== undefined) body.loop_count = loopCount;
140
- if (rampTime !== undefined) body.ramp_time = rampTime;
141
-
142
- console.log(`\n▶️ STEP 2: Running test — ${jmxFilename}`);
143
- console.log(` Overrides: ${JSON.stringify(body) || "none (using JMX defaults)"}`);
144
-
145
- const result = await this._fetch(
146
- "POST",
147
- `/run-test/${encodeURIComponent(jmxFilename)}`,
148
- { body: JSON.stringify(body), isJson: true }
149
- );
150
-
151
- if (result.status !== "started") {
152
- throw new Error(`Run test failed: ${result.message || JSON.stringify(result)}`);
153
- }
154
-
155
- console.log(`✅ STEP 2 done — Task ID: ${result.task_id}`);
156
- return result;
157
- }
158
-
159
- // ══════════════════════════════════════════════════════════════
160
- // STEP 3 — Poll Task Status
161
- // GET /task-status/{task_id}
162
- // Source: app.py line 899
163
- //
164
- // Polls every 5s until status = "success" | "error"
165
- // Returns: { status, result_file, pdf_file, summary_output }
166
- // ══════════════════════════════════════════════════════════════
167
- async pollUntilDone(taskId) {
168
- if (!taskId) throw new Error("pollUntilDone: taskId is required");
169
-
170
- console.log(`\n⏳ STEP 3: Polling task ${taskId}`);
171
- const started = Date.now();
172
- let polls = 0;
173
-
174
- while (Date.now() - started < POLL_TIMEOUT_MS) {
175
- await sleep(POLL_INTERVAL_MS);
176
- polls++;
177
-
178
- const result = await this._fetch("GET", `/task-status/${taskId}`);
179
- const elapsed = Math.round((Date.now() - started) / 1000);
180
-
181
- if (result.status === "running") {
182
- process.stdout.write(`\r ⏳ Running... ${elapsed}s (poll #${polls})`);
183
- continue;
184
- }
185
-
186
- if (result.status === "success") {
187
- console.log(`\n✅ STEP 3 done in ${elapsed}s`);
188
- console.log(` JTL: ${result.result_file}`);
189
- console.log(` PDF: ${result.pdf_file}`);
190
- return result;
191
- }
192
-
193
- if (result.status === "error") {
194
- throw new Error(`Task ${taskId} failed: ${result.message}`);
195
- }
196
-
197
- throw new Error(`Task ${taskId} unknown status: ${result.status}`);
198
- }
199
-
200
- throw new Error(`Task ${taskId} timed out after ${POLL_TIMEOUT_MS / 60000} minutes`);
201
- }
202
-
203
- // ══════════════════════════════════════════════════════════════
204
- // STEP 4 — Analyze JTL
205
- // POST /analyzeJTL
206
- // Source: app.py line 1127
207
- //
208
- // Returns: { filename: "analysis_xxx.pdf" }
209
- // ══════════════════════════════════════════════════════════════
210
- async analyzeJtl(jtlFilename) {
211
- if (!jtlFilename) throw new Error("analyzeJtl: jtlFilename is required");
212
-
213
- console.log(`\n🔬 STEP 4: Analyzing JTL — ${jtlFilename}`);
214
-
215
- const result = await this._fetch("POST", "/analyzeJTL", {
216
- body: JSON.stringify({ filename: jtlFilename }),
217
- isJson: true,
218
- });
219
-
220
- if (!result.filename) {
221
- throw new Error(`JTL analysis returned no filename: ${JSON.stringify(result)}`);
222
- }
223
-
224
- console.log(`✅ STEP 4 done — Analysis PDF: ${result.filename}`);
225
- return result;
226
- }
227
-
228
- // ══════════════════════════════════════════════════════════════
229
- // STEP 5 — Get Download URL
230
- // GET /download/{filename}?mode=inline
231
- // Source: app.py line 1472
232
- //
233
- // Returns: { status: "success", download_url: "https://s3.amazonaws.com/..." }
234
- // ══════════════════════════════════════════════════════════════
235
- async getDownloadUrl(filename) {
236
- if (!filename) return null;
237
-
238
- try {
239
- const result = await this._fetch(
240
- "GET",
241
- `/download/${encodeURIComponent(filename)}?mode=inline`
242
- );
243
- return result.download_url || null;
244
- } catch (err) {
245
- console.warn(`⚠️ Download URL failed for ${filename}: ${err.message}`);
246
- return null;
247
- }
248
- }
249
- }
250
-
251
- // ─── Utility ──────────────────────────────────────────────────
252
- function sleep(ms) {
253
- return new Promise(resolve => setTimeout(resolve, ms));
254
- }
1
+ // kickload-client.js
2
+ // Single source of truth for ALL Kickload API calls.
3
+ // Every endpoint verified against app.py source code.
4
+ //
5
+ // ⚠️ NEW BASE URL: https://kickload.neeyatai.com/api
6
+ // All routes are relative to this base, e.g.:
7
+ // POST https://kickload.neeyatai.com/api/generate-test-plan
8
+ // POST https://kickload.neeyatai.com/api/run-test/{filename}
9
+ // GET https://kickload.neeyatai.com/api/task-status/{id}
10
+ // POST https://kickload.neeyatai.com/api/analyzeJTL
11
+ // GET https://kickload.neeyatai.com/api/download/{filename}
12
+
13
+ import FormData from "form-data";
14
+ import fetch from "node-fetch";
15
+ import fs from "fs";
16
+ import path from "path";
17
+
18
+ const POLL_INTERVAL_MS = 5000; // 5s between polls — matches backend
19
+ const POLL_TIMEOUT_MS = 1800000; // 30 min
20
+
21
+ export class KickloadClient {
22
+ /**
23
+ * @param {string} baseUrl — e.g. "https://kickload.neeyatai.com/api"
24
+ * @param {string} apiToken — X-API-Token value
25
+ */
26
+ constructor(baseUrl, apiToken) {
27
+ // Normalize: strip trailing slash
28
+ this.baseUrl = baseUrl.replace(/\/$/, "");
29
+ this.token = apiToken;
30
+ }
31
+
32
+ // ─── Private authenticated fetch ─────────────────────────────
33
+ async _fetch(method, endpoint, { body, headers = {}, isJson = false } = {}) {
34
+ const url = `${this.baseUrl}${endpoint}`;
35
+
36
+ const reqHeaders = {
37
+ "X-API-Token": this.token,
38
+ ...headers,
39
+ };
40
+
41
+ if (isJson) {
42
+ reqHeaders["Content-Type"] = "application/json";
43
+ }
44
+
45
+ const response = await fetch(url, {
46
+ method,
47
+ headers: reqHeaders,
48
+ body,
49
+ signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
50
+ });
51
+
52
+ const text = await response.text();
53
+
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(text);
57
+ } catch {
58
+ parsed = { _raw: text };
59
+ }
60
+
61
+ if (!response.ok) {
62
+ const err = new Error(
63
+ `Kickload ${method} ${endpoint} → ${response.status} ${response.statusText}\n` +
64
+ `URL: ${url}\n` +
65
+ `Response: ${text.substring(0, 500)}`
66
+ );
67
+ err.status = response.status;
68
+ err.body = text;
69
+ err.parsed = parsed;
70
+ throw err;
71
+ }
72
+
73
+ return parsed;
74
+ }
75
+
76
+ // ══════════════════════════════════════════════════════════════
77
+ // STEP 1 — Generate Test Plan
78
+ // POST /generate-test-plan
79
+ // Source: app.py line 1260
80
+ //
81
+ // Send prompt OR JMX file OR both.
82
+ // Returns: { status: "success", jmx_filename: "test_plan_xxx.jmx" }
83
+ // ══════════════════════════════════════════════════════════════
84
+ async generateTestPlan({ prompt, jmxFilePath } = {}) {
85
+ if (!prompt && !jmxFilePath) {
86
+ throw new Error("generateTestPlan: provide at least a prompt or jmxFilePath");
87
+ }
88
+
89
+ const form = new FormData();
90
+
91
+ if (prompt) {
92
+ form.append("prompt", prompt);
93
+ }
94
+
95
+ if (jmxFilePath) {
96
+ const resolved = path.resolve(jmxFilePath);
97
+ if (!fs.existsSync(resolved)) {
98
+ throw new Error(`JMX file not found: ${resolved}`);
99
+ }
100
+ form.append("file", fs.createReadStream(resolved), {
101
+ filename: path.basename(resolved),
102
+ contentType: "application/xml",
103
+ });
104
+ }
105
+
106
+ console.log("\n📋 STEP 1: Generating test plan...");
107
+ if (prompt) console.log(` Prompt : ${prompt.substring(0, 100)}`);
108
+ if (jmxFilePath) console.log(` JMX file: ${jmxFilePath}`);
109
+
110
+ const result = await this._fetch("POST", "/generate-test-plan", {
111
+ body: form,
112
+ headers: form.getHeaders(),
113
+ });
114
+
115
+ if (result.status !== "success") {
116
+ throw new Error(
117
+ `Test plan generation failed: ${result.message || JSON.stringify(result)}`
118
+ );
119
+ }
120
+
121
+ console.log(`✅ STEP 1 done — JMX: ${result.jmx_filename}`);
122
+ return result;
123
+ }
124
+
125
+ // ══════════════════════════════════════════════════════════════
126
+ // STEP 2 — Run Test
127
+ // POST /run-test/{jmx_filename}
128
+ // Source: app.py line 1022
129
+ //
130
+ // Returns: { status: "started", task_id: "uuid-xxxx" }
131
+ // ══════════════════════════════════════════════════════════════
132
+ async runTest(jmxFilename, { numThreads, loopCount, rampTime } = {}) {
133
+ if (!jmxFilename) {
134
+ throw new Error("runTest: jmxFilename is required");
135
+ }
136
+
137
+ const body = {};
138
+ if (numThreads !== undefined) body.num_threads = numThreads;
139
+ if (loopCount !== undefined) body.loop_count = loopCount;
140
+ if (rampTime !== undefined) body.ramp_time = rampTime;
141
+
142
+ console.log(`\n▶️ STEP 2: Running test — ${jmxFilename}`);
143
+ console.log(` Overrides: ${JSON.stringify(body) || "none (using JMX defaults)"}`);
144
+
145
+ const result = await this._fetch(
146
+ "POST",
147
+ `/run-test/${encodeURIComponent(jmxFilename)}`,
148
+ { body: JSON.stringify(body), isJson: true }
149
+ );
150
+
151
+ if (result.status !== "started") {
152
+ throw new Error(`Run test failed: ${result.message || JSON.stringify(result)}`);
153
+ }
154
+
155
+ console.log(`✅ STEP 2 done — Task ID: ${result.task_id}`);
156
+ return result;
157
+ }
158
+
159
+ // ══════════════════════════════════════════════════════════════
160
+ // STEP 3 — Poll Task Status
161
+ // GET /task-status/{task_id}
162
+ // Source: app.py line 899
163
+ //
164
+ // Polls every 5s until status = "success" | "error"
165
+ // Returns: { status, result_file, pdf_file, summary_output }
166
+ // ══════════════════════════════════════════════════════════════
167
+ async pollUntilDone(taskId) {
168
+ if (!taskId) throw new Error("pollUntilDone: taskId is required");
169
+
170
+ console.log(`\n⏳ STEP 3: Polling task ${taskId}`);
171
+ const started = Date.now();
172
+ let polls = 0;
173
+
174
+ while (Date.now() - started < POLL_TIMEOUT_MS) {
175
+ await sleep(POLL_INTERVAL_MS);
176
+ polls++;
177
+
178
+ const result = await this._fetch("GET", `/task-status/${taskId}`);
179
+ const elapsed = Math.round((Date.now() - started) / 1000);
180
+
181
+ if (result.status === "running") {
182
+ process.stdout.write(`\r ⏳ Running... ${elapsed}s (poll #${polls})`);
183
+ continue;
184
+ }
185
+
186
+ if (result.status === "success") {
187
+ console.log(`\n✅ STEP 3 done in ${elapsed}s`);
188
+ console.log(` JTL: ${result.result_file}`);
189
+ console.log(` PDF: ${result.pdf_file}`);
190
+ return result;
191
+ }
192
+
193
+ if (result.status === "error") {
194
+ throw new Error(`Task ${taskId} failed: ${result.message}`);
195
+ }
196
+
197
+ throw new Error(`Task ${taskId} unknown status: ${result.status}`);
198
+ }
199
+
200
+ throw new Error(
201
+ `Task ${taskId} timed out after ${POLL_TIMEOUT_MS / 60000} minutes.\n` +
202
+ `Possible reasons:\n` +
203
+ `- Large load test\n` +
204
+ `- Slow backend\n` +
205
+ `- KickLoad server delay`
206
+ );
207
+ }
208
+
209
+ // ══════════════════════════════════════════════════════════════
210
+ // STEP 4 — Analyze JTL
211
+ // POST /analyzeJTL
212
+ // Source: app.py line 1127
213
+ //
214
+ // Returns: { filename: "analysis_xxx.pdf" }
215
+ // ══════════════════════════════════════════════════════════════
216
+ async analyzeJtl(jtlFilename) {
217
+ if (!jtlFilename) throw new Error("analyzeJtl: jtlFilename is required");
218
+
219
+ console.log(`\n🔬 STEP 4: Analyzing JTL — ${jtlFilename}`);
220
+
221
+ const result = await this._fetch("POST", "/analyzeJTL", {
222
+ body: JSON.stringify({ filename: jtlFilename }),
223
+ isJson: true,
224
+ });
225
+
226
+ if (!result.filename) {
227
+ throw new Error(`JTL analysis returned no filename: ${JSON.stringify(result)}`);
228
+ }
229
+
230
+ console.log(`✅ STEP 4 done — Analysis PDF: ${result.filename}`);
231
+ return result;
232
+ }
233
+
234
+ // ══════════════════════════════════════════════════════════════
235
+ // STEP 5 — Get Download URL
236
+ // GET /download/{filename}?mode=inline
237
+ // Source: app.py line 1472
238
+ //
239
+ // Returns: { status: "success", download_url: "https://s3.amazonaws.com/..." }
240
+ // ══════════════════════════════════════════════════════════════
241
+ async getDownloadUrl(filename) {
242
+ if (!filename) return null;
243
+
244
+ try {
245
+ const result = await this._fetch(
246
+ "GET",
247
+ `/download/${encodeURIComponent(filename)}?mode=inline`
248
+ );
249
+ return result.download_url || null;
250
+ } catch (err) {
251
+ console.warn(`⚠️ Download URL failed for ${filename}: ${err.message}`);
252
+ return null;
253
+ }
254
+ }
255
+ }
256
+
257
+ // ─── Utility ──────────────────────────────────────────────────
258
+ function sleep(ms) {
259
+ return new Promise(resolve => setTimeout(resolve, ms));
260
+ }
package/package.json CHANGED
@@ -1,51 +1,51 @@
1
- {
2
- "name": "kickload-watcher-mcp",
3
- "version": "0.1.0",
4
- "description": "Automated API performance testing for Claude Code teams via KickLoad",
5
- "main": "index.js",
6
- "type": "module",
7
- "bin": {
8
- "kickload-watcher-mcp": "./index.js"
9
- },
10
- "scripts": {
11
- "start": "node index.js",
12
- "dev": "node --watch index.js",
13
- "setup": "node setup.js"
14
- },
15
- "dependencies": {
16
- "@anthropic-ai/sdk": "^0.39.0",
17
- "@modelcontextprotocol/sdk": "^1.0.0",
18
- "chokidar": "^3.6.0",
19
- "nodemailer": "^6.9.0",
20
- "node-fetch": "^3.3.0",
21
- "form-data": "^4.0.0",
22
- "dotenv": "^16.4.0",
23
- "chalk": "^5.3.0",
24
- "unzipper": "^0.10.14",
25
- "tar": "^6.2.0"
26
- },
27
- "engines": {
28
- "node": ">=18.0.0"
29
- },
30
- "keywords": [
31
- "kickload",
32
- "mcp",
33
- "claude",
34
- "testing",
35
- "watcher",
36
- "oneqa"
37
- ],
38
- "author": "NeeyatAI",
39
- "license": "MIT",
40
- "repository": {
41
- "type": "git",
42
- "url": "https://github.com/KickLoad/kickload-watcher-mcp.git"
43
- },
44
- "homepage": "https://github.com/KickLoad/kickload-watcher-mcp",
45
- "files": [
46
- "**/*.js",
47
- "users.json",
48
- "README.md"
49
- ],
50
- "preferGlobal": true
51
- }
1
+ {
2
+ "name": "kickload-watcher-mcp",
3
+ "version": "0.1.2",
4
+ "description": "Automated API performance testing for Claude Code teams via KickLoad",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "kickload-watcher-mcp": "./index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node index.js",
12
+ "dev": "node --watch index.js",
13
+ "setup": "node setup.js"
14
+ },
15
+ "dependencies": {
16
+ "@anthropic-ai/sdk": "^0.39.0",
17
+ "@modelcontextprotocol/sdk": "^1.0.0",
18
+ "chokidar": "^3.6.0",
19
+ "nodemailer": "^6.9.0",
20
+ "node-fetch": "^3.3.0",
21
+ "form-data": "^4.0.0",
22
+ "dotenv": "^16.4.0",
23
+ "chalk": "^5.3.0",
24
+ "unzipper": "^0.10.14",
25
+ "tar": "^6.2.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "keywords": [
31
+ "kickload",
32
+ "mcp",
33
+ "claude",
34
+ "testing",
35
+ "watcher",
36
+ "oneqa"
37
+ ],
38
+ "author": "NeeyatAI",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/KickLoad/kickload-watcher-mcp.git"
43
+ },
44
+ "homepage": "https://github.com/KickLoad/kickload-watcher-mcp",
45
+ "files": [
46
+ "**/*.js",
47
+ "users.json",
48
+ "README.md"
49
+ ],
50
+ "preferGlobal": true
51
+ }