@gaffer-sh/mcp 0.1.0 → 0.2.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 (3) hide show
  1. package/README.md +95 -15
  2. package/dist/index.js +451 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -9,11 +9,27 @@ This MCP server connects AI coding assistants like Claude Code and Cursor to you
9
9
  - Check your project's test health (pass rate, flaky tests, trends)
10
10
  - Look up the history of specific tests to understand stability
11
11
  - Get context about test failures when debugging
12
+ - Browse all your projects (with user API Keys)
13
+ - Access test report files (HTML reports, coverage, etc.)
12
14
 
13
15
  ## Prerequisites
14
16
 
15
17
  1. A [Gaffer](https://gaffer.sh) account with test results uploaded
16
- 2. An API key from your project settings
18
+ 2. An API Key from Account Settings > API Keys
19
+
20
+ ## Authentication
21
+
22
+ The MCP server supports two types of authentication:
23
+
24
+ ### User API Keys (Recommended)
25
+
26
+ User API Keys (`gaf_` prefix) provide read-only access to all projects across your organizations. This is the recommended approach as it allows your AI assistant to work across multiple projects.
27
+
28
+ Get your API Key from: **Account Settings > API Keys**
29
+
30
+ ### Project Upload Tokens (Legacy)
31
+
32
+ Project Upload Tokens (`gfr_` prefix) are designed for uploading test results and only provide access to a single project. While still supported for backward compatibility, user API Keys are preferred for the MCP server.
17
33
 
18
34
  ## Setup
19
35
 
@@ -28,7 +44,7 @@ Add to your Claude Code settings (`~/.claude.json` or project `.claude/settings.
28
44
  "command": "npx",
29
45
  "args": ["-y", "@gaffer-sh/mcp"],
30
46
  "env": {
31
- "GAFFER_API_KEY": "gfr_your_api_key_here"
47
+ "GAFFER_API_KEY": "gaf_your_api_key_here"
32
48
  }
33
49
  }
34
50
  }
@@ -46,7 +62,7 @@ Add to `.cursor/mcp.json` in your project:
46
62
  "command": "npx",
47
63
  "args": ["-y", "@gaffer-sh/mcp"],
48
64
  "env": {
49
- "GAFFER_API_KEY": "gfr_your_api_key_here"
65
+ "GAFFER_API_KEY": "gaf_your_api_key_here"
50
66
  }
51
67
  }
52
68
  }
@@ -55,11 +71,25 @@ Add to `.cursor/mcp.json` in your project:
55
71
 
56
72
  ## Available Tools
57
73
 
74
+ ### `list_projects`
75
+
76
+ List all projects you have access to. **Requires a user API Key (`gaf_`).**
77
+
78
+ **Input:**
79
+ - `organizationId` (optional): Filter by organization
80
+ - `limit` (optional): Max results (default: 50)
81
+
82
+ **Returns:**
83
+ - List of projects with IDs, names, and organization info
84
+
85
+ **Example prompt:** "What projects do I have in Gaffer?"
86
+
58
87
  ### `get_project_health`
59
88
 
60
- Get the health metrics for your project.
89
+ Get the health metrics for a project.
61
90
 
62
91
  **Input:**
92
+ - `projectId` (required with user API Keys): Project ID from `list_projects`
63
93
  - `days` (optional): Number of days to analyze (default: 30)
64
94
 
65
95
  **Returns:**
@@ -75,9 +105,10 @@ Get the health metrics for your project.
75
105
 
76
106
  Get the pass/fail history for a specific test.
77
107
 
78
- **Input (one required):**
79
- - `testName`: Exact test name to search for
80
- - `filePath`: File path containing the test
108
+ **Input:**
109
+ - `projectId` (required with user API Keys): Project ID from `list_projects`
110
+ - `testName`: Exact test name to search for (one of testName or filePath required)
111
+ - `filePath`: File path containing the test (one of testName or filePath required)
81
112
  - `limit` (optional): Max results (default: 20)
82
113
 
83
114
  **Returns:**
@@ -93,9 +124,10 @@ Get the pass/fail history for a specific test.
93
124
 
94
125
  ### `get_flaky_tests`
95
126
 
96
- Get the list of flaky tests in your project.
127
+ Get the list of flaky tests in a project.
97
128
 
98
129
  **Input:**
130
+ - `projectId` (required with user API Keys): Project ID from `list_projects`
99
131
  - `threshold` (optional): Minimum flip rate to be considered flaky (0-1, default: 0.1)
100
132
  - `limit` (optional): Max results (default: 50)
101
133
  - `days` (optional): Analysis period in days (default: 30)
@@ -114,11 +146,12 @@ Get the list of flaky tests in your project.
114
146
 
115
147
  List recent test runs with optional filtering.
116
148
 
117
- **Input (all optional):**
118
- - `commitSha`: Filter by commit SHA (supports prefix matching)
119
- - `branch`: Filter by branch name
120
- - `status`: Filter by "passed" (no failures) or "failed" (has failures)
121
- - `limit`: Max results (default: 20)
149
+ **Input:**
150
+ - `projectId` (required with user API Keys): Project ID from `list_projects`
151
+ - `commitSha` (optional): Filter by commit SHA (supports prefix matching)
152
+ - `branch` (optional): Filter by branch name
153
+ - `status` (optional): Filter by "passed" (no failures) or "failed" (has failures)
154
+ - `limit` (optional): Max results (default: 20)
122
155
 
123
156
  **Returns:**
124
157
  - List of test runs with pass/fail/skip counts
@@ -130,11 +163,58 @@ List recent test runs with optional filtering.
130
163
  - "Show me test runs on the main branch"
131
164
  - "Did any tests fail on my feature branch?"
132
165
 
166
+ ### `get_report`
167
+
168
+ Get the report files for a specific test run. **Requires a user API Key (`gaf_`).**
169
+
170
+ **Input:**
171
+ - `testRunId` (required): Test run ID from `list_test_runs`
172
+
173
+ **Returns:**
174
+ - Test run ID and project info
175
+ - Framework used (e.g., playwright, vitest)
176
+ - List of files with:
177
+ - Filename (e.g., "report.html", "coverage/index.html")
178
+ - File size in bytes
179
+ - Content type (e.g., "text/html")
180
+ - Download URL
181
+
182
+ **Example prompts:**
183
+ - "Get the Playwright report for the latest test run"
184
+ - "What files were uploaded with this test run?"
185
+ - "Show me the coverage report"
186
+
187
+ ### `get_slowest_tests`
188
+
189
+ Get the slowest tests in a project, sorted by P95 duration. **Requires a user API Key (`gaf_`).**
190
+
191
+ **Input:**
192
+ - `projectId` (required): Project ID from `list_projects`
193
+ - `days` (optional): Analysis period in days (default: 30, max: 365)
194
+ - `limit` (optional): Max tests to return (default: 20, max: 100)
195
+ - `framework` (optional): Filter by framework (e.g., "playwright", "vitest")
196
+
197
+ **Returns:**
198
+ - List of slowest tests with:
199
+ - name: Short test name
200
+ - fullName: Full test name including describe blocks
201
+ - filePath: Test file path (if available)
202
+ - framework: Test framework used
203
+ - avgDurationMs: Average duration in milliseconds
204
+ - p95DurationMs: 95th percentile duration
205
+ - runCount: Number of runs in the period
206
+ - Summary with project info
207
+
208
+ **Example prompts:**
209
+ - "Which tests are slowing down my CI pipeline?"
210
+ - "Find the slowest Playwright tests"
211
+ - "Show me e2e tests that need optimization"
212
+
133
213
  ## Environment Variables
134
214
 
135
215
  | Variable | Required | Description |
136
216
  |----------|----------|-------------|
137
- | `GAFFER_API_KEY` | Yes | Your Gaffer API key (starts with `gfr_`) |
217
+ | `GAFFER_API_KEY` | Yes | Your Gaffer API Key (starts with `gaf_`) |
138
218
  | `GAFFER_API_URL` | No | API base URL (default: `https://app.gaffer.sh`) |
139
219
 
140
220
  ## Local Development
@@ -151,7 +231,7 @@ pnpm --filter @gaffer-sh/mcp build
151
231
  "command": "node",
152
232
  "args": ["/path/to/gaffer-v2/packages/mcp-server/dist/index.js"],
153
233
  "env": {
154
- "GAFFER_API_KEY": "gfr_..."
234
+ "GAFFER_API_KEY": "gaf_..."
155
235
  }
156
236
  }
157
237
  }
