@gaffer-sh/mcp 0.2.0 → 0.3.0
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/dist/index.js +514 -183
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,6 +6,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
6
6
|
|
|
7
7
|
// src/api-client.ts
|
|
8
8
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
9
|
+
var MAX_RETRIES = 3;
|
|
10
|
+
var INITIAL_RETRY_DELAY_MS = 1e3;
|
|
11
|
+
var RETRYABLE_STATUS_CODES = [401, 429, 500, 502, 503, 504];
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
9
15
|
function detectTokenType(token) {
|
|
10
16
|
if (token.startsWith("gaf_")) {
|
|
11
17
|
return "user";
|
|
@@ -42,7 +48,7 @@ var GafferApiClient = class _GafferApiClient {
|
|
|
42
48
|
return this.tokenType === "user";
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
|
-
* Make authenticated request to Gaffer API
|
|
51
|
+
* Make authenticated request to Gaffer API with retry logic
|
|
46
52
|
*/
|
|
47
53
|
async request(endpoint, params) {
|
|
48
54
|
const url = new URL(`/api/v1${endpoint}`, this.baseUrl);
|
|
@@ -53,32 +59,59 @@ var GafferApiClient = class _GafferApiClient {
|
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
62
|
+
let lastError = null;
|
|
63
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(url.toString(), {
|
|
68
|
+
method: "GET",
|
|
69
|
+
headers: {
|
|
70
|
+
"X-API-Key": this.apiKey,
|
|
71
|
+
"Accept": "application/json",
|
|
72
|
+
"User-Agent": "gaffer-mcp/0.2.1"
|
|
73
|
+
},
|
|
74
|
+
signal: controller.signal
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const errorData = await response.json().catch(() => ({}));
|
|
78
|
+
if (RETRYABLE_STATUS_CODES.includes(response.status) && attempt < MAX_RETRIES) {
|
|
79
|
+
let delayMs = INITIAL_RETRY_DELAY_MS * 2 ** attempt;
|
|
80
|
+
if (response.status === 429) {
|
|
81
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
82
|
+
if (retryAfter) {
|
|
83
|
+
delayMs = Math.max(delayMs, Number.parseInt(retryAfter, 10) * 1e3);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
lastError = new Error(errorData.error?.message || `API request failed: ${response.status}`);
|
|
87
|
+
await sleep(delayMs);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const errorMessage = errorData.error?.message || `API request failed: ${response.status}`;
|
|
91
|
+
throw new Error(errorMessage);
|
|
92
|
+
}
|
|
93
|
+
return response.json();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
clearTimeout(timeoutId);
|
|
96
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
97
|
+
lastError = new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`);
|
|
98
|
+
if (attempt < MAX_RETRIES) {
|
|
99
|
+
await sleep(INITIAL_RETRY_DELAY_MS * 2 ** attempt);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
throw lastError;
|
|
103
|
+
}
|
|
104
|
+
if (error instanceof TypeError && attempt < MAX_RETRIES) {
|
|
105
|
+
lastError = error;
|
|
106
|
+
await sleep(INITIAL_RETRY_DELAY_MS * 2 ** attempt);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timeoutId);
|
|
77
112
|
}
|
|
78
|
-
throw error;
|
|
79
|
-
} finally {
|
|
80
|
-
clearTimeout(timeoutId);
|
|
81
113
|
}
|
|
114
|
+
throw lastError || new Error("Request failed after retries");
|
|
82
115
|
}
|
|
83
116
|
/**
|
|
84
117
|
* List all projects the user has access to
|
|
@@ -228,6 +261,7 @@ var GafferApiClient = class _GafferApiClient {
|
|
|
228
261
|
* @param options.days - Analysis period in days (default: 30)
|
|
229
262
|
* @param options.limit - Maximum number of results (default: 20)
|
|
230
263
|
* @param options.framework - Filter by test framework
|
|
264
|
+
* @param options.branch - Filter by git branch name
|
|
231
265
|
* @returns Slowest tests sorted by P95 duration
|
|
232
266
|
*/
|
|
233
267
|
async getSlowestTests(options) {
|
|
@@ -240,31 +274,195 @@ var GafferApiClient = class _GafferApiClient {
|
|
|
240
274
|
return this.request(`/user/projects/${options.projectId}/slowest-tests`, {
|
|
241
275
|
...options.days && { days: options.days },
|
|
242
276
|
...options.limit && { limit: options.limit },
|
|
243
|
-
...options.framework && { framework: options.framework }
|
|
277
|
+
...options.framework && { framework: options.framework },
|
|
278
|
+
...options.branch && { branch: options.branch }
|
|
244
279
|
});
|
|
245
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Get parsed test results for a specific test run
|
|
283
|
+
*
|
|
284
|
+
* @param options - Query options
|
|
285
|
+
* @param options.projectId - The project ID (required)
|
|
286
|
+
* @param options.testRunId - The test run ID (required)
|
|
287
|
+
* @param options.status - Filter by test status ('passed', 'failed', 'skipped')
|
|
288
|
+
* @param options.limit - Maximum number of results (default: 100)
|
|
289
|
+
* @param options.offset - Pagination offset (default: 0)
|
|
290
|
+
* @returns Parsed test cases with pagination
|
|
291
|
+
*/
|
|
292
|
+
async getTestRunDetails(options) {
|
|
293
|
+
if (!this.isUserToken()) {
|
|
294
|
+
throw new Error("getTestRunDetails requires a user API Key (gaf_).");
|
|
295
|
+
}
|
|
296
|
+
if (!options.projectId) {
|
|
297
|
+
throw new Error("projectId is required");
|
|
298
|
+
}
|
|
299
|
+
if (!options.testRunId) {
|
|
300
|
+
throw new Error("testRunId is required");
|
|
301
|
+
}
|
|
302
|
+
return this.request(
|
|
303
|
+
`/user/projects/${options.projectId}/test-runs/${options.testRunId}/details`,
|
|
304
|
+
{
|
|
305
|
+
...options.status && { status: options.status },
|
|
306
|
+
...options.limit && { limit: options.limit },
|
|
307
|
+
...options.offset && { offset: options.offset }
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Compare test metrics between two commits or test runs
|
|
313
|
+
*
|
|
314
|
+
* @param options - Query options
|
|
315
|
+
* @param options.projectId - The project ID (required)
|
|
316
|
+
* @param options.testName - The test name to compare (required)
|
|
317
|
+
* @param options.beforeCommit - Commit SHA for before (use with afterCommit)
|
|
318
|
+
* @param options.afterCommit - Commit SHA for after (use with beforeCommit)
|
|
319
|
+
* @param options.beforeRunId - Test run ID for before (use with afterRunId)
|
|
320
|
+
* @param options.afterRunId - Test run ID for after (use with beforeRunId)
|
|
321
|
+
* @returns Comparison of test metrics
|
|
322
|
+
*/
|
|
323
|
+
async compareTestMetrics(options) {
|
|
324
|
+
if (!this.isUserToken()) {
|
|
325
|
+
throw new Error("compareTestMetrics requires a user API Key (gaf_).");
|
|
326
|
+
}
|
|
327
|
+
if (!options.projectId) {
|
|
328
|
+
throw new Error("projectId is required");
|
|
329
|
+
}
|
|
330
|
+
if (!options.testName) {
|
|
331
|
+
throw new Error("testName is required");
|
|
332
|
+
}
|
|
333
|
+
return this.request(
|
|
334
|
+
`/user/projects/${options.projectId}/compare-test`,
|
|
335
|
+
{
|
|
336
|
+
testName: options.testName,
|
|
337
|
+
...options.beforeCommit && { beforeCommit: options.beforeCommit },
|
|
338
|
+
...options.afterCommit && { afterCommit: options.afterCommit },
|
|
339
|
+
...options.beforeRunId && { beforeRunId: options.beforeRunId },
|
|
340
|
+
...options.afterRunId && { afterRunId: options.afterRunId }
|
|
341
|
+
}
|
|
342
|
+
);
|
|
343
|
+
}
|
|
246
344
|
};
|
|
247
345
|
|
|
248
|
-
// src/tools/
|
|
346
|
+
// src/tools/compare-test-metrics.ts
|
|
249
347
|
import { z } from "zod";
|
|
348
|
+
var compareTestMetricsInputSchema = {
|
|
349
|
+
projectId: z.string().describe("Project ID. Required when using a user API Key (gaf_). Use list_projects to find project IDs."),
|
|
350
|
+
testName: z.string().describe("The test name to compare. Can be the short name or full name including describe blocks."),
|
|
351
|
+
beforeCommit: z.string().optional().describe('Commit SHA for the "before" measurement. Use with afterCommit.'),
|
|
352
|
+
afterCommit: z.string().optional().describe('Commit SHA for the "after" measurement. Use with beforeCommit.'),
|
|
353
|
+
beforeRunId: z.string().optional().describe('Test run ID for the "before" measurement. Use with afterRunId.'),
|
|
354
|
+
afterRunId: z.string().optional().describe('Test run ID for the "after" measurement. Use with beforeRunId.')
|
|
355
|
+
};
|
|
356
|
+
var compareTestMetricsOutputSchema = {
|
|
357
|
+
testName: z.string(),
|
|
358
|
+
before: z.object({
|
|
359
|
+
testRunId: z.string(),
|
|
360
|
+
commit: z.string().nullable(),
|
|
361
|
+
branch: z.string().nullable(),
|
|
362
|
+
status: z.enum(["passed", "failed", "skipped"]),
|
|
363
|
+
durationMs: z.number().nullable(),
|
|
364
|
+
createdAt: z.string()
|
|
365
|
+
}),
|
|
366
|
+
after: z.object({
|
|
367
|
+
testRunId: z.string(),
|
|
368
|
+
commit: z.string().nullable(),
|
|
369
|
+
branch: z.string().nullable(),
|
|
370
|
+
status: z.enum(["passed", "failed", "skipped"]),
|
|
371
|
+
durationMs: z.number().nullable(),
|
|
372
|
+
createdAt: z.string()
|
|
373
|
+
}),
|
|
374
|
+
change: z.object({
|
|
375
|
+
durationMs: z.number().nullable(),
|
|
376
|
+
percentChange: z.number().nullable(),
|
|
377
|
+
statusChanged: z.boolean()
|
|
378
|
+
})
|
|
379
|
+
};
|
|
380
|
+
async function executeCompareTestMetrics(client, input) {
|
|
381
|
+
const hasCommits = input.beforeCommit && input.afterCommit;
|
|
382
|
+
const hasRunIds = input.beforeRunId && input.afterRunId;
|
|
383
|
+
if (!hasCommits && !hasRunIds) {
|
|
384
|
+
throw new Error("Must provide either (beforeCommit + afterCommit) or (beforeRunId + afterRunId)");
|
|
385
|
+
}
|
|
386
|
+
if (hasCommits) {
|
|
387
|
+
if (input.beforeCommit.trim().length === 0 || input.afterCommit.trim().length === 0) {
|
|
388
|
+
throw new Error("beforeCommit and afterCommit must not be empty strings");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (hasRunIds) {
|
|
392
|
+
if (input.beforeRunId.trim().length === 0 || input.afterRunId.trim().length === 0) {
|
|
393
|
+
throw new Error("beforeRunId and afterRunId must not be empty strings");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const response = await client.compareTestMetrics({
|
|
397
|
+
projectId: input.projectId,
|
|
398
|
+
testName: input.testName,
|
|
399
|
+
beforeCommit: input.beforeCommit,
|
|
400
|
+
afterCommit: input.afterCommit,
|
|
401
|
+
beforeRunId: input.beforeRunId,
|
|
402
|
+
afterRunId: input.afterRunId
|
|
403
|
+
});
|
|
404
|
+
return response;
|
|
405
|
+
}
|
|
406
|
+
var compareTestMetricsMetadata = {
|
|
407
|
+
name: "compare_test_metrics",
|
|
408
|
+
title: "Compare Test Metrics",
|
|
409
|
+
description: `Compare test metrics between two commits or test runs.
|
|
410
|
+
|
|
411
|
+
Useful for measuring the impact of code changes on test performance or reliability.
|
|
412
|
+
|
|
413
|
+
When using a user API Key (gaf_), you must provide a projectId.
|
|
414
|
+
Use list_projects to find available project IDs.
|
|
415
|
+
|
|
416
|
+
Parameters:
|
|
417
|
+
- projectId (required): Project ID
|
|
418
|
+
- testName (required): The test name to compare (short name or full name)
|
|
419
|
+
- Option 1 - Compare by commit:
|
|
420
|
+
- beforeCommit: Commit SHA for "before" measurement
|
|
421
|
+
- afterCommit: Commit SHA for "after" measurement
|
|
422
|
+
- Option 2 - Compare by test run:
|
|
423
|
+
- beforeRunId: Test run ID for "before" measurement
|
|
424
|
+
- afterRunId: Test run ID for "after" measurement
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
- testName: The test that was compared
|
|
428
|
+
- before: Metrics from the before commit/run
|
|
429
|
+
- testRunId, commit, branch, status, durationMs, createdAt
|
|
430
|
+
- after: Metrics from the after commit/run
|
|
431
|
+
- testRunId, commit, branch, status, durationMs, createdAt
|
|
432
|
+
- change: Calculated changes
|
|
433
|
+
- durationMs: Duration difference (negative = faster)
|
|
434
|
+
- percentChange: Percentage change (negative = improvement)
|
|
435
|
+
- statusChanged: Whether pass/fail status changed
|
|
436
|
+
|
|
437
|
+
Use cases:
|
|
438
|
+
- "Did my fix make this test faster?"
|
|
439
|
+
- "Compare test performance between these two commits"
|
|
440
|
+
- "Did this test start failing after my changes?"
|
|
441
|
+
- "Show me the before/after for the slow test I optimized"
|
|
442
|
+
|
|
443
|
+
Tip: Use get_test_history first to find the commit SHAs or test run IDs you want to compare.`
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// src/tools/get-flaky-tests.ts
|
|
447
|
+
import { z as z2 } from "zod";
|
|
250
448
|
var getFlakyTestsInputSchema = {
|
|
251
|
-
projectId:
|
|
252
|
-
threshold:
|
|
253
|
-
limit:
|
|
254
|
-
days:
|
|
449
|
+
projectId: z2.string().optional().describe("Project ID to get flaky tests for. Required when using a user API Key (gaf_). Use list_projects to find project IDs."),
|
|
450
|
+
threshold: z2.number().min(0).max(1).optional().describe("Minimum flip rate to be considered flaky (0-1, default: 0.1 = 10%)"),
|
|
451
|
+
limit: z2.number().int().min(1).max(100).optional().describe("Maximum number of flaky tests to return (default: 50)"),
|
|
452
|
+
days: z2.number().int().min(1).max(365).optional().describe("Analysis period in days (default: 30)")
|
|
255
453
|
};
|
|
256
454
|
var getFlakyTestsOutputSchema = {
|
|
257
|
-
flakyTests:
|
|
258
|
-
name:
|
|
259
|
-
flipRate:
|
|
260
|
-
flipCount:
|
|
261
|
-
totalRuns:
|
|
262
|
-
lastSeen:
|
|
455
|
+
flakyTests: z2.array(z2.object({
|
|
456
|
+
name: z2.string(),
|
|
457
|
+
flipRate: z2.number(),
|
|
458
|
+
flipCount: z2.number(),
|
|
459
|
+
totalRuns: z2.number(),
|
|
460
|
+
lastSeen: z2.string()
|
|
263
461
|
})),
|
|
264
|
-
summary:
|
|
265
|
-
threshold:
|
|
266
|
-
totalFlaky:
|
|
267
|
-
period:
|
|
462
|
+
summary: z2.object({
|
|
463
|
+
threshold: z2.number(),
|
|
464
|
+
totalFlaky: z2.number(),
|
|
465
|
+
period: z2.number()
|
|
268
466
|
})
|
|
269
467
|
};
|
|
270
468
|
async function executeGetFlakyTests(client, input) {
|
|
@@ -304,22 +502,22 @@ specific tests are flaky and need investigation.`
|
|
|
304
502
|
};
|
|
305
503
|
|
|
306
504
|
// src/tools/get-project-health.ts
|
|
307
|
-
import { z as
|
|
505
|
+
import { z as z3 } from "zod";
|
|
308
506
|
var getProjectHealthInputSchema = {
|
|
309
|
-
projectId:
|
|
310
|
-
days:
|
|
507
|
+
projectId: z3.string().optional().describe("Project ID to get health for. Required when using a user API Key (gaf_). Use list_projects to find project IDs."),
|
|
508
|
+
days: z3.number().int().min(1).max(365).optional().describe("Number of days to analyze (default: 30)")
|
|
311
509
|
};
|
|
312
510
|
var getProjectHealthOutputSchema = {
|
|
313
|
-
projectName:
|
|
314
|
-
healthScore:
|
|
315
|
-
passRate:
|
|
316
|
-
testRunCount:
|
|
317
|
-
flakyTestCount:
|
|
318
|
-
trend:
|
|
319
|
-
period:
|
|
320
|
-
days:
|
|
321
|
-
start:
|
|
322
|
-
end:
|
|
511
|
+
projectName: z3.string(),
|
|
512
|
+
healthScore: z3.number(),
|
|
513
|
+
passRate: z3.number().nullable(),
|
|
514
|
+
testRunCount: z3.number(),
|
|
515
|
+
flakyTestCount: z3.number(),
|
|
516
|
+
trend: z3.enum(["up", "down", "stable"]),
|
|
517
|
+
period: z3.object({
|
|
518
|
+
days: z3.number(),
|
|
519
|
+
start: z3.string(),
|
|
520
|
+
end: z3.string()
|
|
323
521
|
})
|
|
324
522
|
};
|
|
325
523
|
async function executeGetProjectHealth(client, input) {
|
|
@@ -356,21 +554,22 @@ Use this to understand the current state of your test suite.`
|
|
|
356
554
|
};
|
|
357
555
|
|
|
358
556
|
// src/tools/get-report.ts
|
|
359
|
-
import { z as
|
|
557
|
+
import { z as z4 } from "zod";
|
|
360
558
|
var getReportInputSchema = {
|
|
361
|
-
testRunId:
|
|
559
|
+
testRunId: z4.string().describe("The test run ID to get report files for. Use list_test_runs to find test run IDs.")
|
|
362
560
|
};
|
|
363
561
|
var getReportOutputSchema = {
|
|
364
|
-
testRunId:
|
|
365
|
-
projectId:
|
|
366
|
-
projectName:
|
|
367
|
-
resultSchema:
|
|
368
|
-
files:
|
|
369
|
-
filename:
|
|
370
|
-
size:
|
|
371
|
-
contentType:
|
|
372
|
-
downloadUrl:
|
|
373
|
-
}))
|
|
562
|
+
testRunId: z4.string(),
|
|
563
|
+
projectId: z4.string(),
|
|
564
|
+
projectName: z4.string(),
|
|
565
|
+
resultSchema: z4.string().optional(),
|
|
566
|
+
files: z4.array(z4.object({
|
|
567
|
+
filename: z4.string(),
|
|
568
|
+
size: z4.number(),
|
|
569
|
+
contentType: z4.string(),
|
|
570
|
+
downloadUrl: z4.string()
|
|
571
|
+
})),
|
|
572
|
+
urlExpiresInSeconds: z4.number().optional()
|
|
374
573
|
};
|
|
375
574
|
async function executeGetReport(client, input) {
|
|
376
575
|
const response = await client.getReport(input.testRunId);
|
|
@@ -384,57 +583,73 @@ async function executeGetReport(client, input) {
|
|
|
384
583
|
size: file.size,
|
|
385
584
|
contentType: file.contentType,
|
|
386
585
|
downloadUrl: file.downloadUrl
|
|
387
|
-
}))
|
|
586
|
+
})),
|
|
587
|
+
urlExpiresInSeconds: response.urlExpiresInSeconds
|
|
388
588
|
};
|
|
389
589
|
}
|
|
390
590
|
var getReportMetadata = {
|
|
391
591
|
name: "get_report",
|
|
392
592
|
title: "Get Report Files",
|
|
393
|
-
description: `Get
|
|
593
|
+
description: `Get URLs for report files uploaded with a test run.
|
|
394
594
|
|
|
395
|
-
|
|
396
|
-
|
|
595
|
+
IMPORTANT: This tool returns download URLs, not file content. You must fetch the URLs separately.
|
|
596
|
+
|
|
597
|
+
Returns for each file:
|
|
598
|
+
- filename: The file name (e.g., "report.html", "results.json", "junit.xml")
|
|
397
599
|
- size: File size in bytes
|
|
398
|
-
- contentType: MIME type (e.g., "text/html", "application/json")
|
|
399
|
-
- downloadUrl: URL to download the file (
|
|
600
|
+
- contentType: MIME type (e.g., "text/html", "application/json", "application/xml")
|
|
601
|
+
- downloadUrl: Presigned URL to download the file (valid for ~5 minutes)
|
|
400
602
|
|
|
401
|
-
|
|
402
|
-
- HTML reports (Playwright, pytest-html, Vitest UI)
|
|
403
|
-
- JSON results (Jest, Vitest)
|
|
404
|
-
- JUnit XML files
|
|
405
|
-
- Coverage reports
|
|
603
|
+
How to use the returned URLs:
|
|
406
604
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
- "What files were uploaded with this test run?"
|
|
605
|
+
1. **JSON files** (results.json, coverage.json):
|
|
606
|
+
Use WebFetch with the downloadUrl to retrieve and parse the JSON content.
|
|
607
|
+
Example: WebFetch(url=downloadUrl, prompt="Extract test results from this JSON")
|
|
411
608
|
|
|
412
|
-
|
|
609
|
+
2. **XML files** (junit.xml, xunit.xml):
|
|
610
|
+
Use WebFetch with the downloadUrl to retrieve and parse the XML content.
|
|
611
|
+
Example: WebFetch(url=downloadUrl, prompt="Parse the test results from this JUnit XML")
|
|
612
|
+
|
|
613
|
+
3. **HTML reports** (Playwright, pytest-html, Vitest):
|
|
614
|
+
These are typically bundled React/JavaScript applications that require a browser.
|
|
615
|
+
They cannot be meaningfully parsed by WebFetch.
|
|
616
|
+
For programmatic analysis, use get_test_run_details instead.
|
|
617
|
+
|
|
618
|
+
Recommendations:
|
|
619
|
+
- For analyzing test results programmatically: Use get_test_run_details (returns parsed test data)
|
|
620
|
+
- For JSON/XML files: Use this tool + WebFetch on the downloadUrl
|
|
621
|
+
- For HTML reports: Direct users to view in browser, or use get_test_run_details
|
|
622
|
+
|
|
623
|
+
Use cases:
|
|
624
|
+
- "What files are in this test run?" (list available reports)
|
|
625
|
+
- "Get the coverage data from this run" (then WebFetch the JSON URL)
|
|
626
|
+
- "Parse the JUnit XML results" (then WebFetch the XML URL)`
|
|
413
627
|
};
|
|
414
628
|
|
|
415
629
|
// src/tools/get-slowest-tests.ts
|
|
416
|
-
import { z as
|
|
630
|
+
import { z as z5 } from "zod";
|
|
417
631
|
var getSlowestTestsInputSchema = {
|
|
418
|
-
projectId:
|
|
419
|
-
days:
|
|
420
|
-
limit:
|
|
421
|
-
framework:
|
|
632
|
+
projectId: z5.string().describe("Project ID to get slowest tests for. Required. Use list_projects to find project IDs."),
|
|
633
|
+
days: z5.number().int().min(1).max(365).optional().describe("Analysis period in days (default: 30)"),
|
|
634
|
+
limit: z5.number().int().min(1).max(100).optional().describe("Maximum number of tests to return (default: 20)"),
|
|
635
|
+
framework: z5.string().optional().describe('Filter by test framework (e.g., "playwright", "vitest", "jest")'),
|
|
636
|
+
branch: z5.string().optional().describe('Filter by git branch name (e.g., "main", "develop")')
|
|
422
637
|
};
|
|
423
638
|
var getSlowestTestsOutputSchema = {
|
|
424
|
-
slowestTests:
|
|
425
|
-
name:
|
|
426
|
-
fullName:
|
|
427
|
-
filePath:
|
|
428
|
-
framework:
|
|
429
|
-
avgDurationMs:
|
|
430
|
-
p95DurationMs:
|
|
431
|
-
runCount:
|
|
639
|
+
slowestTests: z5.array(z5.object({
|
|
640
|
+
name: z5.string(),
|
|
641
|
+
fullName: z5.string(),
|
|
642
|
+
filePath: z5.string().optional(),
|
|
643
|
+
framework: z5.string().optional(),
|
|
644
|
+
avgDurationMs: z5.number(),
|
|
645
|
+
p95DurationMs: z5.number(),
|
|
646
|
+
runCount: z5.number()
|
|
432
647
|
})),
|
|
433
|
-
summary:
|
|
434
|
-
projectId:
|
|
435
|
-
projectName:
|
|
436
|
-
period:
|
|
437
|
-
totalReturned:
|
|
648
|
+
summary: z5.object({
|
|
649
|
+
projectId: z5.string(),
|
|
650
|
+
projectName: z5.string(),
|
|
651
|
+
period: z5.number(),
|
|
652
|
+
totalReturned: z5.number()
|
|
438
653
|
})
|
|
439
654
|
};
|
|
440
655
|
async function executeGetSlowestTests(client, input) {
|
|
@@ -442,7 +657,8 @@ async function executeGetSlowestTests(client, input) {
|
|
|
442
657
|
projectId: input.projectId,
|
|
443
658
|
days: input.days,
|
|
444
659
|
limit: input.limit,
|
|
445
|
-
framework: input.framework
|
|
660
|
+
framework: input.framework,
|
|
661
|
+
branch: input.branch
|
|
446
662
|
});
|
|
447
663
|
return {
|
|
448
664
|
slowestTests: response.slowestTests.map((test) => ({
|
|
@@ -470,6 +686,7 @@ Parameters:
|
|
|
470
686
|
- days (optional): Analysis period in days (default: 30, max: 365)
|
|
471
687
|
- limit (optional): Max tests to return (default: 20, max: 100)
|
|
472
688
|
- framework (optional): Filter by framework (e.g., "playwright", "vitest")
|
|
689
|
+
- branch (optional): Filter by git branch (e.g., "main", "develop")
|
|
473
690
|
|
|
474
691
|
Returns:
|
|
475
692
|
- List of slowest tests with:
|
|
@@ -485,32 +702,33 @@ Returns:
|
|
|
485
702
|
Use cases:
|
|
486
703
|
- "Which tests are slowing down my CI pipeline?"
|
|
487
704
|
- "Find the slowest Playwright tests to optimize"
|
|
488
|
-
- "Show me e2e tests taking over 30 seconds"
|
|
705
|
+
- "Show me e2e tests taking over 30 seconds"
|
|
706
|
+
- "What are the slowest tests on the main branch?"`
|
|
489
707
|
};
|
|
490
708
|
|
|
491
709
|
// src/tools/get-test-history.ts
|
|
492
|
-
import { z as
|
|
710
|
+
import { z as z6 } from "zod";
|
|
493
711
|
var getTestHistoryInputSchema = {
|
|
494
|
-
projectId:
|
|
495
|
-
testName:
|
|
496
|
-
filePath:
|
|
497
|
-
limit:
|
|
712
|
+
projectId: z6.string().optional().describe("Project ID to get test history for. Required when using a user API Key (gaf_). Use list_projects to find project IDs."),
|
|
713
|
+
testName: z6.string().optional().describe("Exact test name to search for"),
|
|
714
|
+
filePath: z6.string().optional().describe("File path containing the test"),
|
|
715
|
+
limit: z6.number().int().min(1).max(100).optional().describe("Maximum number of results (default: 20)")
|
|
498
716
|
};
|
|
499
717
|
var getTestHistoryOutputSchema = {
|
|
500
|
-
history:
|
|
501
|
-
testRunId:
|
|
502
|
-
createdAt:
|
|
503
|
-
branch:
|
|
504
|
-
commitSha:
|
|
505
|
-
status:
|
|
506
|
-
durationMs:
|
|
507
|
-
message:
|
|
718
|
+
history: z6.array(z6.object({
|
|
719
|
+
testRunId: z6.string(),
|
|
720
|
+
createdAt: z6.string(),
|
|
721
|
+
branch: z6.string().optional(),
|
|
722
|
+
commitSha: z6.string().optional(),
|
|
723
|
+
status: z6.enum(["passed", "failed", "skipped", "pending"]),
|
|
724
|
+
durationMs: z6.number(),
|
|
725
|
+
message: z6.string().optional()
|
|
508
726
|
})),
|
|
509
|
-
summary:
|
|
510
|
-
totalRuns:
|
|
511
|
-
passedRuns:
|
|
512
|
-
failedRuns:
|
|
513
|
-
passRate:
|
|
727
|
+
summary: z6.object({
|
|
728
|
+
totalRuns: z6.number(),
|
|
729
|
+
passedRuns: z6.number(),
|
|
730
|
+
failedRuns: z6.number(),
|
|
731
|
+
passRate: z6.number().nullable()
|
|
514
732
|
})
|
|
515
733
|
};
|
|
516
734
|
async function executeGetTestHistory(client, input) {
|
|
@@ -564,24 +782,108 @@ Returns:
|
|
|
564
782
|
Use this to investigate flaky tests or understand test stability.`
|
|
565
783
|
};
|
|
566
784
|
|
|
785
|
+
// src/tools/get-test-run-details.ts
|
|
786
|
+
import { z as z7 } from "zod";
|
|
787
|
+
var getTestRunDetailsInputSchema = {
|
|
788
|
+
testRunId: z7.string().describe("The test run ID to get details for. Use list_test_runs to find test run IDs."),
|
|
789
|
+
projectId: z7.string().describe("Project ID the test run belongs to. Required when using a user API Key (gaf_). Use list_projects to find project IDs."),
|
|
790
|
+
status: z7.enum(["passed", "failed", "skipped"]).optional().describe("Filter tests by status. Returns only tests matching this status."),
|
|
791
|
+
limit: z7.number().int().min(1).max(500).optional().describe("Maximum number of tests to return (default: 100, max: 500)"),
|
|
792
|
+
offset: z7.number().int().min(0).optional().describe("Number of tests to skip for pagination (default: 0)")
|
|
793
|
+
};
|
|
794
|
+
var getTestRunDetailsOutputSchema = {
|
|
795
|
+
testRunId: z7.string(),
|
|
796
|
+
summary: z7.object({
|
|
797
|
+
passed: z7.number(),
|
|
798
|
+
failed: z7.number(),
|
|
799
|
+
skipped: z7.number(),
|
|
800
|
+
total: z7.number()
|
|
801
|
+
}),
|
|
802
|
+
tests: z7.array(z7.object({
|
|
803
|
+
name: z7.string(),
|
|
804
|
+
fullName: z7.string(),
|
|
805
|
+
status: z7.enum(["passed", "failed", "skipped"]),
|
|
806
|
+
durationMs: z7.number().nullable(),
|
|
807
|
+
filePath: z7.string().nullable(),
|
|
808
|
+
error: z7.string().nullable()
|
|
809
|
+
})),
|
|
810
|
+
pagination: z7.object({
|
|
811
|
+
total: z7.number(),
|
|
812
|
+
limit: z7.number(),
|
|
813
|
+
offset: z7.number(),
|
|
814
|
+
hasMore: z7.boolean()
|
|
815
|
+
})
|
|
816
|
+
};
|
|
817
|
+
async function executeGetTestRunDetails(client, input) {
|
|
818
|
+
const response = await client.getTestRunDetails({
|
|
819
|
+
projectId: input.projectId,
|
|
820
|
+
testRunId: input.testRunId,
|
|
821
|
+
status: input.status,
|
|
822
|
+
limit: input.limit,
|
|
823
|
+
offset: input.offset
|
|
824
|
+
});
|
|
825
|
+
return {
|
|
826
|
+
testRunId: response.testRunId,
|
|
827
|
+
summary: response.summary,
|
|
828
|
+
tests: response.tests,
|
|
829
|
+
pagination: response.pagination
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
var getTestRunDetailsMetadata = {
|
|
833
|
+
name: "get_test_run_details",
|
|
834
|
+
title: "Get Test Run Details",
|
|
835
|
+
description: `Get parsed test results for a specific test run.
|
|
836
|
+
|
|
837
|
+
When using a user API Key (gaf_), you must provide a projectId.
|
|
838
|
+
Use list_projects to find available project IDs, and list_test_runs to find test run IDs.
|
|
839
|
+
|
|
840
|
+
Parameters:
|
|
841
|
+
- testRunId (required): The test run ID to get details for
|
|
842
|
+
- projectId (required): Project ID the test run belongs to
|
|
843
|
+
- status (optional): Filter by test status: "passed", "failed", or "skipped"
|
|
844
|
+
- limit (optional): Max tests to return (default: 100, max: 500)
|
|
845
|
+
- offset (optional): Pagination offset (default: 0)
|
|
846
|
+
|
|
847
|
+
Returns:
|
|
848
|
+
- testRunId: The test run ID
|
|
849
|
+
- summary: Overall counts (passed, failed, skipped, total)
|
|
850
|
+
- tests: Array of individual test results with:
|
|
851
|
+
- name: Short test name
|
|
852
|
+
- fullName: Full test name including describe blocks
|
|
853
|
+
- status: Test status (passed, failed, skipped)
|
|
854
|
+
- durationMs: Test duration in milliseconds (null if not recorded)
|
|
855
|
+
- filePath: Test file path (null if not recorded)
|
|
856
|
+
- error: Error message for failed tests (null otherwise)
|
|
857
|
+
- pagination: Pagination info (total, limit, offset, hasMore)
|
|
858
|
+
|
|
859
|
+
Use cases:
|
|
860
|
+
- "Show me all failed tests from this test run"
|
|
861
|
+
- "Get the test results from commit abc123"
|
|
862
|
+
- "List tests that took the longest in this run"
|
|
863
|
+
- "Find tests with errors in the auth module"
|
|
864
|
+
|
|
865
|
+
Note: For aggregate analytics like flaky test detection or duration trends,
|
|
866
|
+
use get_test_history, get_flaky_tests, or get_slowest_tests instead.`
|
|
867
|
+
};
|
|
868
|
+
|
|
567
869
|
// src/tools/list-projects.ts
|
|
568
|
-
import { z as
|
|
870
|
+
import { z as z8 } from "zod";
|
|
569
871
|
var listProjectsInputSchema = {
|
|
570
|
-
organizationId:
|
|
571
|
-
limit:
|
|
872
|
+
organizationId: z8.string().optional().describe("Filter by organization ID (optional)"),
|
|
873
|
+
limit: z8.number().int().min(1).max(100).optional().describe("Maximum number of projects to return (default: 50)")
|
|
572
874
|
};
|
|
573
875
|
var listProjectsOutputSchema = {
|
|
574
|
-
projects:
|
|
575
|
-
id:
|
|
576
|
-
name:
|
|
577
|
-
description:
|
|
578
|
-
organization:
|
|
579
|
-
id:
|
|
580
|
-
name:
|
|
581
|
-
slug:
|
|
876
|
+
projects: z8.array(z8.object({
|
|
877
|
+
id: z8.string(),
|
|
878
|
+
name: z8.string(),
|
|
879
|
+
description: z8.string().nullable().optional(),
|
|
880
|
+
organization: z8.object({
|
|
881
|
+
id: z8.string(),
|
|
882
|
+
name: z8.string(),
|
|
883
|
+
slug: z8.string()
|
|
582
884
|
})
|
|
583
885
|
})),
|
|
584
|
-
total:
|
|
886
|
+
total: z8.number()
|
|
585
887
|
};
|
|
586
888
|
async function executeListProjects(client, input) {
|
|
587
889
|
const response = await client.listProjects({
|
|
@@ -610,28 +912,28 @@ Requires a user API Key (gaf_). Get one from Account Settings in the Gaffer dash
|
|
|
610
912
|
};
|
|
611
913
|
|
|
612
914
|
// src/tools/list-test-runs.ts
|
|
613
|
-
import { z as
|
|
915
|
+
import { z as z9 } from "zod";
|
|
614
916
|
var listTestRunsInputSchema = {
|
|
615
|
-
projectId:
|
|
616
|
-
commitSha:
|
|
617
|
-
branch:
|
|
618
|
-
status:
|
|
619
|
-
limit:
|
|
917
|
+
projectId: z9.string().optional().describe("Project ID to list test runs for. Required when using a user API Key (gaf_). Use list_projects to find project IDs."),
|
|
918
|
+
commitSha: z9.string().optional().describe("Filter by commit SHA (exact or prefix match)"),
|
|
919
|
+
branch: z9.string().optional().describe("Filter by branch name"),
|
|
920
|
+
status: z9.enum(["passed", "failed"]).optional().describe('Filter by status: "passed" (no failures) or "failed" (has failures)'),
|
|
921
|
+
limit: z9.number().int().min(1).max(100).optional().describe("Maximum number of test runs to return (default: 20)")
|
|
620
922
|
};
|
|
621
923
|
var listTestRunsOutputSchema = {
|
|
622
|
-
testRuns:
|
|
623
|
-
id:
|
|
624
|
-
commitSha:
|
|
625
|
-
branch:
|
|
626
|
-
passedCount:
|
|
627
|
-
failedCount:
|
|
628
|
-
skippedCount:
|
|
629
|
-
totalCount:
|
|
630
|
-
createdAt:
|
|
924
|
+
testRuns: z9.array(z9.object({
|
|
925
|
+
id: z9.string(),
|
|
926
|
+
commitSha: z9.string().optional(),
|
|
927
|
+
branch: z9.string().optional(),
|
|
928
|
+
passedCount: z9.number(),
|
|
929
|
+
failedCount: z9.number(),
|
|
930
|
+
skippedCount: z9.number(),
|
|
931
|
+
totalCount: z9.number(),
|
|
932
|
+
createdAt: z9.string()
|
|
631
933
|
})),
|
|
632
|
-
pagination:
|
|
633
|
-
total:
|
|
634
|
-
hasMore:
|
|
934
|
+
pagination: z9.object({
|
|
935
|
+
total: z9.number(),
|
|
936
|
+
hasMore: z9.boolean()
|
|
635
937
|
})
|
|
636
938
|
};
|
|
637
939
|
async function executeListTestRuns(client, input) {
|
|
@@ -688,6 +990,23 @@ Use cases:
|
|
|
688
990
|
};
|
|
689
991
|
|
|
690
992
|
// src/index.ts
|
|
993
|
+
function logError(toolName, error) {
|
|
994
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
995
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
996
|
+
const stack = error instanceof Error ? error.stack : void 0;
|
|
997
|
+
console.error(`[${timestamp}] [gaffer-mcp] ${toolName} failed: ${message}`);
|
|
998
|
+
if (stack) {
|
|
999
|
+
console.error(stack);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function handleToolError(toolName, error) {
|
|
1003
|
+
logError(toolName, error);
|
|
1004
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1005
|
+
return {
|
|
1006
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1007
|
+
isError: true
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
691
1010
|
async function main() {
|
|
692
1011
|
const apiKey = process.env.GAFFER_API_KEY;
|
|
693
1012
|
if (!apiKey) {
|
|
@@ -730,11 +1049,7 @@ async function main() {
|
|
|
730
1049
|
structuredContent: output
|
|
731
1050
|
};
|
|
732
1051
|
} catch (error) {
|
|
733
|
-
|
|
734
|
-
return {
|
|
735
|
-
content: [{ type: "text", text: `Error: ${message}` }],
|
|
736
|
-
isError: true
|
|
737
|
-
};
|
|
1052
|
+
return handleToolError(getProjectHealthMetadata.name, error);
|
|
738
1053
|
}
|
|
739
1054
|
}
|
|
740
1055
|
);
|
|
@@ -754,11 +1069,7 @@ async function main() {
|
|
|
754
1069
|
structuredContent: output
|
|
755
1070
|
};
|
|
756
1071
|
} catch (error) {
|
|
757
|
-
|
|
758
|
-
return {
|
|
759
|
-
content: [{ type: "text", text: `Error: ${message}` }],
|
|
760
|
-
isError: true
|
|
761
|
-
};
|
|
1072
|
+
return handleToolError(getTestHistoryMetadata.name, error);
|
|
762
1073
|
}
|
|
763
1074
|
}
|
|
764
1075
|
);
|
|
@@ -778,11 +1089,7 @@ async function main() {
|
|
|
778
1089
|
structuredContent: output
|
|
779
1090
|
};
|
|
780
1091
|
} catch (error) {
|
|
781
|
-
|
|
782
|
-
return {
|
|
783
|
-
content: [{ type: "text", text: `Error: ${message}` }],
|
|
784
|
-
isError: true
|
|
785
|
-
};
|
|
1092
|
+
return handleToolError(getFlakyTestsMetadata.name, error);
|
|
786
1093
|
}
|
|
787
1094
|
}
|
|
788
1095
|
);
|
|
@@ -802,11 +1109,7 @@ async function main() {
|
|
|
802
1109
|
structuredContent: output
|
|
803
1110
|
};
|
|
804
1111
|
} catch (error) {
|
|
805
|
-
|
|
806
|
-
return {
|
|
807
|
-
content: [{ type: "text", text: `Error: ${message}` }],
|
|
808
|
-
isError: true
|
|
809
|
-
};
|
|
1112
|
+
return handleToolError(listTestRunsMetadata.name, error);
|
|
810
1113
|
}
|
|
811
1114
|
}
|
|
812
1115
|
);
|
|
@@ -826,11 +1129,7 @@ async function main() {
|
|
|
826
1129
|
structuredContent: output
|
|
827
1130
|
};
|
|
828
1131
|
} catch (error) {
|
|
829
|
-
|
|
830
|
-
return {
|
|
831
|
-
content: [{ type: "text", text: `Error: ${message}` }],
|
|
832
|
-
isError: true
|
|
833
|
-
};
|
|
1132
|
+
return handleToolError(listProjectsMetadata.name, error);
|
|
834
1133
|
}
|
|
835
1134
|
}
|
|
836
1135
|
);
|
|
@@ -850,11 +1149,7 @@ async function main() {
|
|
|
850
1149
|
structuredContent: output
|
|
851
1150
|
};
|
|
852
1151
|
} catch (error) {
|
|
853
|
-
|
|
854
|
-
return {
|
|
855
|
-
content: [{ type: "text", text: `Error: ${message}` }],
|
|
856
|
-
isError: true
|
|
857
|
-
};
|
|
1152
|
+
return handleToolError(getReportMetadata.name, error);
|
|
858
1153
|
}
|
|
859
1154
|
}
|
|
860
1155
|
);
|
|
@@ -874,11 +1169,47 @@ async function main() {
|
|
|
874
1169
|
structuredContent: output
|
|
875
1170
|
};
|
|
876
1171
|
} catch (error) {
|
|
877
|
-
|
|
1172
|
+
return handleToolError(getSlowestTestsMetadata.name, error);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
);
|
|
1176
|
+
server.registerTool(
|
|
1177
|
+
getTestRunDetailsMetadata.name,
|
|
1178
|
+
{
|
|
1179
|
+
title: getTestRunDetailsMetadata.title,
|
|
1180
|
+
description: getTestRunDetailsMetadata.description,
|
|
1181
|
+
inputSchema: getTestRunDetailsInputSchema,
|
|
1182
|
+
outputSchema: getTestRunDetailsOutputSchema
|
|
1183
|
+
},
|
|
1184
|
+
async (input) => {
|
|
1185
|
+
try {
|
|
1186
|
+
const output = await executeGetTestRunDetails(client, input);
|
|
1187
|
+
return {
|
|
1188
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
1189
|
+
structuredContent: output
|
|
1190
|
+
};
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
return handleToolError(getTestRunDetailsMetadata.name, error);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
);
|
|
1196
|
+
server.registerTool(
|
|
1197
|
+
compareTestMetricsMetadata.name,
|
|
1198
|
+
{
|
|
1199
|
+
title: compareTestMetricsMetadata.title,
|
|
1200
|
+
description: compareTestMetricsMetadata.description,
|
|
1201
|
+
inputSchema: compareTestMetricsInputSchema,
|
|
1202
|
+
outputSchema: compareTestMetricsOutputSchema
|
|
1203
|
+
},
|
|
1204
|
+
async (input) => {
|
|
1205
|
+
try {
|
|
1206
|
+
const output = await executeCompareTestMetrics(client, input);
|
|
878
1207
|
return {
|
|
879
|
-
content: [{ type: "text", text:
|
|
880
|
-
|
|
1208
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
1209
|
+
structuredContent: output
|
|
881
1210
|
};
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
return handleToolError(compareTestMetricsMetadata.name, error);
|
|
882
1213
|
}
|
|
883
1214
|
}
|
|
884
1215
|
);
|
package/package.json
CHANGED