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.
- package/README.md +164 -233
- package/compliance-poller.js +216 -215
- package/config.js +113 -103
- package/email-sender.js +137 -137
- package/github-webhook.js +322 -321
- package/index.js +266 -270
- package/kickload-client.js +260 -254
- package/package.json +51 -51
- package/pipeline.js +448 -448
- package/setup.js +193 -201
package/index.js
CHANGED
|
@@ -1,270 +1,266 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// ── Global error guards — must be first ──────────────────────────────────────
|
|
4
|
-
process.on("uncaughtException", (err) => {
|
|
5
|
-
console.error("\n❌ Uncaught error:", err.message);
|
|
6
|
-
if (process.env.LOG_LEVEL === "debug") console.error(err.stack);
|
|
7
|
-
process.exit(1);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
process.on("unhandledRejection", (reason) => {
|
|
11
|
-
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
12
|
-
console.error("\n❌ Unhandled rejection:", msg);
|
|
13
|
-
if (process.env.LOG_LEVEL === "debug" && reason instanceof Error) console.error(reason.stack);
|
|
14
|
-
process.exit(1);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
// ── Node version check ────────────────────────────────────────────────────────
|
|
18
|
-
const nodeMajor = parseInt(process.versions.node.split(".")[0]);
|
|
19
|
-
if (nodeMajor < 18) {
|
|
20
|
-
console.error(`\n❌ Node.js 18+ required. You have ${process.versions.node}.`);
|
|
21
|
-
console.error(" Download: https://nodejs.org\n");
|
|
22
|
-
process.exit(1);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ── First-run setup (writes .env if missing, reloads env in-process) ─────────
|
|
26
|
-
// STARTUP FIX: runFirstRunSetup no longer exits — continues execution after writing .env
|
|
27
|
-
import { runFirstRunSetup, ensureGitignore } from "./setup.js";
|
|
28
|
-
await runFirstRunSetup();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// ──
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
logger.
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.log(
|
|
266
|
-
console.log(`
|
|
267
|
-
console.log(` KickLoad: ${config.kickload.baseUrl}`);
|
|
268
|
-
console.log(` ngrok : auto-mode (localhost backends tunneled automatically)`);
|
|
269
|
-
console.log(` Email : ${config.email.provider} ✓`);
|
|
270
|
-
console.log(` Watching: ${config.watcher.watchPaths.join(", ")}\n`);
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ── Global error guards — must be first ──────────────────────────────────────
|
|
4
|
+
process.on("uncaughtException", (err) => {
|
|
5
|
+
console.error("\n❌ Uncaught error:", err.message);
|
|
6
|
+
if (process.env.LOG_LEVEL === "debug") console.error(err.stack);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
process.on("unhandledRejection", (reason) => {
|
|
11
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
12
|
+
console.error("\n❌ Unhandled rejection:", msg);
|
|
13
|
+
if (process.env.LOG_LEVEL === "debug" && reason instanceof Error) console.error(reason.stack);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ── Node version check ────────────────────────────────────────────────────────
|
|
18
|
+
const nodeMajor = parseInt(process.versions.node.split(".")[0]);
|
|
19
|
+
if (nodeMajor < 18) {
|
|
20
|
+
console.error(`\n❌ Node.js 18+ required. You have ${process.versions.node}.`);
|
|
21
|
+
console.error(" Download: https://nodejs.org\n");
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── First-run setup (writes .env if missing, reloads env in-process) ─────────
|
|
26
|
+
// STARTUP FIX: runFirstRunSetup no longer exits — continues execution after writing .env
|
|
27
|
+
import { runFirstRunSetup, ensureGitignore } from "./setup.js";
|
|
28
|
+
await runFirstRunSetup();
|
|
29
|
+
|
|
30
|
+
const { default: dotenv } = await import("dotenv");
|
|
31
|
+
dotenv.config();
|
|
32
|
+
// ── All other imports depend on env being loaded ──────────────────────────────
|
|
33
|
+
const { Server } = await import("@modelcontextprotocol/sdk/server/index.js");
|
|
34
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
35
|
+
const { CallToolRequestSchema, ListToolsRequestSchema } = await import("@modelcontextprotocol/sdk/types.js");
|
|
36
|
+
const { config, validateConfig } = await import("./config.js");
|
|
37
|
+
const { initEmailSender, verifyEmailService, sendResultEmail } = await import("./email-sender.js");
|
|
38
|
+
const { watchIdentityMap, getEmailForUser } = await import("./identity-map.js");
|
|
39
|
+
const { startCompliancePoller, onNewSession } = await import("./compliance-poller.js");
|
|
40
|
+
const { startFileWatcher, onNewApiFile } = await import("./file-watcher.js");
|
|
41
|
+
const { runPhase1Pipeline, handleFileEvent, handleComplianceSession, handleGithubEvent } = await import("./pipeline.js");
|
|
42
|
+
const { generateTestPrompt } = await import("./test-generator.js");
|
|
43
|
+
const { startGithubWebhookServer, onGithubPR } = await import("./github-webhook.js");
|
|
44
|
+
const { ensureNgrokInstalled, stopNgrok } = await import("./ngrok-manager.js");
|
|
45
|
+
const { createLogger } = await import("./logger.js");
|
|
46
|
+
|
|
47
|
+
const logger = createLogger("main");
|
|
48
|
+
|
|
49
|
+
// ── Config validation (exits on missing required fields) ─────────────────────
|
|
50
|
+
validateConfig();
|
|
51
|
+
ensureGitignore();
|
|
52
|
+
|
|
53
|
+
// ── PROBLEM 2 — Email strict mode: init + verify before anything else ─────────
|
|
54
|
+
console.log("\n🔧 Initializing subsystems...");
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
initEmailSender();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(`\n❌ Email initialization failed: ${err.message}`);
|
|
60
|
+
console.error(" Check EMAIL_PROVIDER and related settings in .env\n");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Verify SMTP credentials — exits if broken
|
|
65
|
+
await verifyEmailService();
|
|
66
|
+
|
|
67
|
+
// ── ngrok binary pre-fetch (only if auth token is configured) ─────────────────
|
|
68
|
+
// PROBLEM 1: We no longer check NGROK_ENABLED at startup for localhost backends
|
|
69
|
+
// (pipeline.js handles that automatically). We still pre-install the binary
|
|
70
|
+
// if a token is present, so the first tunnel starts faster.
|
|
71
|
+
if (config.ngrok.authToken) {
|
|
72
|
+
logger.info("Pre-fetching ngrok binary...");
|
|
73
|
+
try {
|
|
74
|
+
await ensureNgrokInstalled();
|
|
75
|
+
logger.info("✅ ngrok ready.");
|
|
76
|
+
} catch (err) {
|
|
77
|
+
// Non-fatal — pipeline will handle the failure gracefully
|
|
78
|
+
logger.warn(`ngrok pre-install failed: ${err.message}`);
|
|
79
|
+
logger.warn("Localhost backends will attempt ngrok on first use.");
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
logger.warn("NGROK_AUTHTOKEN not set — ngrok will attempt unauthenticated tunnels for localhost backends.");
|
|
83
|
+
logger.warn("Add NGROK_AUTHTOKEN to .env to fix: https://dashboard.ngrok.com");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Init remaining subsystems ────────────────────────────────────────────────
|
|
87
|
+
watchIdentityMap();
|
|
88
|
+
|
|
89
|
+
onNewSession(handleComplianceSession);
|
|
90
|
+
onNewApiFile(handleFileEvent);
|
|
91
|
+
|
|
92
|
+
if (config.triggerMode === "claudecode" || config.triggerMode === "both") {
|
|
93
|
+
startCompliancePoller();
|
|
94
|
+
startFileWatcher();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (config.triggerMode === "github" || config.triggerMode === "both") {
|
|
98
|
+
onGithubPR(handleGithubEvent);
|
|
99
|
+
startGithubWebhookServer();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
103
|
+
const shutdown = (sig) => {
|
|
104
|
+
console.log(`\n🛑 ${sig} — shutting down`);
|
|
105
|
+
stopNgrok();
|
|
106
|
+
process.exit(0);
|
|
107
|
+
};
|
|
108
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
109
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
110
|
+
|
|
111
|
+
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
112
|
+
const server = new Server(
|
|
113
|
+
{ name: "kickload-watcher-mcp", version: "0.1.1" },
|
|
114
|
+
{ capabilities: { tools: {} } }
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
118
|
+
tools: [
|
|
119
|
+
{
|
|
120
|
+
name: "run_kickload_test",
|
|
121
|
+
description: "Trigger a KickLoad performance test for an API endpoint.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
endpoint: { type: "string", description: "API endpoint path, e.g. /api/checkout" },
|
|
126
|
+
description: { type: "string", description: "What the endpoint does" },
|
|
127
|
+
developer_email: { type: "string", description: "Email to send results to" },
|
|
128
|
+
num_threads: { type: "number", description: "Concurrent users" },
|
|
129
|
+
loop_count: { type: "number", description: "Loops per user" },
|
|
130
|
+
ramp_time: { type: "number", description: "Ramp-up seconds" },
|
|
131
|
+
},
|
|
132
|
+
required: ["endpoint", "developer_email"],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "get_watcher_status",
|
|
137
|
+
description: "Check the current watcher status.",
|
|
138
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "lookup_developer",
|
|
142
|
+
description: "Look up the email for a Claude user ID from users.json.",
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: { claude_user_id: { type: "string" } },
|
|
146
|
+
required: ["claude_user_id"],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: "send_test_email",
|
|
151
|
+
description: "Send a test email to verify email configuration.",
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: "object",
|
|
154
|
+
properties: { to_email: { type: "string" } },
|
|
155
|
+
required: ["to_email"],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
162
|
+
const { name, arguments: args } = request.params;
|
|
163
|
+
|
|
164
|
+
if (name === "run_kickload_test") {
|
|
165
|
+
const { endpoint, description, developer_email } = args;
|
|
166
|
+
try {
|
|
167
|
+
logger.info(`Manual test: ${endpoint}`);
|
|
168
|
+
let prompt = `Load test the ${endpoint} endpoint for performance and stability.`;
|
|
169
|
+
if (description) {
|
|
170
|
+
const p = await generateTestPrompt({
|
|
171
|
+
fileContent: `// ${endpoint}\n// ${description}`,
|
|
172
|
+
detectedEndpoints: [endpoint],
|
|
173
|
+
originalPrompt: description,
|
|
174
|
+
fileName: "mcp-manual",
|
|
175
|
+
});
|
|
176
|
+
prompt = p.testPrompt;
|
|
177
|
+
}
|
|
178
|
+
const result = await runPhase1Pipeline({
|
|
179
|
+
fileName: "mcp-manual",
|
|
180
|
+
fileContent: `// ${endpoint}`,
|
|
181
|
+
detectedEndpoints: [endpoint],
|
|
182
|
+
originalPrompt: prompt,
|
|
183
|
+
devEmail: developer_email,
|
|
184
|
+
userId: null,
|
|
185
|
+
source: "mcp_manual",
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
189
|
+
success: result.success,
|
|
190
|
+
status: result.summary?.passed ? "PASS" : "FAIL",
|
|
191
|
+
averageLatency: result.summary?.averageLatency,
|
|
192
|
+
errorPercentage: result.summary?.errorPercentage,
|
|
193
|
+
throughput: result.summary?.throughput,
|
|
194
|
+
downloadUrl: result.downloadUrl,
|
|
195
|
+
emailSentTo: developer_email,
|
|
196
|
+
}, null, 2) }],
|
|
197
|
+
};
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.error(`run_kickload_test failed: ${err.message}`);
|
|
200
|
+
return {
|
|
201
|
+
content: [{ type: "text", text: JSON.stringify({ success: false, error: err.message }) }],
|
|
202
|
+
isError: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (name === "get_watcher_status") {
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
210
|
+
status: "running",
|
|
211
|
+
version: "1.1.0",
|
|
212
|
+
triggerMode: config.triggerMode,
|
|
213
|
+
watchPaths: config.watcher.watchPaths,
|
|
214
|
+
emailProvider: config.email.provider,
|
|
215
|
+
kickloadBaseUrl: config.kickload.baseUrl,
|
|
216
|
+
ngrokAutoMode: "localhost backends auto-tunneled",
|
|
217
|
+
ngrokTokenSet: !!config.ngrok.authToken,
|
|
218
|
+
compliancePolling: !!config.anthropic.complianceApiKey,
|
|
219
|
+
}, null, 2) }],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (name === "lookup_developer") {
|
|
224
|
+
const email = getEmailForUser(args.claude_user_id);
|
|
225
|
+
return {
|
|
226
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
227
|
+
claudeUserId: args.claude_user_id,
|
|
228
|
+
email: email || null,
|
|
229
|
+
found: !!email,
|
|
230
|
+
}, null, 2) }],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (name === "send_test_email") {
|
|
235
|
+
try {
|
|
236
|
+
await sendResultEmail({
|
|
237
|
+
toEmail: args.to_email,
|
|
238
|
+
endpoint: "/api/test",
|
|
239
|
+
passed: true,
|
|
240
|
+
summary: { averageLatency: 123, errorPercentage: 0.5, throughput: 4500 },
|
|
241
|
+
downloadUrl: null,
|
|
242
|
+
pdfFilename: "test.pdf",
|
|
243
|
+
durationMs: 45000,
|
|
244
|
+
testPrompt: "Test email.",
|
|
245
|
+
});
|
|
246
|
+
return { content: [{ type: "text", text: JSON.stringify({ success: true }) }] };
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return {
|
|
249
|
+
content: [{ type: "text", text: JSON.stringify({ success: false, error: err.message }) }],
|
|
250
|
+
isError: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const transport = new StdioServerTransport();
|
|
259
|
+
await server.connect(transport);
|
|
260
|
+
|
|
261
|
+
console.log("\n✅ KickLoad Watcher running");
|
|
262
|
+
console.log(` Mode : ${config.triggerMode}`);
|
|
263
|
+
console.log(` KickLoad: ${config.kickload.baseUrl}`);
|
|
264
|
+
console.log(` ngrok : auto-mode (localhost backends tunneled automatically)`);
|
|
265
|
+
console.log(` Email : ${config.email.provider} ✓`);
|
|
266
|
+
console.log(` Watching: ${config.watcher.watchPaths.join(", ")}\n`);
|