package/dist/index.js CHANGED
@@ -6,15 +6,26 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
 
7
7
  // src/api-client.ts
8
8
  var REQUEST_TIMEOUT_MS = 3e4;
9
+ function detectTokenType(token) {
10
+ if (token.startsWith("gaf_")) {
11
+ return "user";
12
+ }
13
+ return "project";
14
+ }
9
15
  var GafferApiClient = class _GafferApiClient {
10
16
  apiKey;
11
17
  baseUrl;
18
+ tokenType;
12
19
  constructor(config) {
13
20
  this.apiKey = config.apiKey;
14
21
  this.baseUrl = config.baseUrl.replace(/\/$/, "");
22
+ this.tokenType = detectTokenType(config.apiKey);
15
23
  }
16
24
  /**
17
25
  * Create client from environment variables
26
+ *
27
+ * Supports:
28
+ * - GAFFER_API_KEY (for user API Keys gaf_)
18
29
  */
19
30
  static fromEnv() {
20
31
  const apiKey = process.env.GAFFER_API_KEY;
@@ -24,6 +35,12 @@ var GafferApiClient = class _GafferApiClient {
24
35
  const baseUrl = process.env.GAFFER_API_URL || "https://app.gaffer.sh";
25
36
  return new _GafferApiClient({ apiKey, baseUrl });
26
37
  }
38
+ /**
39
+ * Check if using a user API Key (enables cross-project features)
40
+ */
41
+ isUserToken() {
42
+ return this.tokenType === "user";
43
+ }
27
44
  /**
28
45
  * Make authenticated request to Gaffer API
29
46
  */
@@ -44,7 +61,7 @@ var GafferApiClient = class _GafferApiClient {
44
61
  headers: {
45
62
  "X-API-Key": this.apiKey,
46
63
  "Accept": "application/json",
47
- "User-Agent": "gaffer-mcp/0.1.0"
64
+ "User-Agent": "gaffer-mcp/0.2.0"
48
65
  },
49
66
  signal: controller.signal
50
67
  });
@@ -63,16 +80,53 @@ var GafferApiClient = class _GafferApiClient {
63
80
  clearTimeout(timeoutId);
64
81
  }
65
82
  }
