@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.
Files changed (2) hide show
  1. package/dist/index.js +514 -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,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 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()
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 the report files for a specific test run.
593
+ description: `Get URLs for report files uploaded with a test run.
394
594
 
395
- Returns a list of files uploaded with the test run, including:
396
- - filename: The file name (e.g., "report.html", "coverage/index.html")
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 (requires authentication)
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
- Common report types:
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
- Use cases:
408
- - "Get the Playwright report for this test run"
409
- - "Download the coverage report"
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
- Note: Download URLs require the same API key authentication used for this request.`
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 z4 } from "zod";
630
+ import { z as z5 } from "zod";
417
631
  var getSlowestTestsInputSchema = {
418
- projectId: z4.string().describe("Project ID to get slowest tests for. Required. Use list_projects to find project IDs."),
419
- days: z4.number().int().min(1).max(365).optional().describe("Analysis period in days (default: 30)"),
420
- limit: z4.number().int().min(1).max(100).optional().describe("Maximum number of tests to return (default: 20)"),
421
- 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")')
422
637
  };
423
638
  var getSlowestTestsOutputSchema = {
424
- slowestTests: z4.array(z4.object({
425
- name: z4.string(),
426
- fullName: z4.string(),
427
- filePath: z4.string().optional(),
428
- framework: z4.string().optional(),
429
- avgDurationMs: z4.number(),
430
- p95DurationMs: z4.number(),
431
- 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()
432
647
  })),
433
- summary: z4.object({
434
- projectId: z4.string(),
435
- projectName: z4.string(),
436
- period: z4.number(),
437
- totalReturned: z4.number()
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 z5 } from "zod";
710
+ import { z as z6 } from "zod";
493
711
  var getTestHistoryInputSchema = {
494
- 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."),
495
- testName: z5.string().optional().describe("Exact test name to search for"),
496
- filePath: z5.string().optional().describe("File path containing the test"),
497
- 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)")
498
716
  };
499
717
  var getTestHistoryOutputSchema = {
500
- history: z5.array(z5.object({
501
- testRunId: z5.string(),
502
- createdAt: z5.string(),
503
- branch: z5.string().optional(),
504
- commitSha: z5.string().optional(),
505
- status: z5.enum(["passed", "failed", "skipped", "pending"]),
506
- durationMs: z5.number(),
507
- 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()
508
726
  })),
509
- summary: z5.object({
510
- totalRuns: z5.number(),
511
- passedRuns: z5.number(),
512
- failedRuns: z5.number(),
513
- 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()
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 z6 } from "zod";
870
+ import { z as z8 } from "zod";
569
871
  var listProjectsInputSchema = {
570
- organizationId: z6.string().optional().describe("Filter by organization ID (optional)"),
571
- 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)")
572
874
  };
573
875
  var listProjectsOutputSchema = {
574
- projects: z6.array(z6.object({
575
- id: z6.string(),
576
- name: z6.string(),
577
- description: z6.string().nullable().optional(),
578
- organization: z6.object({
579
- id: z6.string(),
580
- name: z6.string(),
581
- 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()
582
884
  })
583
885
  })),
584
- total: z6.number()
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 z7 } from "zod";
915
+ import { z as z9 } from "zod";
614
916
  var listTestRunsInputSchema = {
615
- 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."),
616
- commitSha: z7.string().optional().describe("Filter by commit SHA (exact or prefix match)"),
617
- branch: z7.string().optional().describe("Filter by branch name"),
618
- status: z7.enum(["passed", "failed"]).optional().describe('Filter by status: "passed" (no failures) or "failed" (has failures)'),
619
- 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)")
620
922
  };
621
923
  var listTestRunsOutputSchema = {
622
- testRuns: z7.array(z7.object({
623
- id: z7.string(),
624
- commitSha: z7.string().optional(),
625
- branch: z7.string().optional(),
626
- passedCount: z7.number(),
627
- failedCount: z7.number(),
628
- skippedCount: z7.number(),
629
- totalCount: z7.number(),
630
- 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()
631
933
  })),
632
- pagination: z7.object({
633
- total: z7.number(),
634
- hasMore: z7.boolean()
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
- const message = error instanceof Error ? error.message : "Unknown error";
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
- const message = error instanceof Error ? error.message : "Unknown error";
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
- const message = error instanceof Error ? error.message : "Unknown error";
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
- const message = error instanceof Error ? error.message : "Unknown error";
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
- const message = error instanceof Error ? error.message : "Unknown error";
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
- const message = error instanceof Error ? error.message : "Unknown error";
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
- 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);
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: `Error: ${message}` }],
880
- isError: true
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gaffer-sh/mcp",
3
3
  "type": "module",
4
- "version": "0.2.0",
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>",