@skyramp/mcp 0.0.62 → 0.0.63-rc.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.
Files changed (28) hide show
  1. package/build/index.js +18 -26
  2. package/build/prompts/test-maintenance/drift-analysis-prompt.js +59 -0
  3. package/build/prompts/test-maintenance/driftAnalysisSections.js +153 -0
  4. package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -9
  5. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +34 -38
  6. package/build/prompts/test-recommendation/test-recommendation-prompt.js +56 -9
  7. package/build/prompts/testbot/testbot-prompts.js +113 -100
  8. package/build/services/DriftAnalysisService.js +1 -1
  9. package/build/services/ScenarioGenerationService.js +5 -1
  10. package/build/services/TestExecutionService.js +2 -24
  11. package/build/services/TestExecutionService.test.js +167 -0
  12. package/build/services/containerEnv.js +35 -0
  13. package/build/tools/generate-tests/generateScenarioRestTool.js +7 -1
  14. package/build/tools/submitReportTool.js +6 -6
  15. package/build/tools/test-management/actionsTool.js +396 -0
  16. package/build/tools/test-management/analyzeChangesTool.js +750 -0
  17. package/build/tools/test-management/analyzeTestHealthTool.js +132 -0
  18. package/build/tools/test-management/executeTestsTool.js +198 -0
  19. package/build/tools/test-management/index.js +5 -0
  20. package/build/tools/test-management/stateCleanupTool.js +163 -0
  21. package/build/tools/test-recommendation/recommendTestsTool.js +1 -1
  22. package/build/utils/analyze-openapi.js +2 -2
  23. package/build/utils/pr-comment-parser.js +157 -36
  24. package/build/utils/pr-comment-parser.test.js +427 -0
  25. package/package.json +1 -1
  26. package/build/tools/initTestbotTool.js +0 -187
  27. package/build/tools/initTestbotTool.test.js +0 -194
  28. package/build/tools/test-recommendation/analyzeRepositoryTool.js +0 -505
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ import { logger } from "../../utils/logger.js";
3
+ import { StateManager, } from "../../utils/AnalysisStateManager.js";
4
+ import path from "path";
5
+ import { AnalyticsService } from "../../services/AnalyticsService.js";
6
+ import { buildDriftAnalysisPrompt } from "../../prompts/test-maintenance/drift-analysis-prompt.js";
7
+ const TOOL_NAME = "skyramp_analyze_test_health";
8
+ export function registerAnalyzeTestHealthTool(server) {
9
+ server.registerTool(TOOL_NAME, {
10
+ description: `Generate drift and health assessment instructions for existing tests — second step of the unified Test Health Analysis Flow.
11
+
12
+ **PREREQUISITE:** Call \`skyramp_analyze_changes\` first to get a stateFile.
13
+
14
+ This tool reads existing tests, the branch diff, and scanned endpoints from the stateFile,
15
+ then returns a structured prompt for the LLM to assess each test for drift and health.
16
+
17
+ The LLM follows the returned prompt to assign health scores (0–100), drift details, and
18
+ actions (UPDATE / REGENERATE / VERIFY / DELETE) for each test, then calls \`skyramp_actions\`.
19
+
20
+ **This tool does not write drift or health data to the stateFile.** Assessment results
21
+ exist only in the LLM's reasoning context and are acted on by \`skyramp_actions\`.
22
+
23
+ (Optional) Call \`skyramp_execute_tests\` before \`skyramp_actions\` to validate tests live.
24
+
25
+ **Output:** LLM drift analysis prompt and assessment instructions.`,
26
+ inputSchema: {
27
+ stateFile: z
28
+ .string()
29
+ .describe("Path to state file from skyramp_analyze_changes"),
30
+ },
31
+ }, async (args) => {
32
+ let errorResult;
33
+ try {
34
+ logger.info(`Analyzing test health from state file: ${args.stateFile}`);
35
+ // Load UnifiedAnalysisState from state file
36
+ const stateManager = StateManager.fromStatePath(args.stateFile);
37
+ const stateData = await stateManager.readData();
38
+ const fullState = await stateManager.readFullState();
39
+ const repositoryPath = fullState?.metadata.repositoryPath || "";
40
+ if (!stateData) {
41
+ errorResult = {
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: JSON.stringify({
46
+ error: "State file is empty or invalid",
47
+ stateFile: args.stateFile,
48
+ }, null, 2),
49
+ },
50
+ ],
51
+ isError: true,
52
+ };
53
+ return errorResult;
54
+ }
55
+ const existingTests = stateData.existingTests || [];
56
+ logger.info(`Loaded ${existingTests.length} existing tests from state file`);
57
+ if (!repositoryPath || typeof repositoryPath !== "string") {
58
+ errorResult = {
59
+ content: [
60
+ {
61
+ type: "text",
62
+ text: JSON.stringify({
63
+ error: "repositoryPath not found in state file metadata",
64
+ details: "State file must contain repositoryPath in metadata",
65
+ }, null, 2),
66
+ },
67
+ ],
68
+ isError: true,
69
+ };
70
+ return errorResult;
71
+ }
72
+ const absoluteRepoPath = path.resolve(repositoryPath);
73
+ // ── Build and return drift analysis prompt ──
74
+ const diff = stateData.repositoryAnalysis?.diff;
75
+ // Build structured diff summary for the prompt
76
+ let parsedDiffText = "";
77
+ if (diff) {
78
+ const lines = [];
79
+ if (diff.currentBranch)
80
+ lines.push(`**Branch**: ${diff.currentBranch} → base: ${diff.baseBranch}`);
81
+ if (diff.changedFiles?.length > 0)
82
+ lines.push(`**Changed Files** (${diff.changedFiles.length}): ${diff.changedFiles.join(", ")}`);
83
+ if (diff.newEndpoints?.length > 0) {
84
+ lines.push(`**New Endpoints** (${diff.newEndpoints.length}): ${diff.newEndpoints
85
+ .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
86
+ .join(", ")}`);
87
+ }
88
+ if (diff.modifiedEndpoints?.length > 0) {
89
+ lines.push(`**Modified Endpoints** (${diff.modifiedEndpoints.length}): ${diff.modifiedEndpoints
90
+ .map((e) => e.methods ? e.methods.map((m) => `${m.method} ${e.path}`).join(", ") : `${e.method} ${e.path}`)
91
+ .join(", ")}`);
92
+ }
93
+ parsedDiffText = lines.join("\n");
94
+ }
95
+ const scannedEndpoints = stateData.repositoryAnalysis?.skeletonEndpoints || [];
96
+ const promptText = buildDriftAnalysisPrompt({
97
+ existingTests,
98
+ parsedDiff: parsedDiffText || undefined,
99
+ scannedEndpoints,
100
+ repositoryPath: absoluteRepoPath,
101
+ stateFile: stateManager.getStatePath(),
102
+ });
103
+ return {
104
+ content: [
105
+ {
106
+ type: "text",
107
+ text: promptText,
108
+ },
109
+ ],
110
+ };
111
+ }
112
+ catch (error) {
113
+ logger.error(`Test health analysis failed: ${error.message}`, error);
114
+ errorResult = {
115
+ content: [
116
+ {
117
+ type: "text",
118
+ text: JSON.stringify({
119
+ error: error.message,
120
+ details: error.stack,
121
+ }, null, 2),
122
+ },
123
+ ],
124
+ isError: true,
125
+ };
126
+ return errorResult;
127
+ }
128
+ finally {
129
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {});
130
+ }
131
+ });
132
+ }
@@ -0,0 +1,198 @@
1
+ import { z } from "zod";
2
+ import { TestExecutionService } from "../../services/TestExecutionService.js";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { StateManager, } from "../../utils/AnalysisStateManager.js";
5
+ import * as path from "path";
6
+ import * as fs from "fs";
7
+ import { AnalyticsService } from "../../services/AnalyticsService.js";
8
+ const TOOL_NAME = "skyramp_execute_tests";
9
+ export function registerExecuteTestsTool(server) {
10
+ server.registerTool(TOOL_NAME, {
11
+ description: `Execute existing Skyramp tests live — optional third step of the unified Test Health Analysis Flow.
12
+
13
+ **PREREQUISITE:** Call \`skyramp_analyze_test_health\` first.
14
+
15
+ Runs all discovered tests in parallel batches (up to 5 concurrent). Results are merged
16
+ back into the state file for use by \`skyramp_actions\`.
17
+
18
+ **Key features:**
19
+ - Parallel execution: up to 5 tests simultaneously
20
+ - Isolated execution: each test in a separate Docker container
21
+ - Error resilient: failed tests don't stop the batch
22
+
23
+ **Next step:** Call \`skyramp_actions\` with the returned stateFile.
24
+
25
+ **Output:** {summary, stateFile, sessionId, stateFileSize, message}`,
26
+ inputSchema: {
27
+ stateFile: z
28
+ .string()
29
+ .describe("Path to state file from skyramp_analyze_test_health (required)"),
30
+ authToken: z
31
+ .string()
32
+ .optional()
33
+ .default("")
34
+ .describe("Authentication token for test execution. Use empty string if no auth required."),
35
+ timeout: z
36
+ .number()
37
+ .optional()
38
+ .describe("Timeout in milliseconds for each test (default: 300000 = 5 minutes)"),
39
+ },
40
+ }, async (args) => {
41
+ let errorResult;
42
+ try {
43
+ logger.info("Starting test execution from unified state");
44
+ // Load UnifiedAnalysisState from state file
45
+ const stateManager = StateManager.fromStatePath(args.stateFile);
46
+ const stateData = await stateManager.readData();
47
+ const fullState = await stateManager.readFullState();
48
+ const repositoryPath = fullState?.metadata.repositoryPath || "";
49
+ if (!stateData) {
50
+ errorResult = {
51
+ content: [
52
+ {
53
+ type: "text",
54
+ text: JSON.stringify({
55
+ error: "State file is empty or invalid",
56
+ stateFile: args.stateFile,
57
+ }, null, 2),
58
+ },
59
+ ],
60
+ isError: true,
61
+ };
62
+ return errorResult;
63
+ }
64
+ const originalTestResults = stateData.existingTests || [];
65
+ if (originalTestResults.length === 0) {
66
+ errorResult = {
67
+ content: [
68
+ {
69
+ type: "text",
70
+ text: JSON.stringify({
71
+ error: "No existing tests found in state file",
72
+ stateFile: args.stateFile,
73
+ }, null, 2),
74
+ },
75
+ ],
76
+ isError: true,
77
+ };
78
+ return errorResult;
79
+ }
80
+ logger.info(`Loaded ${originalTestResults.length} tests from state file: ${args.stateFile}`);
81
+ if (!repositoryPath || typeof repositoryPath !== "string") {
82
+ errorResult = {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: JSON.stringify({
87
+ error: "repositoryPath not found in state file metadata",
88
+ }, null, 2),
89
+ },
90
+ ],
91
+ isError: true,
92
+ };
93
+ return errorResult;
94
+ }
95
+ const absoluteWorkspacePath = path.resolve(repositoryPath);
96
+ if (!fs.existsSync(absoluteWorkspacePath)) {
97
+ errorResult = {
98
+ content: [
99
+ {
100
+ type: "text",
101
+ text: JSON.stringify({
102
+ error: `Workspace path does not exist: ${absoluteWorkspacePath}`,
103
+ }, null, 2),
104
+ },
105
+ ],
106
+ isError: true,
107
+ };
108
+ return errorResult;
109
+ }
110
+ const testsToExecute = originalTestResults.map((test) => ({
111
+ testFile: test.testFile,
112
+ language: test.language,
113
+ testType: test.testType,
114
+ }));
115
+ const testOptions = testsToExecute.map((test) => ({
116
+ testFile: test.testFile,
117
+ workspacePath: absoluteWorkspacePath,
118
+ language: test.language,
119
+ testType: test.testType,
120
+ token: args.authToken || "",
121
+ timeout: args.timeout,
122
+ }));
123
+ logger.info(`Executing ${testOptions.length} tests in parallel batches (max 5 concurrent)`);
124
+ const executionService = new TestExecutionService();
125
+ const executionResult = await executionService.executeBatch(testOptions);
126
+ logger.info(`Batch execution complete: ${executionResult.passed} passed, ` +
127
+ `${executionResult.failed} failed, ${executionResult.crashed} crashed`);
128
+ // Enrich test results with execution data
129
+ const enrichedTests = originalTestResults.map((test) => {
130
+ const execResult = executionResult.results.find((r) => r.testFile === test.testFile);
131
+ if (execResult) {
132
+ return {
133
+ ...test,
134
+ execution: {
135
+ passed: execResult.passed,
136
+ duration: execResult.duration,
137
+ errors: execResult.errors,
138
+ warnings: execResult.warnings,
139
+ crashed: execResult.crashed,
140
+ stdout: execResult.output,
141
+ stderr: execResult.errors.join("\n"),
142
+ executionTimestamp: execResult.executedAt,
143
+ },
144
+ };
145
+ }
146
+ return test;
147
+ });
148
+ // Save updated state
149
+ await stateManager.writeData({ ...stateData, existingTests: enrichedTests }, {
150
+ repositoryPath: absoluteWorkspacePath,
151
+ step: "execution",
152
+ });
153
+ const stateSize = await stateManager.getSizeFormatted();
154
+ logger.info(`Saved ${enrichedTests.length} tests with execution data to: ${stateManager.getStatePath()} (${stateSize})`);
155
+ const responseData = {
156
+ summary: {
157
+ totalTests: executionResult.totalTests,
158
+ passed: executionResult.passed,
159
+ failed: executionResult.failed,
160
+ crashed: executionResult.crashed,
161
+ totalDuration: executionResult.totalDuration,
162
+ totalDurationSeconds: (executionResult.totalDuration / 1000).toFixed(2),
163
+ },
164
+ stateFile: stateManager.getStatePath(),
165
+ sessionId: stateManager.getSessionId(),
166
+ stateFileSize: stateSize,
167
+ message: `Execution complete. ${executionResult.passed} passed, ${executionResult.failed} failed, ${executionResult.crashed} crashed. Pass stateFile to skyramp_actions.`,
168
+ generatedAt: new Date().toISOString(),
169
+ };
170
+ return {
171
+ content: [
172
+ {
173
+ type: "text",
174
+ text: JSON.stringify(responseData, null, 2),
175
+ },
176
+ ],
177
+ };
178
+ }
179
+ catch (error) {
180
+ logger.error(`Test execution failed: ${error.message}`, error);
181
+ errorResult = {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: JSON.stringify({
186
+ error: error.message,
187
+ }, null, 2),
188
+ },
189
+ ],
190
+ isError: true,
191
+ };
192
+ return errorResult;
193
+ }
194
+ finally {
195
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {});
196
+ }
197
+ });
198
+ }
@@ -0,0 +1,5 @@
1
+ export { registerAnalyzeChangesTool } from "./analyzeChangesTool.js";
2
+ export { registerAnalyzeTestHealthTool } from "./analyzeTestHealthTool.js";
3
+ export { registerExecuteTestsTool } from "./executeTestsTool.js";
4
+ export { registerActionsTool } from "./actionsTool.js";
5
+ export { registerStateCleanupTool } from "./stateCleanupTool.js";
@@ -0,0 +1,163 @@
1
+ import { z } from "zod";
2
+ import { StateManager } from "../../utils/AnalysisStateManager.js";
3
+ import { logger } from "../../utils/logger.js";
4
+ import { AnalyticsService } from "../../services/AnalyticsService.js";
5
+ const TOOL_NAME = "skyramp_state_cleanup";
6
+ /**
7
+ * Register the state file cleanup tool with the MCP server
8
+ *
9
+ * This tool helps manage state files created during test analysis workflows.
10
+ * It can list existing state files and clean up old ones to save disk space.
11
+ */
12
+ export function registerStateCleanupTool(server) {
13
+ server.registerTool(TOOL_NAME, {
14
+ description: `Manage and cleanup Skyramp state files.
15
+
16
+ **WHAT IT DOES:**
17
+ - List all existing state files with details (size, age, session ID, type)
18
+ - Delete state files older than specified age
19
+ - Show disk space usage by state files
20
+
21
+ **STATE TYPES MANAGED:**
22
+ - analysis: Test maintenance workflow (discovery, drift, execution, health)
23
+ - recommendation: Test recommendation workflow (analyze repo, map tests, recommend)
24
+
25
+ **WHEN TO USE:**
26
+ - Periodically to clean up old analysis sessions
27
+ - To check current state files in the system
28
+ - To free up disk space from temporary analysis data
29
+
30
+ **OPTIONS:**
31
+ - action: "list" - Show all state files
32
+ - action: "cleanup" - Delete old state files
33
+ - maxAgeHours: How old files must be before deletion (default: 24 hours)
34
+
35
+ **Output:**
36
+ Information about state files and cleanup results.`,
37
+ inputSchema: {
38
+ action: z
39
+ .enum(["list", "cleanup"])
40
+ .describe('Action to perform: "list" shows all state files, "cleanup" deletes old files'),
41
+ maxAgeHours: z
42
+ .number()
43
+ .optional()
44
+ .default(24)
45
+ .describe("For cleanup action: delete files older than this many hours (default: 24)"),
46
+ },
47
+ }, async (args) => {
48
+ let errorResult;
49
+ try {
50
+ logger.info(`State file ${args.action} requested`);
51
+ if (args.action === "list") {
52
+ const stateFiles = await StateManager.listStateFiles();
53
+ if (stateFiles.length === 0) {
54
+ return {
55
+ content: [
56
+ {
57
+ type: "text",
58
+ text: JSON.stringify({
59
+ message: "No state files found",
60
+ totalFiles: 0,
61
+ totalSize: 0,
62
+ }, null, 2),
63
+ },
64
+ ],
65
+ };
66
+ }
67
+ const totalSize = stateFiles.reduce((sum, file) => sum + file.size, 0);
68
+ const formatSize = (bytes) => {
69
+ if (bytes === 0)
70
+ return "0 B";
71
+ const k = 1024;
72
+ const sizes = ["B", "KB", "MB", "GB"];
73
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
74
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
75
+ };
76
+ const fileDetails = stateFiles.map((file) => ({
77
+ sessionId: file.sessionId,
78
+ stateType: file.stateType,
79
+ path: file.path,
80
+ size: formatSize(file.size),
81
+ sizeBytes: file.size,
82
+ createdAt: file.createdAt.toISOString(),
83
+ modifiedAt: file.modifiedAt.toISOString(),
84
+ ageHours: ((Date.now() - file.modifiedAt.getTime()) /
85
+ (1000 * 60 * 60)).toFixed(1),
86
+ }));
87
+ logger.info(`Found ${stateFiles.length} state files, total size: ${formatSize(totalSize)}`);
88
+ return {
89
+ content: [
90
+ {
91
+ type: "text",
92
+ text: JSON.stringify({
93
+ totalFiles: stateFiles.length,
94
+ totalSize: formatSize(totalSize),
95
+ totalSizeBytes: totalSize,
96
+ files: fileDetails,
97
+ }, null, 2),
98
+ },
99
+ ],
100
+ };
101
+ }
102
+ else if (args.action === "cleanup") {
103
+ const maxAgeHours = args.maxAgeHours || 24;
104
+ const deletedCount = await StateManager.cleanupOldStateFiles(maxAgeHours);
105
+ logger.info(`Cleaned up ${deletedCount} state files older than ${maxAgeHours} hours`);
106
+ if (deletedCount > 0) {
107
+ try {
108
+ await server.sendResourceListChanged();
109
+ }
110
+ catch {
111
+ logger.warning("Unable to update MCP clients with new resource list");
112
+ }
113
+ }
114
+ const remainingFiles = await StateManager.listStateFiles();
115
+ return {
116
+ content: [
117
+ {
118
+ type: "text",
119
+ text: JSON.stringify({
120
+ deletedCount,
121
+ maxAgeHours,
122
+ remainingFiles: remainingFiles.length,
123
+ message: deletedCount > 0
124
+ ? `Successfully deleted ${deletedCount} state file(s) older than ${maxAgeHours} hours`
125
+ : `No state files found older than ${maxAgeHours} hours`,
126
+ }, null, 2),
127
+ },
128
+ ],
129
+ };
130
+ }
131
+ errorResult = {
132
+ content: [
133
+ {
134
+ type: "text",
135
+ text: JSON.stringify({
136
+ error: "Invalid action",
137
+ }, null, 2),
138
+ },
139
+ ],
140
+ isError: true,
141
+ };
142
+ return errorResult;
143
+ }
144
+ catch (error) {
145
+ logger.error(`State cleanup failed: ${error.message}`, error);
146
+ errorResult = {
147
+ content: [
148
+ {
149
+ type: "text",
150
+ text: JSON.stringify({
151
+ error: error.message,
152
+ }, null, 2),
153
+ },
154
+ ],
155
+ isError: true,
156
+ };
157
+ return errorResult;
158
+ }
159
+ finally {
160
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {});
161
+ }
162
+ });
163
+ }
@@ -105,7 +105,7 @@ For each recommended test, you'll get:
105
105
  }
106
106
  stateData = normalizeRecommendationState(stateData);
107
107
  const { analysisScope } = stateData;
108
- let { repositoryPath, analysis } = stateData;
108
+ const { repositoryPath, analysis } = stateData;
109
109
  if (!analysis) {
110
110
  throw new Error("Session is missing analysis data. Run skyramp_analyze_repository first.");
111
111
  }
@@ -12,11 +12,11 @@ export async function analyzeOpenAPIWithGivenEndpoint(apiSchemaInput, uriInput,
12
12
  const requiredPathParams = pathParamRequired
13
13
  ? pathParamRequired.split(",").map((p) => p.trim())
14
14
  : [];
15
- let providedPathParams = pathParamsInput
15
+ const providedPathParams = pathParamsInput
16
16
  ? pathParamsInput.split(",").map((param) => param.split("=")[0].trim())
17
17
  : [];
18
18
  // Find missing path parameters by comparing keys
19
- let missingPathParams = requiredPathParams.filter((param) => !providedPathParams.includes(param));
19
+ const missingPathParams = requiredPathParams.filter((param) => !providedPathParams.includes(param));
20
20
  if (missingPathParams.length > 0) {
21
21
  return `${missingPathParams.join(", ")}`;
22
22
  }