83
+ /**
84
+ * List all projects the user has access to
85
+ * Requires user API Key (gaf_)
86
+ *
87
+ * @param options - Query options
88
+ * @param options.organizationId - Filter by organization ID
89
+ * @param options.limit - Maximum number of results
90
+ * @param options.offset - Offset for pagination
91
+ */
92
+ async listProjects(options = {}) {
93
+ if (!this.isUserToken()) {
94
+ throw new Error("listProjects requires a user API Key (gaf_). Upload Tokens (gfr_) can only access a single project.");
95
+ }
96
+ return this.request("/user/projects", {
97
+ ...options.organizationId && { organizationId: options.organizationId },
98
+ ...options.limit && { limit: options.limit },
99
+ ...options.offset && { offset: options.offset }
100
+ });
101
+ }
66
102
  /**
67
103
  * Get project health analytics
104
+ *
105
+ * @param options - Query options
106
+ * @param options.projectId - Required for user tokens, ignored for project tokens
107
+ * @param options.days - Analysis period in days (default: 30)
68
108
  */
69
109
  async getProjectHealth(options = {}) {
110
+ if (this.isUserToken()) {
111
+ if (!options.projectId) {
112
+ throw new Error("projectId is required when using a user API Key");
113
+ }
114
+ return this.request(`/user/projects/${options.projectId}/health`, {
115
+ days: options.days || 30
116
+ });
117
+ }
70
118
  return this.request("/project/analytics", {
71
119
  days: options.days || 30
72
120
  });
73
121
  }
74
122
  /**
75
123
  * Get test history for a specific test
124
+ *
125
+ * @param options - Query options
126
+ * @param options.projectId - Required for user tokens, ignored for project tokens
127
+ * @param options.testName - Test name to search for
128
+ * @param options.filePath - File path to search for
129
+ * @param options.limit - Maximum number of results
76
130
  */
