@gaffer-sh/mcp 0.2.1 → 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.
Files changed (2) hide show
  1. package/dist/index.js +511 -183
  2. 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
- const controller = new AbortController();
57
- const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
58
- try {
59
- const response = await fetch(url.toString(), {
60
- method: "GET",
61
- headers: {
62
- "X-API-Key": this.apiKey,
63
- "Accept": "application/json",
64
- "User-Agent": "gaffer-mcp/0.2.0"
65
- },
66
- signal: controller.signal
67
- });
68
- if (!response.ok) {
69
- const errorData = await response.json().catch(() => ({}));
70
- const errorMessage = errorData.error?.message || `API request failed: ${response.status}`;
71
- throw new Error(errorMessage);
72
- }
73
- return response.json();
74
- } catch (error) {
75
- if (error instanceof Error && error.name === "AbortError") {
76
- throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`);
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/get-flaky-tests.ts
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: z.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."),
252
- threshold: z.number().min(0).max(1).optional().describe("Minimum flip rate to be considered flaky (0-1, default: 0.1 = 10%)"),
253
- limit: z.number().int().min(1).max(100).optional().describe("Maximum number of flaky tests to return (default: 50)"),
254
- days: z.number().int().min(1).max(365).optional().describe("Analysis period in days (default: 30)")
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: z.array(z.object({
258
- name: z.string(),
259
- flipRate: z.number(),
260
- flipCount: z.number(),
261
- totalRuns: z.number(),
262
- lastSeen: z.string()
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: z.object({
265
- threshold: z.number(),
266
- totalFlaky: z.number(),
267
- period: z.number()
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 z2 } from "zod";
505
+ import { z as z3 } from "zod";
308
506
  var getProjectHealthInputSchema = {
309
- projectId: z2.string().optional().describe("Project ID to get health for. Required when using a user API Key (gaf_). Use list_projects to find project IDs."),
310
- days: z2.number().int().min(1).max(365).optional().describe("Number of days to analyze (default: 30)")
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: z2.string(),
314
- healthScore: z2.number(),
315
- passRate: z2.number().nullable(),
316
- testRunCount: z2.number(),
317
- flakyTestCount: z2.number(),
318
- trend: z2.enum(["up", "down", "stable"]),
319
- period: z2.object({
320
- days: z2.number(),
321
- start: z2.string(),
322
- end: z2.string()
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,22 +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 z3 } from "zod";
557
+ import { z as z4 } from "zod";
360
558
  var getReportInputSchema = {
361
- testRunId: z3.string().describe("The test run ID to get report files for. Use list_test_runs to find test run IDs.")
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: z3.string(),
365
- projectId: z3.string(),
366
- projectName: z3.string(),
367
- resultSchema: z3.string().optional(),
368
- files: z3.array(z3.object({
369
- filename: z3.string(),
370
- size: z3.number(),
371
- contentType: z3.string(),
372
- downloadUrl: z3.string()
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()
373
571
  })),
374
- urlExpiresInSeconds: z3.number().optional()
572
+ urlExpiresInSeconds: z4.number().optional()
375
573
  };
376
574
  async function executeGetReport(client, input) {
377
575
  const response = await client.getReport(input.testRunId);
@@ -392,52 +590,66 @@ async function executeGetReport(client, input) {
392
590
  var getReportMetadata = {
393
591
  name: "get_report",
394
592
  title: "Get Report Files",
395
- description: `Get the report files for a specific test run.
593
+ description: `Get URLs for report files uploaded with a test run.
594
+
595
+ IMPORTANT: This tool returns download URLs, not file content. You must fetch the URLs separately.
396
596
 
397
- Returns a list of files uploaded with the test run, including:
398
- - filename: The file name (e.g., "report.html", "coverage/index.html")
597
+ Returns for each file:
598
+ - filename: The file name (e.g., "report.html", "results.json", "junit.xml")
399
599
  - size: File size in bytes
400
- - contentType: MIME type (e.g., "text/html", "application/json")
401
- - downloadUrl: Presigned URL to download the file directly (no authentication required)
402
- - urlExpiresInSeconds: How long the download URLs are valid (typically 5 minutes)
600
+ - contentType: MIME type (e.g., "text/html", "application/json", "application/xml")
601
+ - downloadUrl: Presigned URL to download the file (valid for ~5 minutes)
403
602
 
404
- Common report types:
405
- - HTML reports (Playwright, pytest-html, Vitest UI)
406
- - JSON results (Jest, Vitest)
407
- - JUnit XML files
408
- - Coverage reports
603
+ How to use the returned URLs:
409
604
 
410
- Use cases:
411
- - "Get the Playwright report for this test run"
412
- - "Download the coverage report"
413
- - "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")
608
+
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")
414
612
 
415
- Note: Download URLs are presigned and expire after a few minutes. Request fresh URLs if needed.`
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)`
416
627
  };
417
628
 
418
629
  // src/tools/get-slowest-tests.ts
419
- import { z as z4 } from "zod";
630
+ import { z as z5 } from "zod";
420
631
  var getSlowestTestsInputSchema = {
421
- projectId: z4.string().describe("Project ID to get slowest tests for. Required. Use list_projects to find project IDs."),
422
- days: z4.number().int().min(1).max(365).optional().describe("Analysis period in days (default: 30)"),
423
- limit: z4.number().int().min(1).max(100).optional().describe("Maximum number of tests to return (default: 20)"),
424
- framework: z4.string().optional().describe('Filter by test framework (e.g., "playwright", "vitest", "jest")')
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")')
425
637
  };
426
638
  var getSlowestTestsOutputSchema = {
427
- slowestTests: z4.array(z4.object({
428
- name: z4.string(),
429
- fullName: z4.string(),
430
- filePath: z4.string().optional(),
431
- framework: z4.string().optional(),
432
- avgDurationMs: z4.number(),
433
- p95DurationMs: z4.number(),
434
- runCount: z4.number()
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()
435
647
  })),
436
- summary: z4.object({
437
- projectId: z4.string(),
438
- projectName: z4.string(),
439
- period: z4.number(),
440
- totalReturned: z4.number()
648
+ summary: z5.object({
649
+ projectId: z5.string(),
650
+ projectName: z5.string(),
651
+ period: z5.number(),
652
+ totalReturned: z5.number()
441
653
  })
442
654
  };
443
655
  async function executeGetSlowestTests(client, input) {
@@ -445,7 +657,8 @@ async function executeGetSlowestTests(client, input) {
445
657
  projectId: input.projectId,
446
658
  days: input.days,
447
659
  limit: input.limit,
448
- framework: input.framework
660
+ framework: input.framework,
661
+ branch: input.branch
449
662
  });
450
663
  return {
451
664
  slowestTests: response.slowestTests.map((test) => ({
@@ -473,6 +686,7 @@ Parameters:
473
686
  - days (optional): Analysis period in days (default: 30, max: 365)
474
687
  - limit (optional): Max tests to return (default: 20, max: 100)
475
688
  - framework (optional): Filter by framework (e.g., "playwright", "vitest")
689
+ - branch (optional): Filter by git branch (e.g., "main", "develop")
476
690
 
477
691
  Returns:
478
692
  - List of slowest tests with:
@@ -488,32 +702,33 @@ Returns:
488
702
  Use cases:
489
703
  - "Which tests are slowing down my CI pipeline?"
490
704
  - "Find the slowest Playwright tests to optimize"
491
- - "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?"`
492
707
  };
