@muggleai/mcp-qa-gateway 1.0.1
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 +185 -0
- package/dist/scripts/phase0-smoke.d.ts +7 -0
- package/dist/scripts/phase0-smoke.d.ts.map +1 -0
- package/dist/scripts/phase0-smoke.js +1712 -0
- package/dist/scripts/phase0-smoke.js.map +1 -0
- package/dist/scripts/types/phase0-smoke-types.d.ts +89 -0
- package/dist/scripts/types/phase0-smoke-types.d.ts.map +1 -0
- package/dist/scripts/types/phase0-smoke-types.js +3 -0
- package/dist/scripts/types/phase0-smoke-types.js.map +1 -0
- package/dist/src/__tests__/helpers/mock-setup.d.ts +28 -0
- package/dist/src/__tests__/helpers/mock-setup.d.ts.map +1 -0
- package/dist/src/__tests__/helpers/mock-setup.js +44 -0
- package/dist/src/__tests__/helpers/mock-setup.js.map +1 -0
- package/dist/src/__tests__/helpers/test-fixtures.d.ts +38 -0
- package/dist/src/__tests__/helpers/test-fixtures.d.ts.map +1 -0
- package/dist/src/__tests__/helpers/test-fixtures.js +62 -0
- package/dist/src/__tests__/helpers/test-fixtures.js.map +1 -0
- package/dist/src/__tests__/tools/artifact-tools.test.d.ts +9 -0
- package/dist/src/__tests__/tools/artifact-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/artifact-tools.test.js +307 -0
- package/dist/src/__tests__/tools/artifact-tools.test.js.map +1 -0
- package/dist/src/__tests__/tools/prd-file-tools.test.d.ts +7 -0
- package/dist/src/__tests__/tools/prd-file-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/prd-file-tools.test.js +278 -0
- package/dist/src/__tests__/tools/prd-file-tools.test.js.map +1 -0
- package/dist/src/__tests__/tools/project-tools.test.d.ts +7 -0
- package/dist/src/__tests__/tools/project-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/project-tools.test.js +281 -0
- package/dist/src/__tests__/tools/project-tools.test.js.map +1 -0
- package/dist/src/__tests__/tools/recommendation-tools.test.d.ts +6 -0
- package/dist/src/__tests__/tools/recommendation-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/recommendation-tools.test.js +193 -0
- package/dist/src/__tests__/tools/recommendation-tools.test.js.map +1 -0
- package/dist/src/__tests__/tools/report-tools.test.d.ts +7 -0
- package/dist/src/__tests__/tools/report-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/report-tools.test.js +226 -0
- package/dist/src/__tests__/tools/report-tools.test.js.map +1 -0
- package/dist/src/__tests__/tools/secret-tools.test.d.ts +6 -0
- package/dist/src/__tests__/tools/secret-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/secret-tools.test.js +243 -0
- package/dist/src/__tests__/tools/secret-tools.test.js.map +1 -0
- package/dist/src/__tests__/tools/test-case-prompt-tools.test.d.ts +6 -0
- package/dist/src/__tests__/tools/test-case-prompt-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/test-case-prompt-tools.test.js +291 -0
- package/dist/src/__tests__/tools/test-case-prompt-tools.test.js.map +1 -0
- package/dist/src/__tests__/tools/tool-registry.test.d.ts +7 -0
- package/dist/src/__tests__/tools/tool-registry.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/tool-registry.test.js +205 -0
- package/dist/src/__tests__/tools/tool-registry.test.js.map +1 -0
- package/dist/src/__tests__/tools/use-case-tools.test.d.ts +7 -0
- package/dist/src/__tests__/tools/use-case-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/use-case-tools.test.js +177 -0
- package/dist/src/__tests__/tools/use-case-tools.test.js.map +1 -0
- package/dist/src/__tests__/tools/workflow-tools.test.d.ts +15 -0
- package/dist/src/__tests__/tools/workflow-tools.test.d.ts.map +1 -0
- package/dist/src/__tests__/tools/workflow-tools.test.js +627 -0
- package/dist/src/__tests__/tools/workflow-tools.test.js.map +1 -0
- package/dist/src/config/index.d.ts +18 -0
- package/dist/src/config/index.d.ts.map +1 -0
- package/dist/src/config/index.js +76 -0
- package/dist/src/config/index.js.map +1 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +88 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/interfaces/caller-credentials.d.ts +10 -0
- package/dist/src/interfaces/caller-credentials.d.ts.map +1 -0
- package/dist/src/interfaces/caller-credentials.js +3 -0
- package/dist/src/interfaces/caller-credentials.js.map +1 -0
- package/dist/src/interfaces/gateway-config.d.ts +28 -0
- package/dist/src/interfaces/gateway-config.d.ts.map +1 -0
- package/dist/src/interfaces/gateway-config.js +3 -0
- package/dist/src/interfaces/gateway-config.js.map +1 -0
- package/dist/src/interfaces/index.d.ts +13 -0
- package/dist/src/interfaces/index.d.ts.map +1 -0
- package/dist/src/interfaces/index.js +7 -0
- package/dist/src/interfaces/index.js.map +1 -0
- package/dist/src/interfaces/mcp-tool-call-protocol.d.ts +61 -0
- package/dist/src/interfaces/mcp-tool-call-protocol.d.ts.map +1 -0
- package/dist/src/interfaces/mcp-tool-call-protocol.js +7 -0
- package/dist/src/interfaces/mcp-tool-call-protocol.js.map +1 -0
- package/dist/src/interfaces/pagination.d.ts +24 -0
- package/dist/src/interfaces/pagination.d.ts.map +1 -0
- package/dist/src/interfaces/pagination.js +6 -0
- package/dist/src/interfaces/pagination.js.map +1 -0
- package/dist/src/interfaces/tool-definition.d.ts +22 -0
- package/dist/src/interfaces/tool-definition.d.ts.map +1 -0
- package/dist/src/interfaces/tool-definition.js +6 -0
- package/dist/src/interfaces/tool-definition.js.map +1 -0
- package/dist/src/interfaces/tool-mapping.d.ts +20 -0
- package/dist/src/interfaces/tool-mapping.d.ts.map +1 -0
- package/dist/src/interfaces/tool-mapping.js +6 -0
- package/dist/src/interfaces/tool-mapping.js.map +1 -0
- package/dist/src/interfaces/upstream-call.d.ts +27 -0
- package/dist/src/interfaces/upstream-call.d.ts.map +1 -0
- package/dist/src/interfaces/upstream-call.js +3 -0
- package/dist/src/interfaces/upstream-call.js.map +1 -0
- package/dist/src/interfaces/upstream-response.d.ts +12 -0
- package/dist/src/interfaces/upstream-response.d.ts.map +1 -0
- package/dist/src/interfaces/upstream-response.js +3 -0
- package/dist/src/interfaces/upstream-response.js.map +1 -0
- package/dist/src/server/http-server.d.ts +13 -0
- package/dist/src/server/http-server.d.ts.map +1 -0
- package/dist/src/server/http-server.js +219 -0
- package/dist/src/server/http-server.js.map +1 -0
- package/dist/src/server/mcp-server.d.ts +11 -0
- package/dist/src/server/mcp-server.d.ts.map +1 -0
- package/dist/src/server/mcp-server.js +363 -0
- package/dist/src/server/mcp-server.js.map +1 -0
- package/dist/src/server/session-credentials.d.ts +8 -0
- package/dist/src/server/session-credentials.d.ts.map +1 -0
- package/dist/src/server/session-credentials.js +24 -0
- package/dist/src/server/session-credentials.js.map +1 -0
- package/dist/src/tools/constants.d.ts +6 -0
- package/dist/src/tools/constants.d.ts.map +1 -0
- package/dist/src/tools/constants.js +9 -0
- package/dist/src/tools/constants.js.map +1 -0
- package/dist/src/tools/index.d.ts +7 -0
- package/dist/src/tools/index.d.ts.map +1 -0
- package/dist/src/tools/index.js +23 -0
- package/dist/src/tools/index.js.map +1 -0
- package/dist/src/tools/schemas.d.ts +906 -0
- package/dist/src/tools/schemas.d.ts.map +1 -0
- package/dist/src/tools/schemas.js +386 -0
- package/dist/src/tools/schemas.js.map +1 -0
- package/dist/src/tools/tool-registry.d.ts +26 -0
- package/dist/src/tools/tool-registry.d.ts.map +1 -0
- package/dist/src/tools/tool-registry.js +1142 -0
- package/dist/src/tools/tool-registry.js.map +1 -0
- package/dist/src/types/index.d.ts +35 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +55 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/upstream/prompt-service-client.d.ts +62 -0
- package/dist/src/upstream/prompt-service-client.d.ts.map +1 -0
- package/dist/src/upstream/prompt-service-client.js +329 -0
- package/dist/src/upstream/prompt-service-client.js.map +1 -0
- package/dist/src/utils/logger.d.ts +17 -0
- package/dist/src/utils/logger.d.ts.map +1 -0
- package/dist/src/utils/logger.js +64 -0
- package/dist/src/utils/logger.js.map +1 -0
- package/dist/vitest.config.d.ts +3 -0
- package/dist/vitest.config.d.ts.map +1 -0
- package/dist/vitest.config.js +21 -0
- package/dist/vitest.config.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1712 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runPhase0SmokeAsync = runPhase0SmokeAsync;
|
|
7
|
+
/**
|
|
8
|
+
* Phase 0 end-to-end smoke runner for the MCP QA Gateway.
|
|
9
|
+
*
|
|
10
|
+
* How to run (from mcp-gateway):
|
|
11
|
+
* npx dotenv -e .env.staging -- tsx scripts/phase0-smoke.ts
|
|
12
|
+
*
|
|
13
|
+
* The script will automatically start the MCP Gateway server and run the tests.
|
|
14
|
+
*
|
|
15
|
+
* Optional env:
|
|
16
|
+
* MCP_QA_ARCHIVE_UNAPPROVED=true,
|
|
17
|
+
* MCP_QA_PRD_FILE_PATH (+ MCP_QA_PRD_CONTENT_TYPE),
|
|
18
|
+
* MCP_QA_REPORT_CHANNELS=email,sms,webhook (+ MCP_QA_REPORT_EMAILS/PHONES/WEBHOOK_URL)
|
|
19
|
+
*/
|
|
20
|
+
const node_child_process_1 = require("node:child_process");
|
|
21
|
+
const node_crypto_1 = require("node:crypto");
|
|
22
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
const axios_1 = __importDefault(require("axios"));
|
|
25
|
+
const TOOL_ENDPOINT_PATH = "/mcp";
|
|
26
|
+
const JSON_RPC_VERSION = "2.0";
|
|
27
|
+
const POLL_INTERVAL_MS = 5000;
|
|
28
|
+
const MAX_POLL_ATTEMPTS = 6;
|
|
29
|
+
const SERVER_STARTUP_TIMEOUT_MS = 30000;
|
|
30
|
+
const SERVER_HEALTH_CHECK_INTERVAL_MS = 500;
|
|
31
|
+
/** Delay after server health passes before first request (avoids ECONNRESET on back-to-back runs). */
|
|
32
|
+
const SERVER_READY_SETTLE_MS = 1500;
|
|
33
|
+
const SESSION_INIT_RETRY_ATTEMPTS = 3;
|
|
34
|
+
const SESSION_INIT_RETRY_DELAY_MS = 1000;
|
|
35
|
+
const PRD_PROCESS_POLL_INTERVAL_MS = 5000;
|
|
36
|
+
const PRD_PROCESS_MAX_POLL_ATTEMPTS = 60;
|
|
37
|
+
const TEST_CASE_DETECTION_POLL_INTERVAL_MS = 5000;
|
|
38
|
+
const TEST_CASE_DETECTION_MAX_POLL_ATTEMPTS = 60;
|
|
39
|
+
const TEST_SCRIPT_GENERATION_POLL_INTERVAL_MS = 5000;
|
|
40
|
+
const TEST_SCRIPT_GENERATION_MAX_POLL_ATTEMPTS = 120;
|
|
41
|
+
const WEBSITE_SCAN_POLL_INTERVAL_MS = 10000;
|
|
42
|
+
const WEBSITE_SCAN_MAX_POLL_ATTEMPTS = 60;
|
|
43
|
+
const DISCOVERY_MEMORY_POLL_INTERVAL_MS = 10000;
|
|
44
|
+
const DISCOVERY_MEMORY_MAX_POLL_ATTEMPTS = 60;
|
|
45
|
+
const CLIENT_NAME = "mcp-gateway-phase0-smoke";
|
|
46
|
+
const CLIENT_VERSION = "1.0.0";
|
|
47
|
+
let currentSessionId;
|
|
48
|
+
const DEBUG_RAW_RESPONSES = process.env["MCP_SMOKE_DEBUG_RAW_RESPONSES"] === "true";
|
|
49
|
+
/** Terminal workflow run statuses. */
|
|
50
|
+
const TERMINAL_STATUSES = ["COMPLETED", "FAILED", "CANCELLED", "TIMEOUT"];
|
|
51
|
+
/**
|
|
52
|
+
* Maximum use cases to approve in smoke test.
|
|
53
|
+
* Keeps within typical user quotas and is sufficient to validate the workflow.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_USE_CASES_TO_APPROVE = 1;
|
|
56
|
+
const TOOL_NAMES = {
|
|
57
|
+
projectCreate: "qa_project_create",
|
|
58
|
+
projectGet: "qa_project_get",
|
|
59
|
+
projectList: "qa_project_list",
|
|
60
|
+
projectDelete: "qa_project_delete",
|
|
61
|
+
prdUpload: "qa_prd_file_upload",
|
|
62
|
+
prdProcessStart: "qa_workflow_start_prd_file_process",
|
|
63
|
+
prdProcessLatestRun: "qa_workflow_get_prd_file_process_latest_run",
|
|
64
|
+
useCaseDiscovery: "qa_use_case_discovery_memory_get",
|
|
65
|
+
useCaseApprove: "qa_use_case_candidates_approve",
|
|
66
|
+
useCaseList: "qa_use_case_list",
|
|
67
|
+
workflowStartWebsiteScan: "qa_workflow_start_website_scan",
|
|
68
|
+
workflowGetWebsiteScanLatestRun: "qa_workflow_get_website_scan_latest_run",
|
|
69
|
+
workflowStartTestCaseDetection: "qa_workflow_start_test_case_detection",
|
|
70
|
+
workflowGetTestCaseDetectionLatestRun: "qa_workflow_get_test_case_detection_latest_run",
|
|
71
|
+
workflowStartTestScriptGeneration: "qa_workflow_start_test_script_generation",
|
|
72
|
+
workflowGetTestScriptGenerationLatestRun: "qa_workflow_get_test_script_generation_latest_run",
|
|
73
|
+
workflowStartTestScriptReplayBulk: "qa_workflow_start_test_script_replay_bulk",
|
|
74
|
+
testCaseList: "qa_test_case_list",
|
|
75
|
+
testCaseGet: "qa_test_case_get",
|
|
76
|
+
testCaseGenerateFromPrompt: "qa_test_case_generate_from_prompt",
|
|
77
|
+
testCaseCreate: "qa_test_case_create",
|
|
78
|
+
testScriptList: "qa_test_script_list",
|
|
79
|
+
reportFinalGenerate: "qa_report_final_generate",
|
|
80
|
+
reportPreferencesUpsert: "qa_report_preferences_upsert",
|
|
81
|
+
};
|
|
82
|
+
let sharedHttpClient = null;
|
|
83
|
+
let serverProcess = null;
|
|
84
|
+
/**
|
|
85
|
+
* Extract port number from a base URL.
|
|
86
|
+
*/
|
|
87
|
+
function extractPort(baseUrl) {
|
|
88
|
+
const url = new URL(baseUrl);
|
|
89
|
+
if (url.port) {
|
|
90
|
+
return parseInt(url.port, 10);
|
|
91
|
+
}
|
|
92
|
+
return url.protocol === "https:" ? 443 : 80;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Kill any process listening on the specified port.
|
|
96
|
+
* This is especially important on Windows where killed processes may leave the port bound.
|
|
97
|
+
*/
|
|
98
|
+
function killProcessOnPort(port) {
|
|
99
|
+
try {
|
|
100
|
+
if (process.platform === "win32") {
|
|
101
|
+
// Windows: use netstat to find PID, then taskkill
|
|
102
|
+
const netstatOutput = (0, node_child_process_1.execSync)(`netstat -ano | findstr :${port}`, {
|
|
103
|
+
encoding: "utf8",
|
|
104
|
+
windowsHide: true,
|
|
105
|
+
});
|
|
106
|
+
// Parse lines like " TCP 0.0.0.0:6000 0.0.0.0:0 LISTENING 12345"
|
|
107
|
+
const lines = netstatOutput.split("\n").filter((line) => line.includes("LISTENING"));
|
|
108
|
+
const pids = new Set();
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const parts = line.trim().split(/\s+/);
|
|
111
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
112
|
+
if (!isNaN(pid) && pid > 0) {
|
|
113
|
+
pids.add(pid);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const pid of pids) {
|
|
117
|
+
try {
|
|
118
|
+
(0, node_child_process_1.execSync)(`taskkill /F /PID ${pid}`, {
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
stdio: "ignore",
|
|
122
|
+
});
|
|
123
|
+
console.log(`Killed process ${pid} on port ${port}`);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Process may have already exited
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
// Unix: use lsof to find PID
|
|
132
|
+
const lsofOutput = (0, node_child_process_1.execSync)(`lsof -ti :${port}`, {
|
|
133
|
+
encoding: "utf8",
|
|
134
|
+
});
|
|
135
|
+
const pids = lsofOutput
|
|
136
|
+
.split("\n")
|
|
137
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
138
|
+
.filter((p) => !isNaN(p) && p > 0);
|
|
139
|
+
for (const pid of pids) {
|
|
140
|
+
try {
|
|
141
|
+
(0, node_child_process_1.execSync)(`kill -9 ${pid}`, { stdio: "ignore" });
|
|
142
|
+
console.log(`Killed process ${pid} on port ${port}`);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Process may have already exited
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// No process found on port - this is fine
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Start the MCP Gateway server and wait for it to be ready.
|
|
156
|
+
* @returns Promise that resolves when server is ready.
|
|
157
|
+
*/
|
|
158
|
+
async function startServerAsync(baseUrl) {
|
|
159
|
+
console.log("Starting MCP Gateway server...");
|
|
160
|
+
// Kill any existing process on the port (handles stale processes from previous runs)
|
|
161
|
+
const port = extractPort(baseUrl);
|
|
162
|
+
killProcessOnPort(port);
|
|
163
|
+
// Give OS time to release the port after killing
|
|
164
|
+
await sleepAsync(500);
|
|
165
|
+
// Check if server is already running (shouldn't be after killProcessOnPort, but just in case)
|
|
166
|
+
const isAlreadyRunning = await checkServerHealthAsync(baseUrl);
|
|
167
|
+
if (isAlreadyRunning) {
|
|
168
|
+
console.log("MCP Gateway server is already running");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// Start the server process from the current working directory
|
|
172
|
+
// The script should be run from the mcp-gateway folder
|
|
173
|
+
const gatewayDir = process.cwd();
|
|
174
|
+
// Use npx tsx to start the server directly
|
|
175
|
+
serverProcess = (0, node_child_process_1.spawn)("npx", ["tsx", "src/index.ts"], {
|
|
176
|
+
cwd: gatewayDir,
|
|
177
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
178
|
+
shell: true,
|
|
179
|
+
env: {
|
|
180
|
+
...process.env,
|
|
181
|
+
// Ensure the server inherits the environment
|
|
182
|
+
FORCE_COLOR: "0",
|
|
183
|
+
},
|
|
184
|
+
windowsHide: true,
|
|
185
|
+
});
|
|
186
|
+
// Log server output for debugging
|
|
187
|
+
serverProcess.stdout?.on("data", (data) => {
|
|
188
|
+
const message = data.toString().trim();
|
|
189
|
+
if (message) {
|
|
190
|
+
// Always log first few messages to show startup
|
|
191
|
+
console.log(`[server] ${message}`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
serverProcess.stderr?.on("data", (data) => {
|
|
195
|
+
const message = data.toString().trim();
|
|
196
|
+
if (message) {
|
|
197
|
+
console.error(`[server:err] ${message}`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
serverProcess.on("error", (error) => {
|
|
201
|
+
console.error("Failed to start server process:", error.message);
|
|
202
|
+
});
|
|
203
|
+
serverProcess.on("exit", (code) => {
|
|
204
|
+
if (code !== null && code !== 0) {
|
|
205
|
+
console.error(`Server exited with code ${code}`);
|
|
206
|
+
}
|
|
207
|
+
serverProcess = null;
|
|
208
|
+
});
|
|
209
|
+
// Wait for server to be ready
|
|
210
|
+
const startTime = Date.now();
|
|
211
|
+
while (Date.now() - startTime < SERVER_STARTUP_TIMEOUT_MS) {
|
|
212
|
+
const isReady = await checkServerHealthAsync(baseUrl);
|
|
213
|
+
if (isReady) {
|
|
214
|
+
console.log("MCP Gateway server is ready");
|
|
215
|
+
await sleepAsync(SERVER_READY_SETTLE_MS);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await sleepAsync(SERVER_HEALTH_CHECK_INTERVAL_MS);
|
|
219
|
+
}
|
|
220
|
+
// Timeout - kill the server and throw
|
|
221
|
+
stopServer();
|
|
222
|
+
throw new Error("Server failed to start within timeout");
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Check if the server is healthy and responding.
|
|
226
|
+
*/
|
|
227
|
+
async function checkServerHealthAsync(baseUrl) {
|
|
228
|
+
try {
|
|
229
|
+
const response = await axios_1.default.get(`${baseUrl}/health`, {
|
|
230
|
+
timeout: 2000,
|
|
231
|
+
validateStatus: () => true,
|
|
232
|
+
});
|
|
233
|
+
return response.status === 200;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Stop the MCP Gateway server if it was started by this script.
|
|
241
|
+
* Waits for process to exit and cleans up HTTP client connections.
|
|
242
|
+
*/
|
|
243
|
+
async function stopServerAsync() {
|
|
244
|
+
// Reset HTTP client so next run doesn't reuse stale connections
|
|
245
|
+
if (sharedHttpClient) {
|
|
246
|
+
sharedHttpClient = null;
|
|
247
|
+
}
|
|
248
|
+
if (!serverProcess) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
console.log("Stopping MCP Gateway server...");
|
|
252
|
+
const proc = serverProcess;
|
|
253
|
+
serverProcess = null;
|
|
254
|
+
return new Promise((resolve) => {
|
|
255
|
+
const timeout = setTimeout(() => {
|
|
256
|
+
// Force kill if graceful shutdown takes too long
|
|
257
|
+
try {
|
|
258
|
+
proc.kill("SIGKILL");
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Ignore - process may already be dead
|
|
262
|
+
}
|
|
263
|
+
resolve();
|
|
264
|
+
}, 5000);
|
|
265
|
+
proc.on("exit", () => {
|
|
266
|
+
clearTimeout(timeout);
|
|
267
|
+
resolve();
|
|
268
|
+
});
|
|
269
|
+
// On Windows, SIGTERM may not work; try SIGKILL directly
|
|
270
|
+
if (process.platform === "win32") {
|
|
271
|
+
try {
|
|
272
|
+
proc.kill();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Ignore
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
proc.kill("SIGTERM");
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Synchronous stop for signal handlers (best-effort).
|
|
285
|
+
*/
|
|
286
|
+
function stopServer() {
|
|
287
|
+
if (sharedHttpClient) {
|
|
288
|
+
sharedHttpClient = null;
|
|
289
|
+
}
|
|
290
|
+
if (serverProcess) {
|
|
291
|
+
console.log("Stopping MCP Gateway server...");
|
|
292
|
+
try {
|
|
293
|
+
if (process.platform === "win32") {
|
|
294
|
+
serverProcess.kill();
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
serverProcess.kill("SIGTERM");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// Ignore
|
|
302
|
+
}
|
|
303
|
+
serverProcess = null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Execute the Phase 0 MCP QA workflow end-to-end against the gateway.
|
|
308
|
+
* Returns a structured result instead of throwing errors.
|
|
309
|
+
*/
|
|
310
|
+
async function runPhase0SmokeAsync() {
|
|
311
|
+
const summary = {};
|
|
312
|
+
let currentStep = "initialization";
|
|
313
|
+
// These must be accessible in finally block for cleanup
|
|
314
|
+
const config = loadConfigFromEnv();
|
|
315
|
+
const httpClient = getHttpClient(config);
|
|
316
|
+
try {
|
|
317
|
+
console.log("Starting Phase 0 smoke with config", {
|
|
318
|
+
baseUrl: config.mcpGatewayBaseUrl,
|
|
319
|
+
hasAuthorizationHeader: Boolean(config.authorizationHeader),
|
|
320
|
+
hasApiKeyHeader: Boolean(config.apiKeyHeader),
|
|
321
|
+
projectId: config.projectId,
|
|
322
|
+
});
|
|
323
|
+
currentStep = "session_initialization";
|
|
324
|
+
const seededSessionId = (0, node_crypto_1.randomUUID)();
|
|
325
|
+
const sessionId = await initializeSessionAsync({
|
|
326
|
+
httpClient: httpClient,
|
|
327
|
+
sessionId: seededSessionId,
|
|
328
|
+
});
|
|
329
|
+
if (sessionId) {
|
|
330
|
+
currentSessionId = sessionId;
|
|
331
|
+
httpClient.defaults.headers.common["Mcp-Session-Id"] = sessionId;
|
|
332
|
+
console.log("MCP session initialized", { sessionId: sessionId });
|
|
333
|
+
}
|
|
334
|
+
currentStep = "project_resolution";
|
|
335
|
+
const projectId = await resolveProjectIdAsync({ config: config, httpClient: httpClient });
|
|
336
|
+
summary.projectId = projectId;
|
|
337
|
+
// Fast path: check if project already has approved use cases with test cases
|
|
338
|
+
currentStep = "existing_data_check";
|
|
339
|
+
const existingData = await checkExistingTestDataAsync({
|
|
340
|
+
httpClient: httpClient,
|
|
341
|
+
projectId: projectId,
|
|
342
|
+
});
|
|
343
|
+
if (existingData.hasTestData) {
|
|
344
|
+
console.log("Fast path: using existing test data", {
|
|
345
|
+
useCaseId: existingData.useCaseId,
|
|
346
|
+
testCaseId: existingData.testCaseId,
|
|
347
|
+
});
|
|
348
|
+
// Skip directly to report generation
|
|
349
|
+
currentStep = "report_generation";
|
|
350
|
+
await generateReportAsync({
|
|
351
|
+
httpClient: httpClient,
|
|
352
|
+
projectId: projectId,
|
|
353
|
+
});
|
|
354
|
+
// Project deletion handled by finally block
|
|
355
|
+
return {
|
|
356
|
+
success: true,
|
|
357
|
+
summary: summary,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
currentStep = "prd_upload";
|
|
361
|
+
await maybeUploadPrdAsync({
|
|
362
|
+
config: config,
|
|
363
|
+
httpClient: httpClient,
|
|
364
|
+
projectId: projectId,
|
|
365
|
+
});
|
|
366
|
+
currentStep = "website_scan_start";
|
|
367
|
+
const websiteScanRuntimeId = await startWebsiteScanAsync({
|
|
368
|
+
httpClient: httpClient,
|
|
369
|
+
projectId: projectId,
|
|
370
|
+
websiteUrl: config.websiteUrl,
|
|
371
|
+
scanDescription: config.scanDescription,
|
|
372
|
+
archiveUnapproved: config.archiveUnapproved,
|
|
373
|
+
});
|
|
374
|
+
summary.websiteScanRuntimeId = websiteScanRuntimeId;
|
|
375
|
+
currentStep = "website_scan_wait";
|
|
376
|
+
await waitForWebsiteScanCompletionAsync({
|
|
377
|
+
httpClient: httpClient,
|
|
378
|
+
workflowRuntimeId: websiteScanRuntimeId,
|
|
379
|
+
});
|
|
380
|
+
currentStep = "use_case_discovery";
|
|
381
|
+
const discoveryMemory = await waitForUseCaseDiscoveryAsync({
|
|
382
|
+
httpClient: httpClient,
|
|
383
|
+
projectId: projectId,
|
|
384
|
+
});
|
|
385
|
+
currentStep = "use_case_approval";
|
|
386
|
+
const approvedUseCaseIds = await approveUseCaseCandidatesAsync({
|
|
387
|
+
httpClient: httpClient,
|
|
388
|
+
projectId: projectId,
|
|
389
|
+
useCaseCandidates: discoveryMemory.useCaseCandidates,
|
|
390
|
+
});
|
|
391
|
+
currentStep = "use_case_resolution";
|
|
392
|
+
const primaryUseCaseId = await resolveUseCaseIdAsync({
|
|
393
|
+
httpClient: httpClient,
|
|
394
|
+
projectId: projectId,
|
|
395
|
+
preferredUseCaseIds: approvedUseCaseIds,
|
|
396
|
+
});
|
|
397
|
+
currentStep = "test_case_detection_start";
|
|
398
|
+
const testCaseDetectionRuntimeId = await startTestCaseDetectionAsync({
|
|
399
|
+
httpClient: httpClient,
|
|
400
|
+
projectId: projectId,
|
|
401
|
+
useCaseId: primaryUseCaseId,
|
|
402
|
+
url: config.websiteUrl,
|
|
403
|
+
});
|
|
404
|
+
summary.testCaseDetectionRuntimeId = testCaseDetectionRuntimeId;
|
|
405
|
+
console.log("Started test case detection workflow", {
|
|
406
|
+
workflowRuntimeId: testCaseDetectionRuntimeId,
|
|
407
|
+
});
|
|
408
|
+
currentStep = "test_case_detection_wait";
|
|
409
|
+
await waitForTestCaseDetectionCompletionAsync({
|
|
410
|
+
httpClient: httpClient,
|
|
411
|
+
workflowRuntimeId: testCaseDetectionRuntimeId,
|
|
412
|
+
});
|
|
413
|
+
currentStep = "test_case_resolution";
|
|
414
|
+
const testCaseId = await resolveTestCaseIdAsync({
|
|
415
|
+
httpClient: httpClient,
|
|
416
|
+
projectId: projectId,
|
|
417
|
+
useCaseId: primaryUseCaseId,
|
|
418
|
+
});
|
|
419
|
+
currentStep = "test_script_generation_start";
|
|
420
|
+
const scriptGenerationRuntimeId = await startTestScriptGenerationAsync({
|
|
421
|
+
httpClient: httpClient,
|
|
422
|
+
projectId: projectId,
|
|
423
|
+
testCaseId: testCaseId,
|
|
424
|
+
useCaseId: primaryUseCaseId,
|
|
425
|
+
});
|
|
426
|
+
summary.scriptGenerationRuntimeId = scriptGenerationRuntimeId;
|
|
427
|
+
console.log("Started test script generation workflow", {
|
|
428
|
+
workflowRuntimeId: scriptGenerationRuntimeId,
|
|
429
|
+
});
|
|
430
|
+
currentStep = "test_script_generation_wait";
|
|
431
|
+
await waitForTestScriptGenerationCompletionAsync({
|
|
432
|
+
httpClient: httpClient,
|
|
433
|
+
workflowRuntimeId: scriptGenerationRuntimeId,
|
|
434
|
+
});
|
|
435
|
+
currentStep = "test_script_resolution";
|
|
436
|
+
const testScriptId = await resolveTestScriptIdAsync({
|
|
437
|
+
httpClient: httpClient,
|
|
438
|
+
projectId: projectId,
|
|
439
|
+
});
|
|
440
|
+
summary.testScriptId = testScriptId;
|
|
441
|
+
currentStep = "test_script_replay";
|
|
442
|
+
await startTestScriptReplayBulkAsync({
|
|
443
|
+
httpClient: httpClient,
|
|
444
|
+
projectId: projectId,
|
|
445
|
+
testCaseIds: [testCaseId],
|
|
446
|
+
});
|
|
447
|
+
currentStep = "report_generation";
|
|
448
|
+
console.log("Generating final report...");
|
|
449
|
+
await generateReportAsync({
|
|
450
|
+
httpClient: httpClient,
|
|
451
|
+
projectId: projectId,
|
|
452
|
+
});
|
|
453
|
+
currentStep = "report_preferences";
|
|
454
|
+
if (config.reportChannels && config.reportChannels.length > 0) {
|
|
455
|
+
await upsertReportPreferencesAsync({
|
|
456
|
+
httpClient: httpClient,
|
|
457
|
+
projectId: projectId,
|
|
458
|
+
channels: config.reportChannels,
|
|
459
|
+
emails: config.reportEmails,
|
|
460
|
+
phones: config.reportPhones,
|
|
461
|
+
webhookUrl: config.reportWebhookUrl,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
console.log("Report delivery preferences skipped: no channels provided");
|
|
466
|
+
}
|
|
467
|
+
console.log("Phase 0 smoke workflow completed", {
|
|
468
|
+
projectId: projectId,
|
|
469
|
+
websiteScanRuntimeId: websiteScanRuntimeId,
|
|
470
|
+
testCaseDetectionRuntimeId: testCaseDetectionRuntimeId,
|
|
471
|
+
scriptGenerationRuntimeId: scriptGenerationRuntimeId,
|
|
472
|
+
testScriptId: testScriptId,
|
|
473
|
+
});
|
|
474
|
+
return {
|
|
475
|
+
success: true,
|
|
476
|
+
summary: summary,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
const errorMessage = extractErrorMessage(error);
|
|
481
|
+
console.error(`Smoke test failed at step [${currentStep}]: ${errorMessage}`);
|
|
482
|
+
return {
|
|
483
|
+
success: false,
|
|
484
|
+
error: errorMessage,
|
|
485
|
+
failedStep: currentStep,
|
|
486
|
+
summary: summary,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
// Always clean up: delete the project regardless of success or failure
|
|
491
|
+
if (summary.projectId) {
|
|
492
|
+
try {
|
|
493
|
+
console.log("Cleaning up: deleting project...");
|
|
494
|
+
await deleteProjectAsync({
|
|
495
|
+
httpClient: httpClient,
|
|
496
|
+
projectId: summary.projectId,
|
|
497
|
+
});
|
|
498
|
+
console.log("Project deleted successfully");
|
|
499
|
+
}
|
|
500
|
+
catch (cleanupError) {
|
|
501
|
+
console.warn(`Failed to delete project ${summary.projectId}: ${extractErrorMessage(cleanupError)}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Extract a clean error message from an unknown error.
|
|
508
|
+
*/
|
|
509
|
+
function extractErrorMessage(error) {
|
|
510
|
+
if (error instanceof Error) {
|
|
511
|
+
// Handle axios errors with code property
|
|
512
|
+
const axiosError = error;
|
|
513
|
+
if (axiosError.code === "ECONNREFUSED") {
|
|
514
|
+
return "Cannot connect to MCP Gateway - server may not be running";
|
|
515
|
+
}
|
|
516
|
+
if (axiosError.code === "ENOTFOUND") {
|
|
517
|
+
return "Cannot resolve MCP Gateway host - check the URL";
|
|
518
|
+
}
|
|
519
|
+
if (axiosError.code === "ETIMEDOUT") {
|
|
520
|
+
return "Connection to MCP Gateway timed out";
|
|
521
|
+
}
|
|
522
|
+
return error.message || "Unknown error";
|
|
523
|
+
}
|
|
524
|
+
if (typeof error === "string") {
|
|
525
|
+
return error;
|
|
526
|
+
}
|
|
527
|
+
if (typeof error === "object" && error !== null) {
|
|
528
|
+
const errorObj = error;
|
|
529
|
+
if (errorObj.code === "ECONNREFUSED") {
|
|
530
|
+
return "Cannot connect to MCP Gateway - server may not be running";
|
|
531
|
+
}
|
|
532
|
+
return errorObj.message || errorObj.error || "Unknown error";
|
|
533
|
+
}
|
|
534
|
+
return "Unknown error";
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Entry point when invoked directly.
|
|
538
|
+
*/
|
|
539
|
+
async function main() {
|
|
540
|
+
const baseUrl = process.env["MCP_GATEWAY_BASE_URL"] || "http://localhost:6000";
|
|
541
|
+
let serverStartedByUs = false;
|
|
542
|
+
// Async cleanup for normal exit paths (waits for process to terminate)
|
|
543
|
+
const cleanupAsync = async () => {
|
|
544
|
+
if (serverStartedByUs) {
|
|
545
|
+
await stopServerAsync();
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
// Sync cleanup for signal handlers (best-effort)
|
|
549
|
+
const cleanupSync = () => {
|
|
550
|
+
if (serverStartedByUs) {
|
|
551
|
+
stopServer();
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
process.on("SIGINT", () => {
|
|
555
|
+
console.log("\nReceived SIGINT, cleaning up...");
|
|
556
|
+
cleanupSync();
|
|
557
|
+
process.exit(130);
|
|
558
|
+
});
|
|
559
|
+
process.on("SIGTERM", () => {
|
|
560
|
+
console.log("\nReceived SIGTERM, cleaning up...");
|
|
561
|
+
cleanupSync();
|
|
562
|
+
process.exit(143);
|
|
563
|
+
});
|
|
564
|
+
try {
|
|
565
|
+
// Always kill any existing process on the port first to avoid stale/zombie processes
|
|
566
|
+
const port = extractPort(baseUrl);
|
|
567
|
+
killProcessOnPort(port);
|
|
568
|
+
await sleepAsync(500);
|
|
569
|
+
// Check if server is already running (shouldn't be after killing, but just in case)
|
|
570
|
+
const isRunning = await checkServerHealthAsync(baseUrl);
|
|
571
|
+
if (!isRunning) {
|
|
572
|
+
await startServerAsync(baseUrl);
|
|
573
|
+
serverStartedByUs = true;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
console.log("MCP Gateway server is already running (external)");
|
|
577
|
+
}
|
|
578
|
+
const result = await runPhase0SmokeAsync();
|
|
579
|
+
if (result.success) {
|
|
580
|
+
console.log("\n========================================");
|
|
581
|
+
console.log("SMOKE TEST PASSED");
|
|
582
|
+
console.log("========================================");
|
|
583
|
+
if (result.summary) {
|
|
584
|
+
console.log("Summary:", JSON.stringify(result.summary, null, 2));
|
|
585
|
+
}
|
|
586
|
+
await cleanupAsync();
|
|
587
|
+
process.exit(0);
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
console.log("\n========================================");
|
|
591
|
+
console.log("SMOKE TEST FAILED");
|
|
592
|
+
console.log("========================================");
|
|
593
|
+
console.log(`Step: ${result.failedStep}`);
|
|
594
|
+
console.log(`Error: ${result.error}`);
|
|
595
|
+
if (result.summary && Object.keys(result.summary).length > 0) {
|
|
596
|
+
console.log("Partial progress:", JSON.stringify(result.summary, null, 2));
|
|
597
|
+
}
|
|
598
|
+
await cleanupAsync();
|
|
599
|
+
process.exit(1);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
console.error("Unexpected error:", extractErrorMessage(error));
|
|
604
|
+
await cleanupAsync();
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
void main();
|
|
609
|
+
/**
|
|
610
|
+
* Load and validate configuration from environment variables.
|
|
611
|
+
*/
|
|
612
|
+
function loadConfigFromEnv() {
|
|
613
|
+
const baseUrl = process.env["MCP_GATEWAY_BASE_URL"];
|
|
614
|
+
const authorizationHeader = process.env["MCP_GATEWAY_AUTH_TOKEN"];
|
|
615
|
+
const apiKeyHeader = process.env["MCP_GATEWAY_API_KEY"];
|
|
616
|
+
const projectId = process.env["MCP_QA_PROJECT_ID"];
|
|
617
|
+
const projectName = process.env["MCP_QA_PROJECT_NAME"];
|
|
618
|
+
const projectDescription = process.env["MCP_QA_PROJECT_DESCRIPTION"];
|
|
619
|
+
const websiteUrl = process.env["MCP_QA_WEBSITE_URL"];
|
|
620
|
+
const scanDescription = process.env["MCP_QA_SCAN_DESCRIPTION"];
|
|
621
|
+
const archiveUnapproved = process.env["MCP_QA_ARCHIVE_UNAPPROVED"] === "true";
|
|
622
|
+
const prdFilePath = process.env["MCP_QA_PRD_FILE_PATH"];
|
|
623
|
+
const prdContentType = process.env["MCP_QA_PRD_CONTENT_TYPE"];
|
|
624
|
+
const reportChannelsRaw = process.env["MCP_QA_REPORT_CHANNELS"];
|
|
625
|
+
const reportEmailsRaw = process.env["MCP_QA_REPORT_EMAILS"];
|
|
626
|
+
const reportPhonesRaw = process.env["MCP_QA_REPORT_PHONES"];
|
|
627
|
+
const reportWebhookUrl = process.env["MCP_QA_REPORT_WEBHOOK_URL"];
|
|
628
|
+
if (!baseUrl) {
|
|
629
|
+
throw new Error("Missing MCP_GATEWAY_BASE_URL");
|
|
630
|
+
}
|
|
631
|
+
if (!authorizationHeader && !apiKeyHeader) {
|
|
632
|
+
throw new Error("Missing credentials: set MCP_GATEWAY_AUTH_TOKEN or MCP_GATEWAY_API_KEY");
|
|
633
|
+
}
|
|
634
|
+
if (!websiteUrl) {
|
|
635
|
+
throw new Error("Missing MCP_QA_WEBSITE_URL");
|
|
636
|
+
}
|
|
637
|
+
if (!scanDescription) {
|
|
638
|
+
throw new Error("Missing MCP_QA_SCAN_DESCRIPTION");
|
|
639
|
+
}
|
|
640
|
+
if (!projectId) {
|
|
641
|
+
if (!projectName) {
|
|
642
|
+
throw new Error("Missing MCP_QA_PROJECT_NAME for project creation");
|
|
643
|
+
}
|
|
644
|
+
if (!projectDescription) {
|
|
645
|
+
throw new Error("Missing MCP_QA_PROJECT_DESCRIPTION for project creation");
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const reportChannels = reportChannelsRaw
|
|
649
|
+
? reportChannelsRaw
|
|
650
|
+
.split(",")
|
|
651
|
+
.map((channel) => channel.trim())
|
|
652
|
+
.filter((channel) => channel.length > 0)
|
|
653
|
+
: undefined;
|
|
654
|
+
const reportEmails = reportEmailsRaw
|
|
655
|
+
? reportEmailsRaw
|
|
656
|
+
.split(",")
|
|
657
|
+
.map((email) => email.trim())
|
|
658
|
+
.filter((email) => email.length > 0)
|
|
659
|
+
: undefined;
|
|
660
|
+
const reportPhones = reportPhonesRaw
|
|
661
|
+
? reportPhonesRaw
|
|
662
|
+
.split(",")
|
|
663
|
+
.map((phone) => phone.trim())
|
|
664
|
+
.filter((phone) => phone.length > 0)
|
|
665
|
+
: undefined;
|
|
666
|
+
return {
|
|
667
|
+
mcpGatewayBaseUrl: baseUrl,
|
|
668
|
+
authorizationHeader: authorizationHeader,
|
|
669
|
+
apiKeyHeader: apiKeyHeader,
|
|
670
|
+
projectId: projectId,
|
|
671
|
+
projectName: projectName,
|
|
672
|
+
projectDescription: projectDescription,
|
|
673
|
+
scanDescription: scanDescription,
|
|
674
|
+
websiteUrl: websiteUrl,
|
|
675
|
+
archiveUnapproved: archiveUnapproved,
|
|
676
|
+
prdFilePath: prdFilePath,
|
|
677
|
+
prdContentType: prdContentType,
|
|
678
|
+
reportChannels: reportChannels,
|
|
679
|
+
reportEmails: reportEmails,
|
|
680
|
+
reportPhones: reportPhones,
|
|
681
|
+
reportWebhookUrl: reportWebhookUrl,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Get or create a shared Axios instance configured for the gateway.
|
|
686
|
+
*/
|
|
687
|
+
function getHttpClient(config) {
|
|
688
|
+
if (sharedHttpClient) {
|
|
689
|
+
return sharedHttpClient;
|
|
690
|
+
}
|
|
691
|
+
sharedHttpClient = axios_1.default.create({
|
|
692
|
+
baseURL: config.mcpGatewayBaseUrl,
|
|
693
|
+
headers: {
|
|
694
|
+
"Content-Type": "application/json",
|
|
695
|
+
Accept: "application/json, text/event-stream",
|
|
696
|
+
Authorization: config.authorizationHeader,
|
|
697
|
+
"x-api-key": config.apiKeyHeader,
|
|
698
|
+
},
|
|
699
|
+
validateStatus: () => true,
|
|
700
|
+
});
|
|
701
|
+
return sharedHttpClient;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Initialize MCP session to obtain a session id for subsequent tool calls.
|
|
705
|
+
* Retries on connection errors (e.g. ECONNRESET on 2nd run when server was just started).
|
|
706
|
+
*/
|
|
707
|
+
async function initializeSessionAsync(params) {
|
|
708
|
+
const payload = {
|
|
709
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
710
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
711
|
+
method: "initialize",
|
|
712
|
+
params: {
|
|
713
|
+
clientInfo: { name: CLIENT_NAME, version: CLIENT_VERSION },
|
|
714
|
+
capabilities: { tools: {} },
|
|
715
|
+
protocolVersion: "2024-12-17",
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
const requestConfig = {
|
|
719
|
+
headers: {
|
|
720
|
+
"Mcp-Session-Id": params.sessionId,
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
let lastError;
|
|
724
|
+
for (let attempt = 1; attempt <= SESSION_INIT_RETRY_ATTEMPTS; attempt++) {
|
|
725
|
+
try {
|
|
726
|
+
const response = await params.httpClient.post(TOOL_ENDPOINT_PATH, payload, requestConfig);
|
|
727
|
+
if (response.data.error) {
|
|
728
|
+
const errorData = response.data.error;
|
|
729
|
+
throw new Error(`MCP initialize error: code=${errorData.code} message=${errorData.message} data=${JSON.stringify(errorData.data)}`);
|
|
730
|
+
}
|
|
731
|
+
const sessionIdHeader = response.headers["mcp-session-id"] ||
|
|
732
|
+
response.headers["Mcp-Session-Id"] ||
|
|
733
|
+
response.data.result
|
|
734
|
+
?.sessionId;
|
|
735
|
+
if (!sessionIdHeader) {
|
|
736
|
+
console.warn("MCP initialize succeeded but no session id returned; proceeding without session pinning");
|
|
737
|
+
}
|
|
738
|
+
return sessionIdHeader;
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
lastError = err;
|
|
742
|
+
const code = err.code;
|
|
743
|
+
const isConnectionError = code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT";
|
|
744
|
+
if (isConnectionError && attempt < SESSION_INIT_RETRY_ATTEMPTS) {
|
|
745
|
+
console.warn(`Session init attempt ${attempt}/${SESSION_INIT_RETRY_ATTEMPTS} failed (${code}), retrying in ${SESSION_INIT_RETRY_DELAY_MS}ms...`);
|
|
746
|
+
await sleepAsync(SESSION_INIT_RETRY_DELAY_MS);
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
throw err;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
throw lastError;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Resolve the project id either by reusing an existing id or creating a new project.
|
|
757
|
+
*/
|
|
758
|
+
async function resolveProjectIdAsync(params) {
|
|
759
|
+
if (params.config.projectId) {
|
|
760
|
+
console.log("Using provided project id", { projectId: params.config.projectId });
|
|
761
|
+
await verifyProjectAsync({
|
|
762
|
+
httpClient: params.httpClient,
|
|
763
|
+
projectId: params.config.projectId,
|
|
764
|
+
});
|
|
765
|
+
return params.config.projectId;
|
|
766
|
+
}
|
|
767
|
+
console.log("Creating project", {
|
|
768
|
+
projectName: params.config.projectName,
|
|
769
|
+
websiteUrl: params.config.websiteUrl,
|
|
770
|
+
});
|
|
771
|
+
const createResult = await callToolAsync({
|
|
772
|
+
httpClient: params.httpClient,
|
|
773
|
+
request: {
|
|
774
|
+
toolName: TOOL_NAMES.projectCreate,
|
|
775
|
+
arguments: {
|
|
776
|
+
projectName: params.config.projectName,
|
|
777
|
+
description: params.config.projectDescription,
|
|
778
|
+
url: params.config.websiteUrl,
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
// Handle string response (may be JSON encoded)
|
|
783
|
+
const parsedResult = parseToolResult(createResult);
|
|
784
|
+
// Check if this is an error response (duplicate project)
|
|
785
|
+
const errorResponse = parsedResult;
|
|
786
|
+
if (errorResponse.error === "INVALID_ARGUMENT" &&
|
|
787
|
+
errorResponse.message?.includes("already exists")) {
|
|
788
|
+
console.log("Project already exists, looking up existing project...");
|
|
789
|
+
return await findProjectByNameAsync({
|
|
790
|
+
httpClient: params.httpClient,
|
|
791
|
+
projectName: params.config.projectName,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
const projectId = parsedResult.id;
|
|
795
|
+
if (!projectId) {
|
|
796
|
+
throw new Error("Project creation failed: no project ID returned");
|
|
797
|
+
}
|
|
798
|
+
console.log("Created project", { projectId: projectId });
|
|
799
|
+
return projectId;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Parse tool result, handling string-encoded JSON responses.
|
|
803
|
+
*/
|
|
804
|
+
function parseToolResult(result) {
|
|
805
|
+
if (typeof result === "string") {
|
|
806
|
+
try {
|
|
807
|
+
return JSON.parse(result);
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
return result;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return result;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Delete a project by ID.
|
|
817
|
+
*/
|
|
818
|
+
async function deleteProjectAsync(params) {
|
|
819
|
+
console.log("Deleting smoke test project...", { projectId: params.projectId });
|
|
820
|
+
const rawResult = await callToolAsync({
|
|
821
|
+
httpClient: params.httpClient,
|
|
822
|
+
request: {
|
|
823
|
+
toolName: TOOL_NAMES.projectDelete,
|
|
824
|
+
arguments: {
|
|
825
|
+
projectId: params.projectId,
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
});
|
|
829
|
+
const result = parseToolResult(rawResult);
|
|
830
|
+
if (result.error) {
|
|
831
|
+
console.warn(`Project deletion returned error: ${result.error} - ${result.message || ""}`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
console.log("Smoke test project deleted successfully", { projectId: params.projectId });
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Find an existing project by name.
|
|
838
|
+
*/
|
|
839
|
+
async function findProjectByNameAsync(params) {
|
|
840
|
+
const projects = await callToolAsync({
|
|
841
|
+
httpClient: params.httpClient,
|
|
842
|
+
request: {
|
|
843
|
+
toolName: TOOL_NAMES.projectList,
|
|
844
|
+
arguments: {
|
|
845
|
+
page: 1,
|
|
846
|
+
pageSize: 100,
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
const parsedProjects = parseToolResult(projects);
|
|
851
|
+
const projectList = Array.isArray(parsedProjects) ? parsedProjects : [];
|
|
852
|
+
const matchingProject = projectList.find((p) => p.name === params.projectName);
|
|
853
|
+
if (!matchingProject?.id) {
|
|
854
|
+
throw new Error(`Could not find existing project with name: ${params.projectName}`);
|
|
855
|
+
}
|
|
856
|
+
console.log("Found existing project", {
|
|
857
|
+
projectId: matchingProject.id,
|
|
858
|
+
name: matchingProject.name,
|
|
859
|
+
});
|
|
860
|
+
return matchingProject.id;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Verify the provided project exists.
|
|
864
|
+
*/
|
|
865
|
+
async function verifyProjectAsync(params) {
|
|
866
|
+
const project = await callToolAsync({
|
|
867
|
+
httpClient: params.httpClient,
|
|
868
|
+
request: {
|
|
869
|
+
toolName: TOOL_NAMES.projectGet,
|
|
870
|
+
arguments: {
|
|
871
|
+
projectId: params.projectId,
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
console.log("Verified project", {
|
|
876
|
+
projectId: project.id,
|
|
877
|
+
projectName: project.projectName,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Upload a PRD file if configured, then start processing and wait for completion.
|
|
882
|
+
* Skips processing if use case candidates already exist from a previous run.
|
|
883
|
+
*/
|
|
884
|
+
async function maybeUploadPrdAsync(params) {
|
|
885
|
+
if (!params.config.prdFilePath) {
|
|
886
|
+
console.log("Skipping PRD upload: MCP_QA_PRD_FILE_PATH not set");
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
// Check if use case candidates already exist from a previous run
|
|
890
|
+
console.log("Checking for existing use case candidates...");
|
|
891
|
+
const existingDiscovery = await checkExistingUseCaseCandidatesAsync({
|
|
892
|
+
httpClient: params.httpClient,
|
|
893
|
+
projectId: params.projectId,
|
|
894
|
+
});
|
|
895
|
+
if (existingDiscovery && existingDiscovery.useCaseCandidates.length > 0) {
|
|
896
|
+
console.log("Skipping PRD processing: use case candidates already exist", {
|
|
897
|
+
candidateCount: existingDiscovery.useCaseCandidates.length,
|
|
898
|
+
});
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const absolutePath = node_path_1.default.resolve(params.config.prdFilePath);
|
|
902
|
+
if (!node_fs_1.default.existsSync(absolutePath)) {
|
|
903
|
+
throw new Error(`PRD file not found at ${absolutePath}`);
|
|
904
|
+
}
|
|
905
|
+
const content = node_fs_1.default.readFileSync(absolutePath);
|
|
906
|
+
const base64Content = content.toString("base64");
|
|
907
|
+
const fileName = node_path_1.default.basename(absolutePath);
|
|
908
|
+
console.log("Uploading PRD file...", { fileName: fileName });
|
|
909
|
+
const uploadResult = await callToolAsync({
|
|
910
|
+
httpClient: params.httpClient,
|
|
911
|
+
request: {
|
|
912
|
+
toolName: TOOL_NAMES.prdUpload,
|
|
913
|
+
arguments: {
|
|
914
|
+
projectId: params.projectId,
|
|
915
|
+
fileName: fileName,
|
|
916
|
+
contentBase64: base64Content,
|
|
917
|
+
contentType: params.config.prdContentType,
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
});
|
|
921
|
+
// Parse upload result to get prdFilePath, contentChecksum, and fileSize
|
|
922
|
+
const parsedUploadResult = parseToolResult(uploadResult);
|
|
923
|
+
console.log("PRD file uploaded", parsedUploadResult);
|
|
924
|
+
if (!parsedUploadResult.prdFilePath) {
|
|
925
|
+
console.warn("PRD upload did not return prdFilePath; skipping processing");
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (!parsedUploadResult.contentChecksum || !parsedUploadResult.fileSize) {
|
|
929
|
+
console.warn("PRD upload did not return contentChecksum or fileSize; skipping processing");
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
// Start PRD processing workflow
|
|
933
|
+
console.log("Starting PRD file processing workflow...");
|
|
934
|
+
const rawProcessResult = await callToolAsync({
|
|
935
|
+
httpClient: params.httpClient,
|
|
936
|
+
request: {
|
|
937
|
+
toolName: TOOL_NAMES.prdProcessStart,
|
|
938
|
+
arguments: {
|
|
939
|
+
projectId: params.projectId,
|
|
940
|
+
name: `PRD-Process-${Date.now()}`,
|
|
941
|
+
description: `PRD file processing for ${fileName}`,
|
|
942
|
+
prdFilePath: parsedUploadResult.prdFilePath,
|
|
943
|
+
originalFileName: fileName,
|
|
944
|
+
url: params.config.websiteUrl,
|
|
945
|
+
contentChecksum: parsedUploadResult.contentChecksum,
|
|
946
|
+
fileSize: parsedUploadResult.fileSize,
|
|
947
|
+
},
|
|
948
|
+
},
|
|
949
|
+
});
|
|
950
|
+
const processResult = parseToolResult(rawProcessResult);
|
|
951
|
+
// Check for error response
|
|
952
|
+
if (processResult.error) {
|
|
953
|
+
console.warn(`PRD processing returned error: ${processResult.error}`);
|
|
954
|
+
console.warn(`PRD error details: ${processResult.message || ""}`);
|
|
955
|
+
if (processResult.details) {
|
|
956
|
+
console.warn(`PRD error full details:`, JSON.stringify(processResult.details, null, 2));
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
// Extract workflow runtime ID - may be at top level or nested in workflowRuntime object
|
|
961
|
+
const workflowRuntimeId = processResult.workflowRuntimeId || processResult.id || processResult.workflowRuntime?.id;
|
|
962
|
+
if (!workflowRuntimeId) {
|
|
963
|
+
console.warn("PRD processing did not return a workflow runtime id; skipping wait");
|
|
964
|
+
console.log("PRD process response:", JSON.stringify(processResult, null, 2));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
console.log("PRD processing workflow started", { workflowRuntimeId: workflowRuntimeId });
|
|
968
|
+
// Poll for PRD processing completion
|
|
969
|
+
await waitForPrdProcessingCompletionAsync({
|
|
970
|
+
httpClient: params.httpClient,
|
|
971
|
+
workflowRuntimeId: workflowRuntimeId,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Wait for PRD processing workflow to complete.
|
|
976
|
+
*/
|
|
977
|
+
async function waitForPrdProcessingCompletionAsync(params) {
|
|
978
|
+
let lastProgress = -1;
|
|
979
|
+
for (let attempt = 1; attempt <= PRD_PROCESS_MAX_POLL_ATTEMPTS; attempt++) {
|
|
980
|
+
const rawStatus = await callToolAsync({
|
|
981
|
+
httpClient: params.httpClient,
|
|
982
|
+
request: {
|
|
983
|
+
toolName: TOOL_NAMES.prdProcessLatestRun,
|
|
984
|
+
arguments: {
|
|
985
|
+
workflowRuntimeId: params.workflowRuntimeId,
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
});
|
|
989
|
+
const runStatus = parseToolResult(rawStatus);
|
|
990
|
+
const status = runStatus?.status;
|
|
991
|
+
const progress = runStatus?.progress ?? 0;
|
|
992
|
+
// Print progress if changed
|
|
993
|
+
if (progress !== lastProgress) {
|
|
994
|
+
console.log(`PRD processing progress: ${progress}% (status: ${status})`);
|
|
995
|
+
lastProgress = progress;
|
|
996
|
+
}
|
|
997
|
+
if (status && TERMINAL_STATUSES.includes(status)) {
|
|
998
|
+
if (status === "COMPLETED") {
|
|
999
|
+
console.log("PRD processing completed successfully");
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
const errorDetail = runStatus.error || "unknown reason";
|
|
1004
|
+
throw new Error(`PRD processing failed: ${errorDetail}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
await sleepAsync(PRD_PROCESS_POLL_INTERVAL_MS);
|
|
1008
|
+
}
|
|
1009
|
+
throw new Error("PRD processing timed out - workflow did not complete in time");
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Start website scan workflow.
|
|
1013
|
+
*/
|
|
1014
|
+
async function startWebsiteScanAsync(params) {
|
|
1015
|
+
const startResult = await callToolAsync({
|
|
1016
|
+
httpClient: params.httpClient,
|
|
1017
|
+
request: {
|
|
1018
|
+
toolName: TOOL_NAMES.workflowStartWebsiteScan,
|
|
1019
|
+
arguments: {
|
|
1020
|
+
projectId: params.projectId,
|
|
1021
|
+
url: params.websiteUrl,
|
|
1022
|
+
description: params.scanDescription,
|
|
1023
|
+
archiveUnapproved: params.archiveUnapproved,
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
});
|
|
1027
|
+
const workflowRuntimeId = startResult.id ||
|
|
1028
|
+
startResult.workflowRuntimeId;
|
|
1029
|
+
if (!workflowRuntimeId) {
|
|
1030
|
+
throw new Error("Website scan start returned no workflow runtime id");
|
|
1031
|
+
}
|
|
1032
|
+
console.log("Started website scan", {
|
|
1033
|
+
workflowRuntimeId: workflowRuntimeId,
|
|
1034
|
+
});
|
|
1035
|
+
return workflowRuntimeId;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Wait for website scan workflow to complete (or fail).
|
|
1039
|
+
* Prints progress updates and throws on failure.
|
|
1040
|
+
*/
|
|
1041
|
+
async function waitForWebsiteScanCompletionAsync(params) {
|
|
1042
|
+
let lastProgress = -1;
|
|
1043
|
+
let lastStatus = "";
|
|
1044
|
+
for (let attempt = 1; attempt <= WEBSITE_SCAN_MAX_POLL_ATTEMPTS; attempt++) {
|
|
1045
|
+
const rawStatus = await callToolAsync({
|
|
1046
|
+
httpClient: params.httpClient,
|
|
1047
|
+
request: {
|
|
1048
|
+
toolName: TOOL_NAMES.workflowGetWebsiteScanLatestRun,
|
|
1049
|
+
arguments: {
|
|
1050
|
+
workflowRuntimeId: params.workflowRuntimeId,
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
const runStatus = parseToolResult(rawStatus);
|
|
1055
|
+
if (!runStatus || typeof runStatus !== "object" || !runStatus.status) {
|
|
1056
|
+
console.log(`Waiting for website scan run to start (attempt ${attempt}/${WEBSITE_SCAN_MAX_POLL_ATTEMPTS})`);
|
|
1057
|
+
await sleepAsync(WEBSITE_SCAN_POLL_INTERVAL_MS);
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
const status = runStatus.status || "UNKNOWN";
|
|
1061
|
+
const progress = runStatus.progress ?? 0;
|
|
1062
|
+
// Print progress if changed
|
|
1063
|
+
if (progress !== lastProgress || status !== lastStatus) {
|
|
1064
|
+
console.log(`Website scan progress: ${progress}% (status: ${status})`);
|
|
1065
|
+
lastProgress = progress;
|
|
1066
|
+
lastStatus = status;
|
|
1067
|
+
}
|
|
1068
|
+
if (TERMINAL_STATUSES.includes(status)) {
|
|
1069
|
+
if (status === "COMPLETED") {
|
|
1070
|
+
console.log("Website scan completed successfully", {
|
|
1071
|
+
runId: runStatus.id,
|
|
1072
|
+
});
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
const errorDetail = runStatus.error || runStatus.errorMessage || "unknown reason";
|
|
1077
|
+
throw new Error(`Website scan failed: ${errorDetail}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
await sleepAsync(WEBSITE_SCAN_POLL_INTERVAL_MS);
|
|
1081
|
+
}
|
|
1082
|
+
throw new Error("Website scan timed out - workflow did not complete in time");
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Wait for test case detection workflow to complete.
|
|
1086
|
+
* Polls the latest run status and prints progress updates.
|
|
1087
|
+
*/
|
|
1088
|
+
async function waitForTestCaseDetectionCompletionAsync(params) {
|
|
1089
|
+
let lastProgress = -1;
|
|
1090
|
+
for (let attempt = 1; attempt <= TEST_CASE_DETECTION_MAX_POLL_ATTEMPTS; attempt++) {
|
|
1091
|
+
const rawStatus = await callToolAsync({
|
|
1092
|
+
httpClient: params.httpClient,
|
|
1093
|
+
request: {
|
|
1094
|
+
toolName: TOOL_NAMES.workflowGetTestCaseDetectionLatestRun,
|
|
1095
|
+
arguments: {
|
|
1096
|
+
workflowRuntimeId: params.workflowRuntimeId,
|
|
1097
|
+
},
|
|
1098
|
+
},
|
|
1099
|
+
});
|
|
1100
|
+
const runStatus = parseToolResult(rawStatus);
|
|
1101
|
+
const status = runStatus?.status;
|
|
1102
|
+
const progress = runStatus?.progress ?? 0;
|
|
1103
|
+
// Print progress if changed
|
|
1104
|
+
if (progress !== lastProgress) {
|
|
1105
|
+
console.log(`Test case detection progress: ${progress}% (status: ${status})`);
|
|
1106
|
+
lastProgress = progress;
|
|
1107
|
+
}
|
|
1108
|
+
if (status && TERMINAL_STATUSES.includes(status)) {
|
|
1109
|
+
if (status === "COMPLETED") {
|
|
1110
|
+
console.log("Test case detection completed successfully");
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
const errorDetail = runStatus?.error || "unknown reason";
|
|
1115
|
+
throw new Error(`Test case detection failed: ${errorDetail}`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
await sleepAsync(TEST_CASE_DETECTION_POLL_INTERVAL_MS);
|
|
1119
|
+
}
|
|
1120
|
+
throw new Error("Test case detection timed out - workflow did not complete in time");
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Wait for test script generation workflow to complete.
|
|
1124
|
+
* Polls the latest run status and prints progress updates.
|
|
1125
|
+
*/
|
|
1126
|
+
async function waitForTestScriptGenerationCompletionAsync(params) {
|
|
1127
|
+
let lastProgress = -1;
|
|
1128
|
+
for (let attempt = 1; attempt <= TEST_SCRIPT_GENERATION_MAX_POLL_ATTEMPTS; attempt++) {
|
|
1129
|
+
const rawStatus = await callToolAsync({
|
|
1130
|
+
httpClient: params.httpClient,
|
|
1131
|
+
request: {
|
|
1132
|
+
toolName: TOOL_NAMES.workflowGetTestScriptGenerationLatestRun,
|
|
1133
|
+
arguments: {
|
|
1134
|
+
workflowRuntimeId: params.workflowRuntimeId,
|
|
1135
|
+
},
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
const runStatus = parseToolResult(rawStatus);
|
|
1139
|
+
const status = runStatus?.status;
|
|
1140
|
+
const progress = runStatus?.progress ?? 0;
|
|
1141
|
+
// Print progress if changed
|
|
1142
|
+
if (progress !== lastProgress) {
|
|
1143
|
+
console.log(`Test script generation progress: ${progress}% (status: ${status})`);
|
|
1144
|
+
lastProgress = progress;
|
|
1145
|
+
}
|
|
1146
|
+
if (status && TERMINAL_STATUSES.includes(status)) {
|
|
1147
|
+
if (status === "COMPLETED") {
|
|
1148
|
+
console.log("Test script generation completed successfully");
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
const errorDetail = runStatus?.error || "unknown reason";
|
|
1153
|
+
throw new Error(`Test script generation failed: ${errorDetail}`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
await sleepAsync(TEST_SCRIPT_GENERATION_POLL_INTERVAL_MS);
|
|
1157
|
+
}
|
|
1158
|
+
throw new Error("Test script generation timed out - workflow did not complete in time");
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Check if the project has existing approved use cases with test cases.
|
|
1162
|
+
* Used for the "fast path" to skip workflow-heavy steps on re-runs.
|
|
1163
|
+
*/
|
|
1164
|
+
async function checkExistingTestDataAsync(params) {
|
|
1165
|
+
try {
|
|
1166
|
+
console.log("Checking for existing approved use cases...");
|
|
1167
|
+
// List use cases for this project
|
|
1168
|
+
const useCasesRaw = await callToolAsync({
|
|
1169
|
+
httpClient: params.httpClient,
|
|
1170
|
+
request: {
|
|
1171
|
+
toolName: TOOL_NAMES.useCaseList,
|
|
1172
|
+
arguments: {
|
|
1173
|
+
projectId: params.projectId,
|
|
1174
|
+
},
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
1177
|
+
const useCasesResult = parseToolResult(useCasesRaw);
|
|
1178
|
+
const approvedUseCases = (useCasesResult.useCases || []).filter((uc) => uc.status === "approved" || uc.status === "APPROVED");
|
|
1179
|
+
if (approvedUseCases.length === 0) {
|
|
1180
|
+
console.log("No approved use cases found, proceeding with full workflow");
|
|
1181
|
+
return { hasTestData: false };
|
|
1182
|
+
}
|
|
1183
|
+
console.log("Found approved use cases", { count: approvedUseCases.length });
|
|
1184
|
+
// Check if any approved use case has test cases
|
|
1185
|
+
for (const useCase of approvedUseCases) {
|
|
1186
|
+
const testCasesRaw = await callToolAsync({
|
|
1187
|
+
httpClient: params.httpClient,
|
|
1188
|
+
request: {
|
|
1189
|
+
toolName: TOOL_NAMES.testCaseList,
|
|
1190
|
+
arguments: {
|
|
1191
|
+
projectId: params.projectId,
|
|
1192
|
+
useCaseId: useCase.id,
|
|
1193
|
+
},
|
|
1194
|
+
},
|
|
1195
|
+
});
|
|
1196
|
+
const testCasesResult = parseToolResult(testCasesRaw);
|
|
1197
|
+
const testCases = testCasesResult.testCases || [];
|
|
1198
|
+
if (testCases.length > 0) {
|
|
1199
|
+
console.log("Found existing test cases for use case", {
|
|
1200
|
+
useCaseId: useCase.id,
|
|
1201
|
+
testCaseCount: testCases.length,
|
|
1202
|
+
});
|
|
1203
|
+
return {
|
|
1204
|
+
hasTestData: true,
|
|
1205
|
+
useCaseId: useCase.id,
|
|
1206
|
+
testCaseId: testCases[0].id,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
console.log("No test cases found for approved use cases, proceeding with full workflow");
|
|
1211
|
+
return { hasTestData: false };
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
console.log("Error checking existing test data, proceeding with full workflow:", extractErrorMessage(error));
|
|
1215
|
+
return { hasTestData: false };
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Check if use case candidates already exist from a previous run (non-blocking).
|
|
1220
|
+
* Returns the discovery memory if candidates exist, undefined otherwise.
|
|
1221
|
+
* Unwraps prompt-service response (memory + useCaseQuotaStatus) when present.
|
|
1222
|
+
*/
|
|
1223
|
+
async function checkExistingUseCaseCandidatesAsync(params) {
|
|
1224
|
+
try {
|
|
1225
|
+
const rawResult = await callToolAsync({
|
|
1226
|
+
httpClient: params.httpClient,
|
|
1227
|
+
request: {
|
|
1228
|
+
toolName: TOOL_NAMES.useCaseDiscovery,
|
|
1229
|
+
arguments: {
|
|
1230
|
+
projectId: params.projectId,
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
});
|
|
1234
|
+
const parsed = parseToolResult(rawResult);
|
|
1235
|
+
const memory = parsed.memory ?? parsed;
|
|
1236
|
+
if (memory?.useCaseCandidates && memory.useCaseCandidates.length > 0) {
|
|
1237
|
+
return memory;
|
|
1238
|
+
}
|
|
1239
|
+
return undefined;
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
// If the call fails, return undefined to proceed with PRD processing
|
|
1243
|
+
console.log("Could not check for existing use case candidates, proceeding with PRD processing");
|
|
1244
|
+
return undefined;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Fetch use case discovery memory.
|
|
1249
|
+
* Unwraps prompt-service response (memory + useCaseQuotaStatus) and returns memory when it has candidates.
|
|
1250
|
+
*/
|
|
1251
|
+
async function waitForUseCaseDiscoveryAsync(params) {
|
|
1252
|
+
const memory = await pollForValueWithOptionsAsync({
|
|
1253
|
+
action: async () => {
|
|
1254
|
+
const value = await callToolAsync({
|
|
1255
|
+
httpClient: params.httpClient,
|
|
1256
|
+
request: {
|
|
1257
|
+
toolName: TOOL_NAMES.useCaseDiscovery,
|
|
1258
|
+
arguments: {
|
|
1259
|
+
projectId: params.projectId,
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1262
|
+
});
|
|
1263
|
+
const raw = value;
|
|
1264
|
+
const mem = raw.memory ?? value;
|
|
1265
|
+
const candidates = mem?.useCaseCandidates;
|
|
1266
|
+
if (candidates && candidates.length > 0) {
|
|
1267
|
+
return mem;
|
|
1268
|
+
}
|
|
1269
|
+
return undefined;
|
|
1270
|
+
},
|
|
1271
|
+
description: "use case discovery memory",
|
|
1272
|
+
intervalMs: DISCOVERY_MEMORY_POLL_INTERVAL_MS,
|
|
1273
|
+
maxAttempts: DISCOVERY_MEMORY_MAX_POLL_ATTEMPTS,
|
|
1274
|
+
});
|
|
1275
|
+
console.log("Discovered use case candidates", {
|
|
1276
|
+
candidateCount: memory.useCaseCandidates.length,
|
|
1277
|
+
});
|
|
1278
|
+
return memory;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Approve all eligible use case candidates.
|
|
1282
|
+
*/
|
|
1283
|
+
async function approveUseCaseCandidatesAsync(params) {
|
|
1284
|
+
const allCandidateIds = params.useCaseCandidates
|
|
1285
|
+
.filter((candidate) => candidate.status === "candidate" || candidate.status === "draft")
|
|
1286
|
+
.map((candidate) => candidate.id);
|
|
1287
|
+
if (allCandidateIds.length === 0) {
|
|
1288
|
+
throw new Error("No approvable use case candidates found");
|
|
1289
|
+
}
|
|
1290
|
+
// Limit to MAX_USE_CASES_TO_APPROVE to stay within typical user quotas
|
|
1291
|
+
const candidateIds = allCandidateIds.slice(0, MAX_USE_CASES_TO_APPROVE);
|
|
1292
|
+
console.log("Approving use case candidates", {
|
|
1293
|
+
candidateIds: candidateIds,
|
|
1294
|
+
totalAvailable: allCandidateIds.length,
|
|
1295
|
+
limitedTo: MAX_USE_CASES_TO_APPROVE,
|
|
1296
|
+
});
|
|
1297
|
+
const rawApproveResult = await callToolAsync({
|
|
1298
|
+
httpClient: params.httpClient,
|
|
1299
|
+
request: {
|
|
1300
|
+
toolName: TOOL_NAMES.useCaseApprove,
|
|
1301
|
+
arguments: {
|
|
1302
|
+
projectId: params.projectId,
|
|
1303
|
+
approvedCandidateIds: candidateIds,
|
|
1304
|
+
},
|
|
1305
|
+
},
|
|
1306
|
+
});
|
|
1307
|
+
const approveResult = parseToolResult(rawApproveResult);
|
|
1308
|
+
// Check for error response - handle quota exceeded gracefully
|
|
1309
|
+
if (approveResult.error) {
|
|
1310
|
+
const isQuotaExceeded = approveResult.message?.includes("limit exceeded") ||
|
|
1311
|
+
approveResult.message?.includes("Remaining approvals: 0");
|
|
1312
|
+
if (isQuotaExceeded) {
|
|
1313
|
+
console.warn(`Use case approval quota exhausted: ${approveResult.message}. Will use existing approved use cases.`);
|
|
1314
|
+
return [];
|
|
1315
|
+
}
|
|
1316
|
+
const errorDetail = approveResult.message || approveResult.error || "unknown reason";
|
|
1317
|
+
throw new Error(`Use case approval failed: ${errorDetail}`);
|
|
1318
|
+
}
|
|
1319
|
+
let approvedUseCaseIds = (approveResult.approved || [])
|
|
1320
|
+
.map((useCase) => useCase.id)
|
|
1321
|
+
.filter(Boolean);
|
|
1322
|
+
if (approvedUseCaseIds.length === 0) {
|
|
1323
|
+
const memory = approveResult.memory;
|
|
1324
|
+
const requestedSet = new Set(candidateIds);
|
|
1325
|
+
const graduated = (memory?.useCaseCandidates ?? []).filter((c) => (c.status === "graduated" || c.status === "GRADUATED") && requestedSet.has(c.id));
|
|
1326
|
+
approvedUseCaseIds = graduated
|
|
1327
|
+
.map((c) => c.useCase?.id ?? c.useCaseId)
|
|
1328
|
+
.filter(Boolean);
|
|
1329
|
+
}
|
|
1330
|
+
if (approvedUseCaseIds.length === 0) {
|
|
1331
|
+
const quotaRemaining = approveResult.quotaInfo?.remaining;
|
|
1332
|
+
if (typeof quotaRemaining === "number" && quotaRemaining <= 0) {
|
|
1333
|
+
console.warn("Use case approval quota exhausted (remaining=0). Will use existing approved use cases.");
|
|
1334
|
+
return [];
|
|
1335
|
+
}
|
|
1336
|
+
const keys = typeof approveResult === "object" && approveResult !== null
|
|
1337
|
+
? Object.keys(approveResult)
|
|
1338
|
+
: [];
|
|
1339
|
+
throw new Error(`Use case approval failed: no use cases were approved. ` +
|
|
1340
|
+
`Response keys: ${keys.join(", ") || "none"}. ` +
|
|
1341
|
+
`Set MCP_SMOKE_DEBUG_RAW_RESPONSES=true to log full response.`);
|
|
1342
|
+
}
|
|
1343
|
+
console.log(`Approved ${approvedUseCaseIds.length} use case(s)`);
|
|
1344
|
+
return approvedUseCaseIds;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Resolve a use case id, preferring newly approved ids.
|
|
1348
|
+
*/
|
|
1349
|
+
async function resolveUseCaseIdAsync(params) {
|
|
1350
|
+
if (params.preferredUseCaseIds && params.preferredUseCaseIds.length > 0) {
|
|
1351
|
+
return params.preferredUseCaseIds[0];
|
|
1352
|
+
}
|
|
1353
|
+
const useCases = await callToolAsync({
|
|
1354
|
+
httpClient: params.httpClient,
|
|
1355
|
+
request: {
|
|
1356
|
+
toolName: TOOL_NAMES.useCaseList,
|
|
1357
|
+
arguments: {
|
|
1358
|
+
projectId: params.projectId,
|
|
1359
|
+
page: 1,
|
|
1360
|
+
pageSize: 20,
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
});
|
|
1364
|
+
const firstUseCaseId = useCases && useCases.length > 0 ? useCases[0].id : undefined;
|
|
1365
|
+
if (!firstUseCaseId) {
|
|
1366
|
+
throw new Error("No use cases available for test case detection");
|
|
1367
|
+
}
|
|
1368
|
+
return firstUseCaseId;
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Start test case detection workflow.
|
|
1372
|
+
*/
|
|
1373
|
+
async function startTestCaseDetectionAsync(params) {
|
|
1374
|
+
const startResult = await callToolAsync({
|
|
1375
|
+
httpClient: params.httpClient,
|
|
1376
|
+
request: {
|
|
1377
|
+
toolName: TOOL_NAMES.workflowStartTestCaseDetection,
|
|
1378
|
+
arguments: {
|
|
1379
|
+
projectId: params.projectId,
|
|
1380
|
+
useCaseId: params.useCaseId,
|
|
1381
|
+
name: "phase0-smoke-test-case-detection",
|
|
1382
|
+
description: "Generate test cases from approved use cases",
|
|
1383
|
+
url: params.url,
|
|
1384
|
+
},
|
|
1385
|
+
},
|
|
1386
|
+
});
|
|
1387
|
+
const workflowRuntimeId = startResult.id ||
|
|
1388
|
+
startResult.workflowRuntimeId;
|
|
1389
|
+
if (!workflowRuntimeId) {
|
|
1390
|
+
throw new Error("Test case detection start returned no workflow runtime id");
|
|
1391
|
+
}
|
|
1392
|
+
return workflowRuntimeId;
|
|
1393
|
+
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Resolve a test case id by polling the list endpoint.
|
|
1396
|
+
*/
|
|
1397
|
+
async function resolveTestCaseIdAsync(params) {
|
|
1398
|
+
const testCaseId = await pollForValueAsync({
|
|
1399
|
+
action: async () => {
|
|
1400
|
+
const listResponse = await callToolAsync({
|
|
1401
|
+
httpClient: params.httpClient,
|
|
1402
|
+
request: {
|
|
1403
|
+
toolName: TOOL_NAMES.testCaseList,
|
|
1404
|
+
arguments: {
|
|
1405
|
+
projectId: params.projectId,
|
|
1406
|
+
page: 1,
|
|
1407
|
+
pageSize: 20,
|
|
1408
|
+
},
|
|
1409
|
+
},
|
|
1410
|
+
});
|
|
1411
|
+
const match = (listResponse || []).find((entry) => entry.useCaseId === params.useCaseId);
|
|
1412
|
+
return match ? match.id : undefined;
|
|
1413
|
+
},
|
|
1414
|
+
description: "test case list",
|
|
1415
|
+
});
|
|
1416
|
+
if (!testCaseId) {
|
|
1417
|
+
throw new Error("No test case found after polling");
|
|
1418
|
+
}
|
|
1419
|
+
console.log("Resolved test case", { testCaseId: testCaseId });
|
|
1420
|
+
return testCaseId;
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Start test script generation workflow.
|
|
1424
|
+
*/
|
|
1425
|
+
async function startTestScriptGenerationAsync(params) {
|
|
1426
|
+
const testCase = await callToolAsync({
|
|
1427
|
+
httpClient: params.httpClient,
|
|
1428
|
+
request: {
|
|
1429
|
+
toolName: TOOL_NAMES.testCaseGet,
|
|
1430
|
+
arguments: {
|
|
1431
|
+
testCaseId: params.testCaseId,
|
|
1432
|
+
},
|
|
1433
|
+
},
|
|
1434
|
+
});
|
|
1435
|
+
const url = testCase.url;
|
|
1436
|
+
const goal = testCase.goal;
|
|
1437
|
+
const precondition = testCase.precondition;
|
|
1438
|
+
const instructions = testCase.description;
|
|
1439
|
+
const expectedResult = testCase.expectedResult;
|
|
1440
|
+
if (!url || !goal || !precondition || !instructions || !expectedResult) {
|
|
1441
|
+
throw new Error(`Missing required test case fields for script generation. testCaseId=${params.testCaseId} ` +
|
|
1442
|
+
`url=${Boolean(url)} goal=${Boolean(goal)} precondition=${Boolean(precondition)} ` +
|
|
1443
|
+
`description=${Boolean(instructions)} expectedResult=${Boolean(expectedResult)}`);
|
|
1444
|
+
}
|
|
1445
|
+
const startResult = await callToolAsync({
|
|
1446
|
+
httpClient: params.httpClient,
|
|
1447
|
+
request: {
|
|
1448
|
+
toolName: TOOL_NAMES.workflowStartTestScriptGeneration,
|
|
1449
|
+
arguments: {
|
|
1450
|
+
projectId: params.projectId,
|
|
1451
|
+
testCaseId: params.testCaseId,
|
|
1452
|
+
useCaseId: params.useCaseId,
|
|
1453
|
+
name: "phase0-smoke-script-generation",
|
|
1454
|
+
url: url,
|
|
1455
|
+
goal: goal,
|
|
1456
|
+
precondition: precondition,
|
|
1457
|
+
instructions: instructions,
|
|
1458
|
+
expectedResult: expectedResult,
|
|
1459
|
+
},
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1462
|
+
const workflowRuntimeId = startResult.id ||
|
|
1463
|
+
startResult.workflowRuntimeId;
|
|
1464
|
+
if (!workflowRuntimeId) {
|
|
1465
|
+
throw new Error("Test script generation start returned no workflow runtime id");
|
|
1466
|
+
}
|
|
1467
|
+
return workflowRuntimeId;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Resolve a test script id by polling the list endpoint.
|
|
1471
|
+
*/
|
|
1472
|
+
async function resolveTestScriptIdAsync(params) {
|
|
1473
|
+
const testScriptId = await pollForValueAsync({
|
|
1474
|
+
action: async () => {
|
|
1475
|
+
const listResponse = await callToolAsync({
|
|
1476
|
+
httpClient: params.httpClient,
|
|
1477
|
+
request: {
|
|
1478
|
+
toolName: TOOL_NAMES.testScriptList,
|
|
1479
|
+
arguments: {
|
|
1480
|
+
projectId: params.projectId,
|
|
1481
|
+
page: 1,
|
|
1482
|
+
pageSize: 20,
|
|
1483
|
+
},
|
|
1484
|
+
},
|
|
1485
|
+
});
|
|
1486
|
+
const firstScript = listResponse && listResponse.length > 0 ? listResponse[0] : undefined;
|
|
1487
|
+
return firstScript ? firstScript.id : undefined;
|
|
1488
|
+
},
|
|
1489
|
+
description: "test script list",
|
|
1490
|
+
});
|
|
1491
|
+
if (!testScriptId) {
|
|
1492
|
+
throw new Error("No test script found after polling");
|
|
1493
|
+
}
|
|
1494
|
+
console.log("Resolved test script", { testScriptId: testScriptId });
|
|
1495
|
+
return testScriptId;
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Start bulk test script replay for the provided scripts.
|
|
1499
|
+
*/
|
|
1500
|
+
async function startTestScriptReplayBulkAsync(params) {
|
|
1501
|
+
await callToolAsync({
|
|
1502
|
+
httpClient: params.httpClient,
|
|
1503
|
+
request: {
|
|
1504
|
+
toolName: TOOL_NAMES.workflowStartTestScriptReplayBulk,
|
|
1505
|
+
arguments: {
|
|
1506
|
+
projectId: params.projectId,
|
|
1507
|
+
name: "phase0-smoke-replay",
|
|
1508
|
+
intervalSec: -1,
|
|
1509
|
+
testCaseIds: params.testCaseIds,
|
|
1510
|
+
},
|
|
1511
|
+
},
|
|
1512
|
+
});
|
|
1513
|
+
console.log("Started test script replay bulk", { testCaseIds: params.testCaseIds });
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Default export format for reports.
|
|
1517
|
+
*/
|
|
1518
|
+
const DEFAULT_REPORT_EXPORT_FORMAT = "pdf";
|
|
1519
|
+
/**
|
|
1520
|
+
* Trigger final report generation.
|
|
1521
|
+
*/
|
|
1522
|
+
async function generateReportAsync(params) {
|
|
1523
|
+
const exportFormat = params.exportFormat ?? DEFAULT_REPORT_EXPORT_FORMAT;
|
|
1524
|
+
const reportResult = await callToolAsync({
|
|
1525
|
+
httpClient: params.httpClient,
|
|
1526
|
+
request: {
|
|
1527
|
+
toolName: TOOL_NAMES.reportFinalGenerate,
|
|
1528
|
+
arguments: {
|
|
1529
|
+
projectId: params.projectId,
|
|
1530
|
+
exportFormat: exportFormat,
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
const workflowRuntimeId = reportResult.workflowRuntimeId;
|
|
1535
|
+
if (!workflowRuntimeId) {
|
|
1536
|
+
throw new Error(`Report generation failed: no workflowRuntimeId returned. ` +
|
|
1537
|
+
`Response: ${JSON.stringify(reportResult)}`);
|
|
1538
|
+
}
|
|
1539
|
+
console.log("Triggered report generation", {
|
|
1540
|
+
workflowRuntimeId: workflowRuntimeId,
|
|
1541
|
+
exportFormat: exportFormat,
|
|
1542
|
+
});
|
|
1543
|
+
return workflowRuntimeId;
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Upsert report delivery preferences.
|
|
1547
|
+
*/
|
|
1548
|
+
async function upsertReportPreferencesAsync(params) {
|
|
1549
|
+
const result = await callToolAsync({
|
|
1550
|
+
httpClient: params.httpClient,
|
|
1551
|
+
request: {
|
|
1552
|
+
toolName: TOOL_NAMES.reportPreferencesUpsert,
|
|
1553
|
+
arguments: {
|
|
1554
|
+
projectId: params.projectId,
|
|
1555
|
+
channels: params.channels,
|
|
1556
|
+
emails: params.emails,
|
|
1557
|
+
phones: params.phones,
|
|
1558
|
+
webhookUrl: params.webhookUrl,
|
|
1559
|
+
},
|
|
1560
|
+
},
|
|
1561
|
+
});
|
|
1562
|
+
console.log("Updated report delivery preferences", {
|
|
1563
|
+
projectId: result.projectId,
|
|
1564
|
+
channels: result.channels,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Generic JSON-RPC MCP tool caller with error handling.
|
|
1569
|
+
*/
|
|
1570
|
+
async function callToolAsync(params) {
|
|
1571
|
+
const rpcEnvelope = {
|
|
1572
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
1573
|
+
id: (0, node_crypto_1.randomUUID)(),
|
|
1574
|
+
method: "tools/call",
|
|
1575
|
+
params: {
|
|
1576
|
+
name: params.request.toolName,
|
|
1577
|
+
arguments: params.request.arguments,
|
|
1578
|
+
},
|
|
1579
|
+
};
|
|
1580
|
+
let response;
|
|
1581
|
+
try {
|
|
1582
|
+
response = await params.httpClient.post(TOOL_ENDPOINT_PATH, rpcEnvelope, currentSessionId
|
|
1583
|
+
? {
|
|
1584
|
+
headers: {
|
|
1585
|
+
"Mcp-Session-Id": currentSessionId,
|
|
1586
|
+
},
|
|
1587
|
+
}
|
|
1588
|
+
: undefined);
|
|
1589
|
+
}
|
|
1590
|
+
catch (error) {
|
|
1591
|
+
const axiosError = error;
|
|
1592
|
+
if (axiosError.code === "ECONNREFUSED") {
|
|
1593
|
+
throw new Error(`MCP Gateway server is not running or stopped responding. ` +
|
|
1594
|
+
`Please ensure the server is running on the configured URL. ` +
|
|
1595
|
+
`Tool: ${params.request.toolName}`);
|
|
1596
|
+
}
|
|
1597
|
+
throw error;
|
|
1598
|
+
}
|
|
1599
|
+
if (DEBUG_RAW_RESPONSES) {
|
|
1600
|
+
console.log("tool response raw", params.request.toolName, JSON.stringify(response.data));
|
|
1601
|
+
}
|
|
1602
|
+
response = {
|
|
1603
|
+
...response,
|
|
1604
|
+
data: normalizeStreamableHttpResponse(response.data),
|
|
1605
|
+
};
|
|
1606
|
+
// If server reports not initialized, try to re-init once and retry.
|
|
1607
|
+
if (response.status === 400 &&
|
|
1608
|
+
typeof response.data === "object" &&
|
|
1609
|
+
response.data &&
|
|
1610
|
+
response.data.error?.message?.includes("Server not initialized")) {
|
|
1611
|
+
const newSessionId = (0, node_crypto_1.randomUUID)();
|
|
1612
|
+
const initializedSession = await initializeSessionAsync({
|
|
1613
|
+
httpClient: params.httpClient,
|
|
1614
|
+
sessionId: newSessionId,
|
|
1615
|
+
});
|
|
1616
|
+
if (initializedSession) {
|
|
1617
|
+
currentSessionId = initializedSession;
|
|
1618
|
+
}
|
|
1619
|
+
response = await params.httpClient.post(TOOL_ENDPOINT_PATH, rpcEnvelope, currentSessionId
|
|
1620
|
+
? {
|
|
1621
|
+
headers: {
|
|
1622
|
+
"Mcp-Session-Id": currentSessionId,
|
|
1623
|
+
},
|
|
1624
|
+
}
|
|
1625
|
+
: undefined);
|
|
1626
|
+
response = {
|
|
1627
|
+
...response,
|
|
1628
|
+
data: normalizeStreamableHttpResponse(response.data),
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
if (response.status >= 400) {
|
|
1632
|
+
throw new Error(`HTTP error from gateway: status=${response.status} body=${JSON.stringify(response.data)}`);
|
|
1633
|
+
}
|
|
1634
|
+
const data = response.data;
|
|
1635
|
+
if (data.error) {
|
|
1636
|
+
const errorData = data.error;
|
|
1637
|
+
throw new Error(`MCP tool error: name=${params.request.toolName} code=${errorData.code} message=${errorData.message}`);
|
|
1638
|
+
}
|
|
1639
|
+
const success = data;
|
|
1640
|
+
const contentArray = success.result
|
|
1641
|
+
?.content || success.content;
|
|
1642
|
+
if (contentArray && Array.isArray(contentArray) && contentArray.length > 0) {
|
|
1643
|
+
const first = contentArray[0];
|
|
1644
|
+
if (first.type === "text" && typeof first.text === "string") {
|
|
1645
|
+
try {
|
|
1646
|
+
const parsed = JSON.parse(first.text);
|
|
1647
|
+
if (success.isError || parsed.error) {
|
|
1648
|
+
throw new Error(`MCP tool error: name=${params.request.toolName} message=${parsed.message || first.text}`);
|
|
1649
|
+
}
|
|
1650
|
+
return parsed;
|
|
1651
|
+
}
|
|
1652
|
+
catch {
|
|
1653
|
+
// fallback to raw text
|
|
1654
|
+
return first.text;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return success.result;
|
|
1659
|
+
}
|
|
1660
|
+
function normalizeStreamableHttpResponse(data) {
|
|
1661
|
+
if (typeof data !== "string") {
|
|
1662
|
+
return data;
|
|
1663
|
+
}
|
|
1664
|
+
const lines = data.split("\n");
|
|
1665
|
+
const dataLine = lines.find((line) => line.startsWith("data: "));
|
|
1666
|
+
if (!dataLine) {
|
|
1667
|
+
return data;
|
|
1668
|
+
}
|
|
1669
|
+
const jsonText = dataLine.replace(/^data:\s*/, "").trim();
|
|
1670
|
+
try {
|
|
1671
|
+
return JSON.parse(jsonText);
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
return data;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Poll until a value is returned or attempts are exhausted.
|
|
1679
|
+
*/
|
|
1680
|
+
async function pollForValueAsync(params) {
|
|
1681
|
+
for (let attempt = 1; attempt <= MAX_POLL_ATTEMPTS; attempt++) {
|
|
1682
|
+
const value = await params.action();
|
|
1683
|
+
if (value) {
|
|
1684
|
+
return value;
|
|
1685
|
+
}
|
|
1686
|
+
console.log(`Waiting for ${params.description} (attempt ${attempt}/${MAX_POLL_ATTEMPTS})`);
|
|
1687
|
+
await sleepAsync(POLL_INTERVAL_MS);
|
|
1688
|
+
}
|
|
1689
|
+
return undefined;
|
|
1690
|
+
}
|
|
1691
|
+
async function pollForValueWithOptionsAsync(params) {
|
|
1692
|
+
for (let attempt = 1; attempt <= params.maxAttempts; attempt++) {
|
|
1693
|
+
const value = await params.action();
|
|
1694
|
+
if (value) {
|
|
1695
|
+
return value;
|
|
1696
|
+
}
|
|
1697
|
+
console.log(`Waiting for ${params.description} (attempt ${attempt}/${params.maxAttempts})`);
|
|
1698
|
+
await sleepAsync(params.intervalMs);
|
|
1699
|
+
}
|
|
1700
|
+
throw new Error(`Timed out waiting for ${params.description}`);
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Sleep helper.
|
|
1704
|
+
*/
|
|
1705
|
+
function sleepAsync(durationMs) {
|
|
1706
|
+
return new Promise((resolve) => {
|
|
1707
|
+
setTimeout(() => {
|
|
1708
|
+
resolve();
|
|
1709
|
+
}, durationMs);
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
//# sourceMappingURL=phase0-smoke.js.map
|