@skyramp/mcp 0.0.56 → 0.0.57

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.
@@ -2,8 +2,9 @@ import { pushToolEvent } from "@skyramp/skyramp";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { getEntryPoint, getCIPlatform } from "../utils/telemetry.js";
6
+ import { logger } from "../utils/logger.js";
5
7
  export class AnalyticsService {
6
- static entryPoint = "mcp";
7
8
  static async pushTestGenerationToolEvent(toolName, result, params) {
8
9
  const analyticsResult = {};
9
10
  analyticsResult["prompt"] = params.prompt;
@@ -11,19 +12,29 @@ export class AnalyticsService {
11
12
  this.pushMCPToolEvent(toolName, result, analyticsResult);
12
13
  }
13
14
  static async pushMCPToolEvent(toolName, result, params) {
14
- let errorMessage = "";
15
- if (result && result.isError) {
16
- for (const content of result?.content ?? []) {
17
- if ("text" in content && content.text) {
18
- errorMessage += content.text + ", ";
15
+ try {
16
+ let errorMessage = "";
17
+ if (result && result.isError) {
18
+ for (const content of result?.content ?? []) {
19
+ if ("text" in content && content.text) {
20
+ errorMessage += content.text + ", ";
21
+ }
22
+ }
23
+ if (errorMessage.length > 0) {
24
+ errorMessage = errorMessage.slice(0, -2);
19
25
  }
20
26
  }
21
- if (errorMessage.length > 0) {
22
- errorMessage = errorMessage.slice(0, -2);
27
+ params.mcpServerVersion = getMCPPackageVersion();
28
+ const ciPlatform = getCIPlatform();
29
+ if (ciPlatform) {
30
+ params.ciPlatform = ciPlatform;
23
31
  }
32
+ await pushToolEvent(getEntryPoint(), toolName, errorMessage, params);
33
+ }
34
+ catch (error) {
35
+ logger.error("Error pushing MCP tool event", { error: error });
36
+ // silently ignore
24
37
  }
25
- params.mcpServerVersion = getMCPPackageVersion();
26
- await pushToolEvent(this.entryPoint, toolName, errorMessage, params);
27
38
  }
28
39
  /**
29
40
  * Track server crash events
@@ -34,7 +45,11 @@ export class AnalyticsService {
34
45
  errorStack: errorStack || "no stack trace",
35
46
  mcpServerVersion: getMCPPackageVersion(),
36
47
  };
37
- await pushToolEvent(this.entryPoint, "mcp_server_crash", errorMessage, params);
48
+ const ciPlatform = getCIPlatform();
49
+ if (ciPlatform) {
50
+ params.ciPlatform = ciPlatform;
51
+ }
52
+ await pushToolEvent(getEntryPoint(), "mcp_server_crash", errorMessage, params);
38
53
  }
39
54
  /**
40
55
  * Track tool timeout events
@@ -46,7 +61,11 @@ export class AnalyticsService {
46
61
  timeoutMs: timeoutMs.toString(),
47
62
  mcpServerVersion: getMCPPackageVersion(),
48
63
  };
49
- await pushToolEvent(this.entryPoint, `${toolName}_timeout`, errorMessage, timeoutParams);
64
+ const ciPlatform = getCIPlatform();
65
+ if (ciPlatform) {
66
+ timeoutParams.ciPlatform = ciPlatform;
67
+ }
68
+ await pushToolEvent(getEntryPoint(), `${toolName}_timeout`, errorMessage, timeoutParams);
50
69
  }
51
70
  }
52
71
  /**
@@ -6,7 +6,7 @@ import { stripVTControlCharacters } from "util";
6
6
  import { logger } from "../utils/logger.js";
7
7
  const DEFAULT_TIMEOUT = 300000; // 5 minutes
8
8
  const MAX_CONCURRENT_EXECUTIONS = 5;
9
- const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.9";
9
+ export const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.10";
10
10
  const DOCKER_PLATFORM = "linux/amd64";
11
11
  const EXECUTION_PROGRESS_INTERVAL = 10000; // 10 seconds between progress updates during execution
12
12
  // Files and directories to exclude when mounting workspace to Docker container
@@ -1,6 +1,7 @@
1
1
  import { SkyrampClient } from "@skyramp/skyramp";
2
2
  import { analyzeOpenAPIWithGivenEndpoint } from "../utils/analyze-openapi.js";
3
- import { getPathParameterValidationError, OUTPUT_DIR_FIELD_NAME, PATH_PARAMS_FIELD_NAME, QUERY_PARAMS_FIELD_NAME, FORM_PARAMS_FIELD_NAME, validateParams, validatePath, validateRequestData, TELEMETRY_entrypoint_FIELD_NAME, } from "../utils/utils.js";
3
+ import { getPathParameterValidationError, OUTPUT_DIR_FIELD_NAME, PATH_PARAMS_FIELD_NAME, QUERY_PARAMS_FIELD_NAME, FORM_PARAMS_FIELD_NAME, validateParams, validatePath, validateRequestData, } from "../utils/utils.js";
4
+ import { getEntryPoint } from "../utils/telemetry.js";
4
5
  import { getLanguageSteps } from "../utils/language-helper.js";
5
6
  import { logger } from "../utils/logger.js";
6
7
  export class TestGenerationService {
@@ -148,6 +149,10 @@ The generated test file remains unchanged and ready to use as-is.
148
149
  }
149
150
  async executeGeneration(generateOptions) {
150
151
  try {
152
+ //if auth header is authorization then exclude auth header from request
153
+ if (generateOptions.authHeader === "Authorization") {
154
+ generateOptions.authHeader = "";
155
+ }
151
156
  const result = await this.client.generateRestTest(generateOptions);
152
157
  // Check if the result indicates failure
153
158
  if (result && result.length > 0) {
@@ -201,7 +206,7 @@ ${result}`;
201
206
  generateInclude: params.include,
202
207
  generateExclude: params.exclude,
203
208
  generateInsecure: params.insecure,
204
- entrypoint: TELEMETRY_entrypoint_FIELD_NAME,
209
+ entrypoint: getEntryPoint(),
205
210
  chainingKey: params.chainingKey,
206
211
  };
207
212
  }
@@ -4,6 +4,7 @@ import * as fs from "fs/promises";
4
4
  import * as path from "path";
5
5
  import { AnalyticsService } from "../services/AnalyticsService.js";
6
6
  const TOOL_NAME = "skyramp_submit_report";
7
+ const DEFAULT_COMMIT_MESSAGE = "Added recommendations by Skyramp Testbot.";
7
8
  const testResultSchema = z.object({
8
9
  testType: z.string().describe("Type of test: Smoke, Contract, Integration, Fuzz, E2E, Load, etc."),
9
10
  endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
@@ -41,6 +42,12 @@ export function registerSubmitReportTool(server) {
41
42
  issuesFound: z
42
43
  .array(descriptionSchema)
43
44
  .describe("List of issues, failures, or bugs found. Use empty array [] if none."),
45
+ commitMessage: z
46
+ .string()
47
+ .optional()
48
+ .default(DEFAULT_COMMIT_MESSAGE)
49
+ .describe("Succinct commit message (under 72 chars) summarizing what the testbot did, " +
50
+ "e.g. 'add contract tests for /products endpoint' or 'update smoke tests for order API changes'"),
44
51
  },
45
52
  _meta: {
46
53
  keywords: ["report", "summary", "testbot", "submit"],
@@ -54,6 +61,7 @@ export function registerSubmitReportTool(server) {
54
61
  testMaintenance: params.testMaintenance,
55
62
  testResults: params.testResults,
56
63
  issuesFound: params.issuesFound,
64
+ commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim().slice(0, 72) || DEFAULT_COMMIT_MESSAGE,
57
65
  }, null, 2);
58
66
  logger.info("Submitting testbot report", {
59
67
  outputFile: params.summaryOutputFile,
@@ -88,10 +96,11 @@ export function registerSubmitReportTool(server) {
88
96
  return errorResult;
89
97
  }
90
98
  finally {
91
- const recordParams = {
99
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
92
100
  summary_output_file: params.summaryOutputFile,
93
- };
94
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, recordParams);
101
+ testResultCount: String(params.testResults.length),
102
+ payloadBytes: String(reportJson.length),
103
+ }).catch(() => { });
95
104
  }
96
105
  });
97
106
  }
@@ -7,7 +7,7 @@ jest.mock("../utils/logger.js", () => ({
7
7
  logger: { info: jest.fn(), error: jest.fn() },
8
8
  }));
9
9
  jest.mock("../services/AnalyticsService.js", () => ({
10
- AnalyticsService: { pushMCPToolEvent: jest.fn() },
10
+ AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
11
11
  }));
12
12
  function captureToolHandler() {
13
13
  let handler;
@@ -104,6 +104,64 @@ describe("registerSubmitReportTool", () => {
104
104
  expect(result.isError).toBe(true);
105
105
  expect(result.content[0].text).toContain("Failed to write report");
106
106
  });
107
+ it("should write commitMessage when provided", async () => {
108
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
109
+ tmpDirs.push(tmpDir);
110
+ const outputFile = path.join(tmpDir, "report.json");
111
+ const result = await handler({
112
+ ...sampleReportParams(outputFile),
113
+ commitMessage: "add contract tests for /products endpoint",
114
+ });
115
+ expect(result.isError).toBeUndefined();
116
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
117
+ expect(written.commitMessage).toBe("add contract tests for /products endpoint");
118
+ });
119
+ it("should use default commitMessage when omitted", async () => {
120
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
121
+ tmpDirs.push(tmpDir);
122
+ const outputFile = path.join(tmpDir, "report.json");
123
+ const result = await handler(sampleReportParams(outputFile));
124
+ expect(result.isError).toBeUndefined();
125
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
126
+ expect(written.commitMessage).toBe("Added recommendations by Skyramp Testbot.");
127
+ });
128
+ it("should sanitize commitMessage (newlines, length)", async () => {
129
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
130
+ tmpDirs.push(tmpDir);
131
+ const outputFile = path.join(tmpDir, "report.json");
132
+ const result = await handler({
133
+ ...sampleReportParams(outputFile),
134
+ commitMessage: " line one\nline two\r\nline three ",
135
+ });
136
+ expect(result.isError).toBeUndefined();
137
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
138
+ expect(written.commitMessage).toBe("line one line two line three");
139
+ expect(written.commitMessage.length).toBeLessThanOrEqual(72);
140
+ });
141
+ it("should use default commitMessage when provided as empty string", async () => {
142
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
143
+ tmpDirs.push(tmpDir);
144
+ const outputFile = path.join(tmpDir, "report.json");
145
+ const result = await handler({
146
+ ...sampleReportParams(outputFile),
147
+ commitMessage: "",
148
+ });
149
+ expect(result.isError).toBeUndefined();
150
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
151
+ expect(written.commitMessage).toBe("Added recommendations by Skyramp Testbot.");
152
+ });
153
+ it("should use default commitMessage when whitespace-only", async () => {
154
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
155
+ tmpDirs.push(tmpDir);
156
+ const outputFile = path.join(tmpDir, "report.json");
157
+ const result = await handler({
158
+ ...sampleReportParams(outputFile),
159
+ commitMessage: " \n\r\n ",
160
+ });
161
+ expect(result.isError).toBeUndefined();
162
+ const written = JSON.parse(await fs.readFile(outputFile, "utf-8"));
163
+ expect(written.commitMessage).toBe("Added recommendations by Skyramp Testbot.");
164
+ });
107
165
  it("should produce valid JSON with pretty formatting", async () => {
108
166
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
109
167
  tmpDirs.push(tmpDir);
@@ -1,7 +1,165 @@
1
1
  import { z } from "zod";
2
+ import { simpleGit } from "simple-git";
2
3
  import { logger } from "../../utils/logger.js";
3
4
  import { getRepositoryAnalysisPrompt } from "../../prompts/test-recommendation/repository-analysis-prompt.js";
4
5
  import { AnalyticsService } from "../../services/AnalyticsService.js";
6
+ import { StateManager, } from "../../utils/AnalysisStateManager.js";
7
+ const MAX_DIFF_LENGTH = 50_000;
8
+ /**
9
+ * Parse the list of changed files from a unified diff string by scanning
10
+ * for `diff --git a/... b/<file>` header lines.
11
+ */
12
+ function parseChangedFilesFromDiff(diffContent) {
13
+ const files = [];
14
+ for (const line of diffContent.split("\n")) {
15
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
16
+ if (match) {
17
+ files.push(match[1]);
18
+ }
19
+ }
20
+ return files;
21
+ }
22
+ /**
23
+ * Extract an API endpoint definition from a single diff line.
24
+ * Handles FastAPI, Flask, Express, and Spring patterns.
25
+ */
26
+ function parseRouteLine(line, sourceFile) {
27
+ const stripped = line.replace(/^[+-]\s*/, "").trim();
28
+ // FastAPI / Starlette: @router.get("/path") or @app.post("/path")
29
+ const decoratorMatch = stripped.match(/@(?:\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"'?#]+)/i);
30
+ if (decoratorMatch) {
31
+ return {
32
+ method: decoratorMatch[1].toUpperCase(),
33
+ path: decoratorMatch[2],
34
+ sourceFile,
35
+ };
36
+ }
37
+ // Flask: @app.route("/path", methods=["GET"])
38
+ const flaskMatch = stripped.match(/@\w+\.route\s*\(\s*["']([^"'?#]+)["'][^)]*methods\s*=\s*\[["'](\w+)["']/i);
39
+ if (flaskMatch) {
40
+ return {
41
+ method: flaskMatch[2].toUpperCase(),
42
+ path: flaskMatch[1],
43
+ sourceFile,
44
+ };
45
+ }
46
+ // Express: router.get('/path', ...) or app.post('/path', ...)
47
+ const expressMatch = stripped.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*["']([^"'?#]+)/i);
48
+ if (expressMatch) {
49
+ return {
50
+ method: expressMatch[1].toUpperCase(),
51
+ path: expressMatch[2],
52
+ sourceFile,
53
+ };
54
+ }
55
+ // Spring: @GetMapping("/path") or @PostMapping("/path")
56
+ const springMatch = stripped.match(/@(Get|Post|Put|Patch|Delete)Mapping\s*(?:\(\s*(?:value\s*=\s*)?["']([^"'?#]+)["'])?/i);
57
+ // Only capture Spring endpoints when an explicit path is provided.
58
+ // When no path argument is given, Spring uses the class-level @RequestMapping path,
59
+ // which we do not track here.
60
+ if (springMatch && springMatch[2]) {
61
+ return {
62
+ method: springMatch[1].toUpperCase(),
63
+ path: springMatch[2],
64
+ sourceFile,
65
+ };
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Parse a unified diff and extract new/modified API endpoints.
71
+ * Processes diffContent entirely inside the tool — never embedded in the response.
72
+ */
73
+ function parseEndpointsFromDiff(diffData) {
74
+ const lines = diffData.diffContent.split("\n");
75
+ const addedRoutes = [];
76
+ const removedKeys = new Set();
77
+ let currentFile = "";
78
+ for (const line of lines) {
79
+ const fileMatch = line.match(/^diff --git a\/.+ b\/(.+)$/);
80
+ if (fileMatch) {
81
+ currentFile = fileMatch[1];
82
+ continue;
83
+ }
84
+ if (line.startsWith("+") && !line.startsWith("+++")) {
85
+ const ep = parseRouteLine(line, currentFile);
86
+ if (ep)
87
+ addedRoutes.push(ep);
88
+ }
89
+ else if (line.startsWith("-") && !line.startsWith("---")) {
90
+ const ep = parseRouteLine(line, currentFile);
91
+ if (ep)
92
+ removedKeys.add(`${ep.method} ${ep.path}`);
93
+ }
94
+ }
95
+ const newEndpoints = [];
96
+ const modifiedEndpoints = [];
97
+ for (const ep of addedRoutes) {
98
+ if (removedKeys.has(`${ep.method} ${ep.path}`)) {
99
+ modifiedEndpoints.push(ep);
100
+ }
101
+ else {
102
+ newEndpoints.push(ep);
103
+ }
104
+ }
105
+ // Infer services from changed file paths (e.g. src/services/order-service.ts)
106
+ const servicePattern = /(?:services?|modules?|apps?)\/([a-z0-9_-]+)/i;
107
+ const affectedServices = [
108
+ ...new Set(diffData.changedFiles
109
+ .map((f) => f.match(servicePattern)?.[1])
110
+ .filter((s) => !!s)),
111
+ ];
112
+ return {
113
+ currentBranch: diffData.currentBranch,
114
+ baseBranch: diffData.baseBranch,
115
+ changedFiles: diffData.changedFiles,
116
+ diffStat: diffData.diffStat,
117
+ newEndpoints,
118
+ modifiedEndpoints,
119
+ affectedServices,
120
+ };
121
+ }
122
+ async function computeBranchDiff(repositoryPath) {
123
+ const git = simpleGit(repositoryPath);
124
+ const isRepo = await git.checkIsRepo();
125
+ if (!isRepo) {
126
+ throw new Error(`"${repositoryPath}" is not a Git repository.`);
127
+ }
128
+ const branchInfo = await git.branch();
129
+ const currentBranch = branchInfo.current || "HEAD";
130
+ // Prefer remote tracking refs (origin/main) over local branch names so this
131
+ // works in detached-HEAD CI environments (e.g. PR merge checkouts) where
132
+ // local "main"/"master" branches don't exist.
133
+ let baseBranch = "origin/main";
134
+ try {
135
+ const remoteBranches = await git.branch(["-r"]);
136
+ if (remoteBranches.all.some((b) => b.endsWith("/main"))) {
137
+ baseBranch = "origin/main";
138
+ }
139
+ else if (remoteBranches.all.some((b) => b.endsWith("/master"))) {
140
+ baseBranch = "origin/master";
141
+ }
142
+ }
143
+ catch {
144
+ logger.debug("Could not determine remote default branch, falling back to origin/main");
145
+ }
146
+ const changedFilesRaw = await git.diff([
147
+ `${baseBranch}...HEAD`,
148
+ "--name-only",
149
+ ]);
150
+ const changedFiles = changedFilesRaw
151
+ .split("\n")
152
+ .map((f) => f.trim())
153
+ .filter((f) => f.length > 0);
154
+ const diffStat = await git.diff([`${baseBranch}...HEAD`, "--stat"]);
155
+ let diffContent = await git.diff([`${baseBranch}...HEAD`]);
156
+ if (diffContent.length > MAX_DIFF_LENGTH) {
157
+ diffContent =
158
+ diffContent.substring(0, MAX_DIFF_LENGTH) +
159
+ `\n\n... [diff truncated at ${MAX_DIFF_LENGTH} chars, ${changedFiles.length} files total] ...`;
160
+ }
161
+ return { currentBranch, baseBranch, changedFiles, diffContent, diffStat };
162
+ }
5
163
  /**
6
164
  * Analyze Repository Tool
7
165
  * MCP tool for comprehensive repository analysis
@@ -14,6 +172,10 @@ const analyzeRepositorySchema = z.object({
14
172
  .enum(["quick", "full"])
15
173
  .default("full")
16
174
  .describe("Analysis depth: 'quick' for basic info, 'full' for comprehensive analysis"),
175
+ analysisScope: z
176
+ .enum(["full_repo", "current_branch_diff"])
177
+ .default("full_repo")
178
+ .describe("Scope of analysis. 'full_repo' analyzes the entire repository. 'current_branch_diff' analyzes only the changes in the current branch compared to the default branch (e.g., main/master), useful for PR-scoped test recommendations."),
17
179
  focusAreas: z
18
180
  .array(z.string())
19
181
  .optional()
@@ -50,11 +212,14 @@ This tool performs comprehensive repository analysis including:
50
212
 
51
213
  The analysis provides structured data that can be used by skyramp_map_tests to calculate test priorities.
52
214
 
215
+ When \`analysisScope\` is set to \`"current_branch_diff"\`, the analysis focuses specifically on the code changes in the current branch — identifying new/modified endpoints, changed services, and affected areas — rather than scanning the entire repository. This is ideal for PR-scoped test recommendations.
216
+
53
217
  Example usage:
54
218
  \`\`\`
55
219
  {
56
220
  "repositoryPath": "/Users/dev/my-api",
57
- "scanDepth": "full"
221
+ "scanDepth": "full",
222
+ "analysisScope": "current_branch_diff",
58
223
  }
59
224
  \`\`\`
60
225
 
@@ -66,27 +231,58 @@ Output: Detailed RepositoryAnalysis JSON object with all repository characterist
66
231
  }, async (params) => {
67
232
  let errorResult;
68
233
  try {
234
+ const analysisScope = params.analysisScope || "full_repo";
69
235
  logger.info("Analyze repository tool invoked", {
70
236
  repositoryPath: params.repositoryPath,
71
237
  scanDepth: params.scanDepth,
238
+ analysisScope,
239
+ });
240
+ let diffData;
241
+ if (analysisScope === "current_branch_diff") {
242
+ try {
243
+ diffData = await computeBranchDiff(params.repositoryPath);
244
+ logger.info("Branch diff computed via git", {
245
+ currentBranch: diffData.currentBranch,
246
+ baseBranch: diffData.baseBranch,
247
+ changedFiles: diffData.changedFiles.length,
248
+ });
249
+ }
250
+ catch (error) {
251
+ const msg = error instanceof Error ? error.message : String(error);
252
+ logger.error("Failed to obtain branch diff", { error: msg });
253
+ throw new Error(`Failed to obtain branch diff: ${msg}`);
254
+ }
255
+ }
256
+ // Parse the diff content inside the tool — never embed raw diff in the response.
257
+ const parsedDiff = diffData ? parseEndpointsFromDiff(diffData) : undefined;
258
+ const analysisPrompt = getRepositoryAnalysisPrompt(params.repositoryPath, analysisScope, parsedDiff);
259
+ const stateManager = new StateManager("recommendation");
260
+ const stateFilePath = stateManager.getStatePath();
261
+ logger.info("Created state file for analysis", {
262
+ stateFile: stateFilePath,
72
263
  });
73
- const analysisPrompt = getRepositoryAnalysisPrompt(params.repositoryPath);
264
+ // Compact diff section built entirely from pre-parsed data — no raw content
265
+ const diffSection = parsedDiff
266
+ ? `
267
+ ## Branch Diff Context
268
+ **Branch**: \`${parsedDiff.currentBranch}\` → base: \`${parsedDiff.baseBranch}\`
269
+ **Changed Files** (${parsedDiff.changedFiles.length}): ${parsedDiff.changedFiles.join(", ")}
270
+ **New Endpoints** (${parsedDiff.newEndpoints.length}): ${parsedDiff.newEndpoints.map((e) => `${e.method} ${e.path}`).join(", ") || "none detected"}
271
+ **Modified Endpoints** (${parsedDiff.modifiedEndpoints.length}): ${parsedDiff.modifiedEndpoints.map((e) => `${e.method} ${e.path}`).join(", ") || "none detected"}
272
+ **Affected Services**: ${parsedDiff.affectedServices.join(", ") || "none detected"}
273
+ `
274
+ : "";
74
275
  return {
75
276
  content: [
76
277
  {
77
278
  type: "text",
78
- text: `⚠️ CRITICAL INSTRUCTION - READ FIRST:
79
- - Return the JSON object directly in your response text.
80
- - DO NOT create or save any files (no repository_analysis.json or .md files).
81
- - DO NOT use file write tools or save analysis to disk.
82
- - Output the complete analysis JSON inline in your response.
83
-
84
- # Repository Analysis Request
279
+ text: `# Repository Analysis Request
85
280
 
86
281
  Please analyze the repository at: \`${params.repositoryPath}\`
282
+ **Analysis Scope**: \`${analysisScope}\`
87
283
 
88
284
  ${params.focusAreas ? `Focus on: ${params.focusAreas.join(", ")}\n` : ""}
89
-
285
+ ${diffSection}
90
286
  Use the following tools to gather information:
91
287
  - \`codebase_search\` - to understand code patterns and structure
92
288
  - \`grep\` - to find specific patterns (route definitions, dependencies, etc.)
@@ -97,10 +293,12 @@ Use the following tools to gather information:
97
293
  ${analysisPrompt}
98
294
 
99
295
  After gathering all information:
100
- 1. Return the RepositoryAnalysis JSON in your response
101
- 2. Then call \`skyramp_map_tests\` with:
102
- - repositoryPath: \`${params.repositoryPath}\`
103
- - analysisReport: <the JSON you just created>`,
296
+ 1. Construct the RepositoryAnalysis JSON${analysisScope === "current_branch_diff" ? " (include branchDiffContext)" : ""}
297
+ 2. Save the complete JSON to this file: \`${stateFilePath}\`
298
+ 3. Then call \`skyramp_map_tests\` with:
299
+ - stateFile: \`${stateFilePath}\`
300
+ - analysisScope: \`${analysisScope}\`
301
+ Do NOT pass the JSON inline as analysisReport — use stateFile to avoid serialization issues with large JSON.`,
104
302
  },
105
303
  ],
106
304
  isError: false,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { readFile } from "fs/promises";
2
3
  import { repositoryAnalysisSchema } from "../../types/RepositoryAnalysis.js";
3
4
  import { TestType } from "../../types/TestTypes.js";
4
5
  import { ScoringEngine } from "../../utils/scoring-engine.js";
@@ -10,12 +11,13 @@ import { AnalyticsService } from "../../services/AnalyticsService.js";
10
11
  * MCP tool for calculating test priority scores
11
12
  */
12
13
  const mapTestsSchema = z.object({
13
- repositoryPath: z
14
+ stateFile: z
14
15
  .string()
15
- .describe("Absolute path to the repository that was analyzed (used for saving results)"),
16
- analysisReport: z
17
- .union([z.string(), repositoryAnalysisSchema])
18
- .describe("Repository analysis result (JSON string or object from skyramp_analyze_repository)"),
16
+ .describe("Path to a state file containing the RepositoryAnalysis JSON (created by skyramp_analyze_repository)."),
17
+ analysisScope: z
18
+ .enum(["full_repo", "current_branch_diff"])
19
+ .default("full_repo")
20
+ .describe("Scope of the analysis that produced the report. Must match the scope used in skyramp_analyze_repository. 'current_branch_diff' scopes recommendations to only the endpoints/code changed in the current branch."),
19
21
  customWeights: z
20
22
  .record(z.number())
21
23
  .optional()
@@ -45,8 +47,7 @@ The scoring algorithm considers:
45
47
  Example usage:
46
48
  \`\`\`
47
49
  {
48
- "repositoryPath": "/Users/dev/my-api",
49
- "analysisReport": "<RepositoryAnalysis JSON from skyramp_analyze_repository>",
50
+ "stateFile": "/tmp/skyramp-recommendation-123456.json",
50
51
  "customWeights": {
51
52
  "load": 1.5,
52
53
  "fuzz": 1.3
@@ -62,33 +63,51 @@ Output: TestMappingResult with priority scores, and state file path for next ste
62
63
  }, async (params) => {
63
64
  let errorResult;
64
65
  try {
65
- logger.info("Map tests tool invoked");
66
- // Parse and validate analysis report
67
- let analysis = params.analysisReport;
68
- if (typeof analysis === "string") {
69
- try {
70
- analysis = JSON.parse(analysis);
66
+ logger.info("Map tests tool invoked", {
67
+ hasStateFile: !!params.stateFile,
68
+ });
69
+ if (!params.stateFile) {
70
+ throw new Error("stateFile is required. Run skyramp_analyze_repository first to create one.");
71
+ }
72
+ let analysis;
73
+ let repositoryPath;
74
+ logger.info("Reading analysis from state file", {
75
+ stateFile: params.stateFile,
76
+ });
77
+ try {
78
+ const raw = await readFile(params.stateFile, "utf-8");
79
+ const parsed = JSON.parse(raw);
80
+ if (parsed.metadata?.stateType && parsed.analysis) {
81
+ analysis = parsed.analysis;
82
+ if (parsed.repositoryPath) {
83
+ repositoryPath = parsed.repositoryPath;
84
+ }
71
85
  }
72
- catch (error) {
73
- throw new Error("analysisReport must be valid JSON string or RepositoryAnalysis object. JSON parsing failed.");
86
+ else {
87
+ analysis = parsed;
74
88
  }
75
89
  }
90
+ catch (error) {
91
+ const msg = error instanceof Error ? error.message : String(error);
92
+ throw new Error(`Failed to read analysis from stateFile "${params.stateFile}": ${msg}`);
93
+ }
76
94
  // Validate the analysis object against the schema
77
95
  const validationResult = repositoryAnalysisSchema.safeParse(analysis);
78
96
  if (!validationResult.success) {
79
97
  const errors = validationResult.error.errors
80
98
  .map((e) => `${e.path.join(".")}: ${e.message}`)
81
99
  .join("; ");
82
- throw new Error(`analysisReport validation failed: ${errors}`);
100
+ throw new Error(`Analysis validation failed: ${errors}`);
83
101
  }
84
102
  analysis = validationResult.data;
85
103
  // Determine which test types to evaluate
86
104
  const testTypesToEvaluate = params.focusTestTypes ||
87
105
  Object.values(TestType).filter((t) => typeof t === "string");
88
106
  // Calculate scores for each test type
107
+ const analysisScope = params.analysisScope || "full_repo";
89
108
  const priorityScores = [];
90
109
  for (const testType of testTypesToEvaluate) {
91
- const score = ScoringEngine.calculateTestScore(testType, analysis);
110
+ const score = ScoringEngine.calculateTestScore(testType, analysis, analysisScope);
92
111
  // Apply custom weights if provided
93
112
  if (params.customWeights && params.customWeights[testType]) {
94
113
  score._finalScore *= params.customWeights[testType];
@@ -144,21 +163,24 @@ Output: TestMappingResult with priority scores, and state file path for next ste
144
163
  contextFactors,
145
164
  summary: { highPriority, mediumPriority, lowPriority },
146
165
  };
147
- // Save results using StateManager (stores in /tmp)
148
- const stateManager = new StateManager("recommendation");
166
+ // Save results using StateManager reuse session from input stateFile if provided
167
+ const stateManager = params.stateFile
168
+ ? StateManager.fromStatePath(params.stateFile)
169
+ : new StateManager("recommendation");
149
170
  const stateData = {
150
- repositoryPath: params.repositoryPath,
171
+ repositoryPath: repositoryPath ?? "",
172
+ analysisScope,
151
173
  analysis,
152
174
  mapping,
153
175
  };
154
176
  await stateManager.writeData(stateData, {
155
- repositoryPath: params.repositoryPath,
177
+ repositoryPath,
156
178
  step: "map-tests",
157
179
  });
158
180
  const stateFilePath = stateManager.getStatePath();
159
181
  const sessionId = stateManager.getSessionId();
160
182
  const stateSize = await stateManager.getSizeFormatted();
161
- logger.info(`Saved test mapping to: ${stateFilePath} (${stateSize})`);
183
+ logger.info(`Saved test mapping to: ${stateFilePath} (${stateSize}), repositoryPath: ${repositoryPath ?? "unknown"}`);
162
184
  // Format output
163
185
  const output = `# Test Priority Mapping
164
186