493
708
 
494
709
  // src/tools/get-test-history.ts
495
- import { z as z5 } from "zod";
710
+ import { z as z6 } from "zod";
496
711
  var getTestHistoryInputSchema = {
497
- projectId: z5.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."),
498
- testName: z5.string().optional().describe("Exact test name to search for"),
499
- filePath: z5.string().optional().describe("File path containing the test"),
500
- limit: z5.number().int().min(1).max(100).optional().describe("Maximum number of results (default: 20)")
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)")
501
716
  };
502
717
  var getTestHistoryOutputSchema = {
503
- history: z5.array(z5.object({
504
- testRunId: z5.string(),
505
- createdAt: z5.string(),
506
- branch: z5.string().optional(),
507
- commitSha: z5.string().optional(),
508
- status: z5.enum(["passed", "failed", "skipped", "pending"]),
509
- durationMs: z5.number(),
510
- message: z5.string().optional()
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()
511
726
  })),
512
- summary: z5.object({
513
- totalRuns: z5.number(),
514
- passedRuns: z5.number(),
515
- failedRuns: z5.number(),
516
- passRate: z5.number().nullable()
727
+ summary: z6.object({
728
+ totalRuns: z6.number(),
729
+ passedRuns: z6.number(),
730
+ failedRuns: z6.number(),
731
+ passRate: z6.number().nullable()
517
732
  })
518
733
  };
519
734
  async function executeGetTestHistory(client, input) {
@@ -567,24 +782,108 @@ Returns:
567
782
  Use this to investigate flaky tests or understand test stability.`
568
783
  };
569
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
+
570
869
  // src/tools/list-projects.ts
571
- import { z as z6 } from "zod";
870
+ import { z as z8 } from "zod";
572
871
  var listProjectsInputSchema = {
573
- organizationId: z6.string().optional().describe("Filter by organization ID (optional)"),
574
- limit: z6.number().int().min(1).max(100).optional().describe("Maximum number of projects to return (default: 50)")
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)")
575
874
  };
576
875
  var listProjectsOutputSchema = {
577
- projects: z6.array(z6.object({
578
- id: z6.string(),
579
- name: z6.string(),
580
- description: z6.string().nullable().optional(),
581
- organization: z6.object({
582
- id: z6.string(),
583
- name: z6.string(),
584
- slug: z6.string()
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()
585
884
  })
586
885
  })),
587
- total: z6.number()
886
+ total: z8.number()
588
887
  };
589
888
  async function executeListProjects(client, input) {
590
889
  const response = await client.listProjects({
@@ -613,28 +912,28 @@ Requires a user API Key (gaf_). Get one from Account Settings in the Gaffer dash
613
912
  };
614
913
 
615
914
  // src/tools/list-test-runs.ts
616
- import { z as z7 } from "zod";
915
+ import { z as z9 } from "zod";
617
916
  var listTestRunsInputSchema = {
618
- projectId: z7.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."),
619
- commitSha: z7.string().optional().describe("Filter by commit SHA (exact or prefix match)"),
620
- branch: z7.string().optional().describe("Filter by branch name"),
621
- status: z7.enum(["passed", "failed"]).optional().describe('Filter by status: "passed" (no failures) or "failed" (has failures)'),
622
- limit: z7.number().int().min(1).max(100).optional().describe("Maximum number of test runs to return (default: 20)")
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)")
623
922
  };
624
923
  var listTestRunsOutputSchema = {
625
- testRuns: z7.array(z7.object({
626
- id: z7.string(),
627
- commitSha: z7.string().optional(),
628
- branch: z7.string().optional(),
629
- passedCount: z7.number(),
630
- failedCount: z7.number(),
631
- skippedCount: z7.number(),
632
- totalCount: z7.number(),
633
- createdAt: z7.string()
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()
634
933
  })),
635
- pagination: z7.object({
636
- total: z7.number(),
637
- hasMore: z7.boolean()
934
+ pagination: z9.object({
935
+ total: z9.number(),
936
+ hasMore: z9.boolean()
638
937
  })
639
938
  };
640
939
  async function executeListTestRuns(client, input) {
@@ -691,6 +990,23 @@ Use cases:
691
990
  };
692
991
 
693
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
+ }
694
1010
  async function main() {
695
1011
  const apiKey = process.env.GAFFER_API_KEY;
696
1012
  if (!apiKey) {
@@ -733,11 +1049,7 @@ async function main() {
733
1049
  structuredContent: output
734
1050
  };
735
1051
  } catch (error) {
736
- const message = error instanceof Error ? error.message : "Unknown error";
737
- return {
738
- content: [{ type: "text", text: `Error: ${message}` }],
739
- isError: true
740
- };
1052
+ return handleToolError(getProjectHealthMetadata.name, error);
741
1053
  }
742
1054
  }
743
1055
  );
@@ -757,11 +1069,7 @@ async function main() {
757
1069
  structuredContent: output
758
1070
  };
759
1071
  } catch (error) {
760
- const message = error instanceof Error ? error.message : "Unknown error";
761
- return {
762
- content: [{ type: "text", text: `Error: ${message}` }],
763
- isError: true
764
- };
1072
+ return handleToolError(getTestHistoryMetadata.name, error);
765
1073
  }
766
1074
  }
767
1075
  );
@@ -781,11 +1089,7 @@ async function main() {
781
1089
  structuredContent: output
782
1090
  };
783
1091
  } catch (error) {
784
- const message = error instanceof Error ? error.message : "Unknown error";
785
- return {
786
- content: [{ type: "text", text: `Error: ${message}` }],
787
- isError: true
788
- };
1092
+ return handleToolError(getFlakyTestsMetadata.name, error);
789
1093
  }
790
1094
  }
791
1095
  );
@@ -805,11 +1109,7 @@ async function main() {
805
1109
  structuredContent: output
806
1110
  };
807
1111
  } catch (error) {
808
- const message = error instanceof Error ? error.message : "Unknown error";
809
- return {
810
- content: [{ type: "text", text: `Error: ${message}` }],
811
- isError: true
812
- };
1112
+ return handleToolError(listTestRunsMetadata.name, error);
813
1113
  }
814
1114
  }
815
1115
  );
@@ -829,11 +1129,7 @@ async function main() {
829
1129
  structuredContent: output
830
1130
  };
831
1131
  } catch (error) {
832
- const message = error instanceof Error ? error.message : "Unknown error";
833
- return {
834
- content: [{ type: "text", text: `Error: ${message}` }],
835
- isError: true
836
- };
1132
+ return handleToolError(listProjectsMetadata.name, error);
837
1133
  }
838
1134
  }
839
1135
  );
@@ -853,11 +1149,7 @@ async function main() {
853
1149
  structuredContent: output
854
1150
  };
855
1151
  } catch (error) {
856
- const message = error instanceof Error ? error.message : "Unknown error";
857
- return {
858
- content: [{ type: "text", text: `Error: ${message}` }],
859
- isError: true
860
- };
1152
+ return handleToolError(getReportMetadata.name, error);
861
1153
  }
862
1154
  }
863
1155
  );
@@ -877,11 +1169,47 @@ async function main() {
877
1169
  structuredContent: output
878
1170
  };
879
1171
  } catch (error) {
880
- const message = error instanceof Error ? error.message : "Unknown error";
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);
881
1187
  return {
882
- content: [{ type: "text", text: `Error: ${message}` }],
883
- isError: true
1188
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
1189
+ structuredContent: output
884
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);
1207
+ return {
1208
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
1209
+ structuredContent: output
1210
+ };
1211
+ } catch (error) {
1212
+ return handleToolError(compareTestMetricsMetadata.name, error);
885
1213
  }
886
1214
  }
887
1215
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gaffer-sh/mcp",
3
3
  "type": "module",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "description": "MCP server for Gaffer test history - give your AI assistant memory of your tests",
6
6
  "license": "MIT",
7
7
  "author": "Gaffer <hello@gaffer.sh>",