77
131
  async getTestHistory(options) {
78
132
  const testName = options.testName?.trim();
@@ -80,6 +134,16 @@ var GafferApiClient = class _GafferApiClient {
80
134
  if (!testName && !filePath) {
81
135
  throw new Error("Either testName or filePath is required (and must not be empty)");
82
136
  }
137
+ if (this.isUserToken()) {
138
+ if (!options.projectId) {
139
+ throw new Error("projectId is required when using a user API Key");
140
+ }
141
+ return this.request(`/user/projects/${options.projectId}/test-history`, {
142
+ ...testName && { testName },
143
+ ...filePath && { filePath },
144
+ ...options.limit && { limit: options.limit }
145
+ });
146
+ }
83
147
  return this.request("/project/test-history", {
84
148
  ...testName && { testName },
85
149
  ...filePath && { filePath },
@@ -88,8 +152,24 @@ var GafferApiClient = class _GafferApiClient {
88
152
  }
89
153
  /**
90
154
  * Get flaky tests for the project
155
+ *
156
+ * @param options - Query options
157
+ * @param options.projectId - Required for user tokens, ignored for project tokens
158
+ * @param options.threshold - Minimum flip rate to be considered flaky (0-1)
159
+ * @param options.limit - Maximum number of results
160
+ * @param options.days - Analysis period in days
91
161
  */
92
162
  async getFlakyTests(options = {}) {
163
+ if (this.isUserToken()) {
164
+ if (!options.projectId) {
165
+ throw new Error("projectId is required when using a user API Key");
166
+ }
167
+ return this.request(`/user/projects/${options.projectId}/flaky-tests`, {
168
+ ...options.threshold && { threshold: options.threshold },
169
+ ...options.limit && { limit: options.limit },
170
+ ...options.days && { days: options.days }
171
+ });
172
+ }
93
173
  return this.request("/project/flaky-tests", {
94
174
  ...options.threshold && { threshold: options.threshold },
95
175
  ...options.limit && { limit: options.limit },
@@ -98,8 +178,26 @@ var GafferApiClient = class _GafferApiClient {
98
178
  }
99
179
  /**
100
180
  * List test runs for the project
181
+ *
182
+ * @param options - Query options
183
+ * @param options.projectId - Required for user tokens, ignored for project tokens
184
+ * @param options.commitSha - Filter by commit SHA
185
+ * @param options.branch - Filter by branch name
186
+ * @param options.status - Filter by status ('passed' or 'failed')
187
+ * @param options.limit - Maximum number of results
101
188
  */
102
189
  async getTestRuns(options = {}) {
190
+ if (this.isUserToken()) {
191
+ if (!options.projectId) {
192
+ throw new Error("projectId is required when using a user API Key");
193
+ }
194
+ return this.request(`/user/projects/${options.projectId}/test-runs`, {
195
+ ...options.commitSha && { commitSha: options.commitSha },
196
+ ...options.branch && { branch: options.branch },
197
+ ...options.status && { status: options.status },
198
+ ...options.limit && { limit: options.limit }
199
+ });
200
+ }
103
201
  return this.request("/project/test-runs", {
104
202
  ...options.commitSha && { commitSha: options.commitSha },
105
203
  ...options.branch && { branch: options.branch },
@@ -107,11 +205,50 @@ var GafferApiClient = class _GafferApiClient {
107
205
  ...options.limit && { limit: options.limit }
108
206
  });
109
207
  }
208
+ /**
209
+ * Get report files for a test run
210
+ *
211
+ * @param testRunId - The test run ID
212
+ * @returns Report metadata with download URLs for each file
213
+ */
214
+ async getReport(testRunId) {
215
+ if (!this.isUserToken()) {
216
+ throw new Error("getReport requires a user API Key (gaf_). Upload Tokens (gfr_) cannot access reports via API.");
217
+ }
218
+ if (!testRunId) {
219
+ throw new Error("testRunId is required");
220
+ }
221
+ return this.request(`/user/test-runs/${testRunId}/report`);
222
+ }
223
+ /**
224
+ * Get slowest tests for a project
225
+ *
226
+ * @param options - Query options
227
+ * @param options.projectId - The project ID (required)
228
+ * @param options.days - Analysis period in days (default: 30)
229
+ * @param options.limit - Maximum number of results (default: 20)
230
+ * @param options.framework - Filter by test framework
231
+ * @returns Slowest tests sorted by P95 duration
232
+ */
233
+ async getSlowestTests(options) {
234
+ if (!this.isUserToken()) {
235
+ throw new Error("getSlowestTests requires a user API Key (gaf_).");
236
+ }
237
+ if (!options.projectId) {
238
+ throw new Error("projectId is required");
239
+ }
240
+ return this.request(`/user/projects/${options.projectId}/slowest-tests`, {
241
+ ...options.days && { days: options.days },
242
+ ...options.limit && { limit: options.limit },
243
+ ...options.framework && { framework: options.framework }
244
+ });
245
+ }
110
246
  };
111
247
 
112
248
  // src/tools/get-flaky-tests.ts
113
249
  import { z } from "zod";
114
250
  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."),
115
252
  threshold: z.number().min(0).max(1).optional().describe("Minimum flip rate to be considered flaky (0-1, default: 0.1 = 10%)"),
116
253
  limit: z.number().int().min(1).max(100).optional().describe("Maximum number of flaky tests to return (default: 50)"),
117
254
  days: z.number().int().min(1).max(365).optional().describe("Analysis period in days (default: 30)")
@@ -132,6 +269,7 @@ var getFlakyTestsOutputSchema = {
132
269
  };
133
270
  async function executeGetFlakyTests(client, input) {
134
271
  const response = await client.getFlakyTests({
272
+ projectId: input.projectId,
135
273
  threshold: input.threshold,
136
274
  limit: input.limit,
137
275
  days: input.days
@@ -144,7 +282,10 @@ async function executeGetFlakyTests(client, input) {
144
282
  var getFlakyTestsMetadata = {
145
283
  name: "get_flaky_tests",
146
284
  title: "Get Flaky Tests",
147
- description: `Get the list of flaky tests in the project.
285
+ description: `Get the list of flaky tests in a project.
286
+
287
+ When using a user API Key (gaf_), you must provide a projectId.
288
+ Use list_projects first to find available project IDs.
148
289
 
149
290
  A test is considered flaky if it frequently switches between pass and fail states
150
291
  (high "flip rate"). This helps identify unreliable tests that need attention.
@@ -165,6 +306,7 @@ specific tests are flaky and need investigation.`
165
306
  // src/tools/get-project-health.ts
166
307
  import { z as z2 } from "zod";
167
308
  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."),
168
310
  days: z2.number().int().min(1).max(365).optional().describe("Number of days to analyze (default: 30)")
169
311
  };
170
312
  var getProjectHealthOutputSchema = {
@@ -181,7 +323,10 @@ var getProjectHealthOutputSchema = {
181
323
  })
182
324
  };
183
325
  async function executeGetProjectHealth(client, input) {
184
- const response = await client.getProjectHealth({ days: input.days });
326
+ const response = await client.getProjectHealth({
327
+ projectId: input.projectId,
328
+ days: input.days
329
+ });
185
330
  return {
186
331
  projectName: response.analytics.projectName,
187
332
  healthScore: response.analytics.healthScore,
@@ -195,7 +340,10 @@ async function executeGetProjectHealth(client, input) {
195
340
  var getProjectHealthMetadata = {
196
341
  name: "get_project_health",
197
342
  title: "Get Project Health",
198
- description: `Get the health metrics for the project associated with your API key.
343
+ description: `Get the health metrics for a project.
344
+
345
+ When using a user API Key (gaf_), you must provide a projectId.
346
+ Use list_projects first to find available project IDs.
199
347
 
200
348
  Returns:
201
349
  - Health score (0-100): Overall project health based on pass rate and trend
@@ -207,28 +355,162 @@ Returns:
207
355
  Use this to understand the current state of your test suite.`
208
356
  };
209
357
 
210
- // src/tools/get-test-history.ts
358
+ // src/tools/get-report.ts
211
359
  import { z as z3 } from "zod";
360
+ 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.")
362
+ };
363
+ 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
+ }))
374
+ };
375
+ async function executeGetReport(client, input) {
376
+ const response = await client.getReport(input.testRunId);
377
+ return {
378
+ testRunId: response.testRunId,
379
+ projectId: response.projectId,
380
+ projectName: response.projectName,
381
+ resultSchema: response.resultSchema,
382
+ files: response.files.map((file) => ({
383
+ filename: file.filename,
384
+ size: file.size,
385
+ contentType: file.contentType,
386
+ downloadUrl: file.downloadUrl
387
+ }))
388
+ };
389
+ }
390
+ var getReportMetadata = {
391
+ name: "get_report",
392
+ title: "Get Report Files",
393
+ description: `Get the report files for a specific test run.
394
+
395
+ Returns a list of files uploaded with the test run, including:
396
+ - filename: The file name (e.g., "report.html", "coverage/index.html")
397
+ - size: File size in bytes
398
+ - contentType: MIME type (e.g., "text/html", "application/json")
399
+ - downloadUrl: URL to download the file (requires authentication)
400
+
401
+ Common report types:
402
+ - HTML reports (Playwright, pytest-html, Vitest UI)
403
+ - JSON results (Jest, Vitest)
404
+ - JUnit XML files
405
+ - Coverage reports
406
+
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?"
411
+
412
+ Note: Download URLs require the same API key authentication used for this request.`
413
+ };
414
+
415
+ // src/tools/get-slowest-tests.ts
416
+ import { z as z4 } from "zod";
417
+ 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")')
422
+ };
423
+ 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()
432
+ })),
433
+ summary: z4.object({
434
+ projectId: z4.string(),
435
+ projectName: z4.string(),
436
+ period: z4.number(),
437
+ totalReturned: z4.number()
438
+ })
439
+ };
440
+ async function executeGetSlowestTests(client, input) {
441
+ const response = await client.getSlowestTests({
442
+ projectId: input.projectId,
443
+ days: input.days,
444
+ limit: input.limit,
445
+ framework: input.framework
446
+ });
447
+ return {
448
+ slowestTests: response.slowestTests.map((test) => ({
449
+ name: test.name,
450
+ fullName: test.fullName,
451
+ filePath: test.filePath,
452
+ framework: test.framework,
453
+ avgDurationMs: test.avgDurationMs,
454
+ p95DurationMs: test.p95DurationMs,
455
+ runCount: test.runCount
456
+ })),
457
+ summary: response.summary
458
+ };
459
+ }
460
+ var getSlowestTestsMetadata = {
461
+ name: "get_slowest_tests",
462
+ title: "Get Slowest Tests",
463
+ description: `Get the slowest tests in a project, sorted by P95 duration.
464
+
465
+ When using a user API Key (gaf_), you must provide a projectId.
466
+ Use list_projects first to find available project IDs.
467
+
468
+ Parameters:
469
+ - projectId (required): Project ID to analyze
470
+ - days (optional): Analysis period in days (default: 30, max: 365)
471
+ - limit (optional): Max tests to return (default: 20, max: 100)
472
+ - framework (optional): Filter by framework (e.g., "playwright", "vitest")
473
+
474
+ Returns:
475
+ - List of slowest tests with:
476
+ - name: Short test name
477
+ - fullName: Full test name including describe blocks
478
+ - filePath: Test file path (if available)
479
+ - framework: Test framework used
480
+ - avgDurationMs: Average test duration in milliseconds
481
+ - p95DurationMs: 95th percentile duration (used for sorting)
482
+ - runCount: Number of times the test ran in the period
483
+ - Summary with project info and period
484
+
485
+ Use cases:
486
+ - "Which tests are slowing down my CI pipeline?"
487
+ - "Find the slowest Playwright tests to optimize"
488
+ - "Show me e2e tests taking over 30 seconds"`
489
+ };
490
+
491
+ // src/tools/get-test-history.ts
492
+ import { z as z5 } from "zod";
212
493
  var getTestHistoryInputSchema = {
213
- testName: z3.string().optional().describe("Exact test name to search for"),
214
- filePath: z3.string().optional().describe("File path containing the test"),
215
- limit: z3.number().int().min(1).max(100).optional().describe("Maximum number of results (default: 20)")
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)")
216
498
  };
217
499
  var getTestHistoryOutputSchema = {
218
- history: z3.array(z3.object({
219
- testRunId: z3.string(),
220
- createdAt: z3.string(),
221
- branch: z3.string().optional(),
222
- commitSha: z3.string().optional(),
223
- status: z3.enum(["passed", "failed", "skipped", "pending"]),
224
- durationMs: z3.number(),
225
- message: z3.string().optional()
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()
226
508
  })),
227
- summary: z3.object({
228
- totalRuns: z3.number(),
229
- passedRuns: z3.number(),
230
- failedRuns: z3.number(),
231
- passRate: z3.number().nullable()
509
+ summary: z5.object({
510
+ totalRuns: z5.number(),
511
+ passedRuns: z5.number(),
512
+ failedRuns: z5.number(),
513
+ passRate: z5.number().nullable()
232
514
  })
233
515
  };
234
516
  async function executeGetTestHistory(client, input) {
@@ -236,6 +518,7 @@ async function executeGetTestHistory(client, input) {
236
518
  throw new Error("Either testName or filePath is required");
237
519
  }
238
520
  const response = await client.getTestHistory({
521
+ projectId: input.projectId,
239
522
  testName: input.testName,
240
523
  filePath: input.filePath,
241
524
  limit: input.limit || 20
@@ -264,6 +547,9 @@ var getTestHistoryMetadata = {
264
547
  title: "Get Test History",
265
548
  description: `Get the pass/fail history for a specific test.
266
549
 
550
+ When using a user API Key (gaf_), you must provide a projectId.
551
+ Use list_projects first to find available project IDs.
552
+
267
553
  Search by either:
268
554
  - testName: The exact name of the test (e.g., "should handle user login")
269
555
  - filePath: The file path containing the test (e.g., "tests/auth.test.ts")
@@ -278,32 +564,79 @@ Returns:
278
564
  Use this to investigate flaky tests or understand test stability.`
279
565
  };
280
566
 
567
+ // src/tools/list-projects.ts
568
+ import { z as z6 } from "zod";
569
+ 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)")
572
+ };
573
+ 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()
582
+ })
583
+ })),
584
+ total: z6.number()
585
+ };
586
+ async function executeListProjects(client, input) {
587
+ const response = await client.listProjects({
588
+ organizationId: input.organizationId,
589
+ limit: input.limit
590
+ });
591
+ return {
592
+ projects: response.projects.map((p) => ({
593
+ id: p.id,
594
+ name: p.name,
595
+ description: p.description,
596
+ organization: p.organization
597
+ })),
598
+ total: response.pagination.total
599
+ };
600
+ }
601
+ var listProjectsMetadata = {
602
+ name: "list_projects",
603
+ title: "List Projects",
604
+ description: `List all projects you have access to.
605
+
606
+ Returns a list of projects with their IDs, names, and organization info.
607
+ Use this to find project IDs for other tools like get_project_health.
608
+
609
+ Requires a user API Key (gaf_). Get one from Account Settings in the Gaffer dashboard.`
610
+ };
611
+
281
612
  // src/tools/list-test-runs.ts
282
- import { z as z4 } from "zod";
613
+ import { z as z7 } from "zod";
283
614
  var listTestRunsInputSchema = {
284
- commitSha: z4.string().optional().describe("Filter by commit SHA (exact or prefix match)"),
285
- branch: z4.string().optional().describe("Filter by branch name"),
286
- status: z4.enum(["passed", "failed"]).optional().describe('Filter by status: "passed" (no failures) or "failed" (has failures)'),
287
- limit: z4.number().int().min(1).max(100).optional().describe("Maximum number of test runs to return (default: 20)")
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)")
288
620
  };
289
621
  var listTestRunsOutputSchema = {
290
- testRuns: z4.array(z4.object({
291
- id: z4.string(),
292
- commitSha: z4.string().optional(),
293
- branch: z4.string().optional(),
294
- passedCount: z4.number(),
295
- failedCount: z4.number(),
296
- skippedCount: z4.number(),
297
- totalCount: z4.number(),
298
- createdAt: z4.string()
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()
299
631
  })),
300
- pagination: z4.object({
301
- total: z4.number(),
302
- hasMore: z4.boolean()
632
+ pagination: z7.object({
633
+ total: z7.number(),
634
+ hasMore: z7.boolean()
303
635
  })
304
636
  };
305
637
  async function executeListTestRuns(client, input) {
306
638
  const response = await client.getTestRuns({
639
+ projectId: input.projectId,
307
640
  commitSha: input.commitSha,
308
641
  branch: input.branch,
309
642
  status: input.status,
@@ -329,7 +662,10 @@ async function executeListTestRuns(client, input) {
329
662
  var listTestRunsMetadata = {
330
663
  name: "list_test_runs",
331
664
  title: "List Test Runs",
332
- description: `List recent test runs for the project with optional filtering.
665
+ description: `List recent test runs for a project with optional filtering.
666
+
667
+ When using a user API Key (gaf_), you must provide a projectId.
668
+ Use list_projects first to find available project IDs.
333
669
 
334
670
  Filter by:
335
671
  - commitSha: Filter by commit SHA (supports prefix matching)
@@ -353,10 +689,11 @@ Use cases:
353
689
 
354
690
  // src/index.ts
355
691
  async function main() {
356
- if (!process.env.GAFFER_API_KEY) {
692
+ const apiKey = process.env.GAFFER_API_KEY;
693
+ if (!apiKey) {
357
694
  console.error("Error: GAFFER_API_KEY environment variable is required");
358
695
  console.error("");
359
- console.error("Get your API key from: https://app.gaffer.sh/settings/api-keys");
696
+ console.error("Get your API Key from: https://app.gaffer.sh/account/api-keys");
360
697
  console.error("");
361
698
  console.error("Then configure Claude Code or Cursor with:");
362
699
  console.error(JSON.stringify({
@@ -365,7 +702,7 @@ async function main() {
365
702
  command: "npx",
366
703
  args: ["-y", "@gaffer-sh/mcp"],
367
704
  env: {
368
- GAFFER_API_KEY: "your-api-key-here"
705
+ GAFFER_API_KEY: "gaf_your-api-key-here"
369
706
  }
370
707
  }
371
708
  }
@@ -473,6 +810,78 @@ async function main() {
473
810
  }
474
811
  }
475
812
  );
813
+ server.registerTool(
814
+ listProjectsMetadata.name,
815
+ {
816
+ title: listProjectsMetadata.title,
817
+ description: listProjectsMetadata.description,
818
+ inputSchema: listProjectsInputSchema,
819
+ outputSchema: listProjectsOutputSchema
820
+ },
821
+ async (input) => {
822
+ try {
823
+ const output = await executeListProjects(client, input);
824
+ return {
825
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
826
+ structuredContent: output
827
+ };
828
+ } 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
+ };
834
+ }
835
+ }
836
+ );
837
+ server.registerTool(
838
+ getReportMetadata.name,
839
+ {
840
+ title: getReportMetadata.title,
841
+ description: getReportMetadata.description,
842
+ inputSchema: getReportInputSchema,
843
+ outputSchema: getReportOutputSchema
844
+ },
845
+ async (input) => {
846
+ try {
847
+ const output = await executeGetReport(client, input);
848
+ return {
849
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
850
+ structuredContent: output
851
+ };
852
+ } 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
+ };
858
+ }
859
+ }
860
+ );
861
+ server.registerTool(
862
+ getSlowestTestsMetadata.name,
863
+ {
864
+ title: getSlowestTestsMetadata.title,
865
+ description: getSlowestTestsMetadata.description,
866
+ inputSchema: getSlowestTestsInputSchema,
867
+ outputSchema: getSlowestTestsOutputSchema
868
+ },
869
+ async (input) => {
870
+ try {
871
+ const output = await executeGetSlowestTests(client, input);
872
+ return {
873
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
874
+ structuredContent: output
875
+ };
876
+ } catch (error) {
877
+ const message = error instanceof Error ? error.message : "Unknown error";
878
+ return {
879
+ content: [{ type: "text", text: `Error: ${message}` }],
880
+ isError: true
881
+ };
882
+ }
883
+ }
884
+ );
476
885
  const transport = new StdioServerTransport();
477
886
  await server.connect(transport);
478
887
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gaffer-sh/mcp",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.2.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>",