@gaffer-sh/mcp 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +841 -432
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -10,7 +10,6 @@ const REQUEST_TIMEOUT_MS = 3e4;
|
|
|
10
10
|
const MAX_RETRIES = 3;
|
|
11
11
|
const INITIAL_RETRY_DELAY_MS = 1e3;
|
|
12
12
|
const RETRYABLE_STATUS_CODES = [
|
|
13
|
-
401,
|
|
14
13
|
429,
|
|
15
14
|
500,
|
|
16
15
|
502,
|
|
@@ -26,23 +25,28 @@ function sleep(ms) {
|
|
|
26
25
|
/**
|
|
27
26
|
* Detect token type from prefix
|
|
28
27
|
* - gaf_ = user API Key (read-only, cross-project)
|
|
29
|
-
* - gfr_ = Project
|
|
28
|
+
* - gfr_ = Project Token (single project)
|
|
30
29
|
*/
|
|
31
30
|
function detectTokenType(token) {
|
|
32
31
|
if (token.startsWith("gaf_")) return "user";
|
|
33
|
-
return "project";
|
|
32
|
+
if (token.startsWith("gfr_")) return "project";
|
|
33
|
+
throw new Error(`Unrecognized API key format. Expected a user API key (gaf_...) or project token (gfr_...). Got: "${token.substring(0, 4)}...". Check your GAFFER_API_KEY environment variable.`);
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* Gaffer API v1 client for MCP server
|
|
37
37
|
*
|
|
38
38
|
* Supports two authentication modes:
|
|
39
39
|
* 1. User API Keys (gaf_) - Read-only access to all user's projects
|
|
40
|
-
* 2. Project
|
|
40
|
+
* 2. Project Tokens (gfr_) - Single project access, auto-resolves projectId
|
|
41
|
+
*
|
|
42
|
+
* All methods use the unified /user/projects/:id/ route tree.
|
|
43
|
+
* Project tokens auto-resolve their projectId via /project on first use.
|
|
41
44
|
*/
|
|
42
45
|
var GafferApiClient = class GafferApiClient {
|
|
43
46
|
apiKey;
|
|
44
47
|
baseUrl;
|
|
45
48
|
tokenType;
|
|
49
|
+
resolveProjectIdPromise = null;
|
|
46
50
|
constructor(config) {
|
|
47
51
|
this.apiKey = config.apiKey;
|
|
48
52
|
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
@@ -52,7 +56,7 @@ var GafferApiClient = class GafferApiClient {
|
|
|
52
56
|
* Create client from environment variables
|
|
53
57
|
*
|
|
54
58
|
* Supports:
|
|
55
|
-
* - GAFFER_API_KEY (for user API Keys gaf_)
|
|
59
|
+
* - GAFFER_API_KEY (for user API Keys gaf_ or project tokens gfr_)
|
|
56
60
|
*/
|
|
57
61
|
static fromEnv() {
|
|
58
62
|
const apiKey = process.env.GAFFER_API_KEY;
|
|
@@ -69,6 +73,24 @@ var GafferApiClient = class GafferApiClient {
|
|
|
69
73
|
return this.tokenType === "user";
|
|
70
74
|
}
|
|
71
75
|
/**
|
|
76
|
+
* Resolve the project ID for the current token.
|
|
77
|
+
* For project tokens, fetches from /project on first call and caches the Promise
|
|
78
|
+
* to deduplicate concurrent calls.
|
|
79
|
+
* For user tokens, requires explicit projectId.
|
|
80
|
+
*/
|
|
81
|
+
async resolveProjectId(projectId) {
|
|
82
|
+
if (projectId) return projectId;
|
|
83
|
+
if (this.isUserToken()) throw new Error("projectId is required when using a user API Key");
|
|
84
|
+
if (!this.resolveProjectIdPromise) this.resolveProjectIdPromise = this.request("/project").then((response) => {
|
|
85
|
+
if (!response?.project?.id) throw new Error("Failed to resolve project ID from token: unexpected response from /project endpoint. Ensure your project token (gfr_) is valid and the project still exists.");
|
|
86
|
+
return response.project.id;
|
|
87
|
+
}).catch((error) => {
|
|
88
|
+
this.resolveProjectIdPromise = null;
|
|
89
|
+
throw error;
|
|
90
|
+
});
|
|
91
|
+
return this.resolveProjectIdPromise;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
72
94
|
* Make authenticated request to Gaffer API with retry logic
|
|
73
95
|
*/
|
|
74
96
|
async request(endpoint, params) {
|
|
@@ -129,13 +151,8 @@ var GafferApiClient = class GafferApiClient {
|
|
|
129
151
|
throw lastError || /* @__PURE__ */ new Error("Request failed after retries");
|
|
130
152
|
}
|
|
131
153
|
/**
|
|
132
|
-
* List all projects the user has access to
|
|
133
|
-
* Requires user API Key (gaf_)
|
|
134
|
-
*
|
|
135
|
-
* @param options - Query options
|
|
136
|
-
* @param options.organizationId - Filter by organization ID
|
|
137
|
-
* @param options.limit - Maximum number of results
|
|
138
|
-
* @param options.offset - Offset for pagination
|
|
154
|
+
* List all projects the user has access to.
|
|
155
|
+
* Requires user API Key (gaf_). Not available with project tokens.
|
|
139
156
|
*/
|
|
140
157
|
async listProjects(options = {}) {
|
|
141
158
|
if (!this.isUserToken()) throw new Error("list_projects is not available with project tokens (gfr_). Your token is already scoped to a single project — call tools directly without passing projectId.");
|
|
@@ -147,40 +164,20 @@ var GafferApiClient = class GafferApiClient {
|
|
|
147
164
|
}
|
|
148
165
|
/**
|
|
149
166
|
* Get project health analytics
|
|
150
|
-
*
|
|
151
|
-
* @param options - Query options
|
|
152
|
-
* @param options.projectId - Required for user tokens, ignored for project tokens
|
|
153
|
-
* @param options.days - Analysis period in days (default: 30)
|
|
154
167
|
*/
|
|
155
168
|
async getProjectHealth(options = {}) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return this.request(`/user/projects/${options.projectId}/health`, { days: options.days || 30 });
|
|
159
|
-
}
|
|
160
|
-
return this.request("/project/analytics", { days: options.days || 30 });
|
|
169
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
170
|
+
return this.request(`/user/projects/${projectId}/health`, { days: options.days || 30 });
|
|
161
171
|
}
|
|
162
172
|
/**
|
|
163
173
|
* Get test history for a specific test
|
|
164
|
-
*
|
|
165
|
-
* @param options - Query options
|
|
166
|
-
* @param options.projectId - Required for user tokens, ignored for project tokens
|
|
167
|
-
* @param options.testName - Test name to search for
|
|
168
|
-
* @param options.filePath - File path to search for
|
|
169
|
-
* @param options.limit - Maximum number of results
|
|
170
174
|
*/
|
|
171
175
|
async getTestHistory(options) {
|
|
172
176
|
const testName = options.testName?.trim();
|
|
173
177
|
const filePath = options.filePath?.trim();
|
|
174
178
|
if (!testName && !filePath) throw new Error("Either testName or filePath is required (and must not be empty)");
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return this.request(`/user/projects/${options.projectId}/test-history`, {
|
|
178
|
-
...testName && { testName },
|
|
179
|
-
...filePath && { filePath },
|
|
180
|
-
...options.limit && { limit: options.limit }
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
return this.request("/project/test-history", {
|
|
179
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
180
|
+
return this.request(`/user/projects/${projectId}/test-history`, {
|
|
184
181
|
...testName && { testName },
|
|
185
182
|
...filePath && { filePath },
|
|
186
183
|
...options.limit && { limit: options.limit }
|
|
@@ -188,23 +185,10 @@ var GafferApiClient = class GafferApiClient {
|
|
|
188
185
|
}
|
|
189
186
|
/**
|
|
190
187
|
* Get flaky tests for the project
|
|
191
|
-
*
|
|
192
|
-
* @param options - Query options
|
|
193
|
-
* @param options.projectId - Required for user tokens, ignored for project tokens
|
|
194
|
-
* @param options.threshold - Minimum flip rate to be considered flaky (0-1)
|
|
195
|
-
* @param options.limit - Maximum number of results
|
|
196
|
-
* @param options.days - Analysis period in days
|
|
197
188
|
*/
|
|
198
189
|
async getFlakyTests(options = {}) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return this.request(`/user/projects/${options.projectId}/flaky-tests`, {
|
|
202
|
-
...options.threshold && { threshold: options.threshold },
|
|
203
|
-
...options.limit && { limit: options.limit },
|
|
204
|
-
...options.days && { days: options.days }
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
return this.request("/project/flaky-tests", {
|
|
190
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
191
|
+
return this.request(`/user/projects/${projectId}/flaky-tests`, {
|
|
208
192
|
...options.threshold && { threshold: options.threshold },
|
|
209
193
|
...options.limit && { limit: options.limit },
|
|
210
194
|
...options.days && { days: options.days }
|
|
@@ -212,25 +196,10 @@ var GafferApiClient = class GafferApiClient {
|
|
|
212
196
|
}
|
|
213
197
|
/**
|
|
214
198
|
* List test runs for the project
|
|
215
|
-
*
|
|
216
|
-
* @param options - Query options
|
|
217
|
-
* @param options.projectId - Required for user tokens, ignored for project tokens
|
|
218
|
-
* @param options.commitSha - Filter by commit SHA
|
|
219
|
-
* @param options.branch - Filter by branch name
|
|
220
|
-
* @param options.status - Filter by status ('passed' or 'failed')
|
|
221
|
-
* @param options.limit - Maximum number of results
|
|
222
199
|
*/
|
|
223
200
|
async getTestRuns(options = {}) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return this.request(`/user/projects/${options.projectId}/test-runs`, {
|
|
227
|
-
...options.commitSha && { commitSha: options.commitSha },
|
|
228
|
-
...options.branch && { branch: options.branch },
|
|
229
|
-
...options.status && { status: options.status },
|
|
230
|
-
...options.limit && { limit: options.limit }
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
return this.request("/project/test-runs", {
|
|
201
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
202
|
+
return this.request(`/user/projects/${projectId}/test-runs`, {
|
|
234
203
|
...options.commitSha && { commitSha: options.commitSha },
|
|
235
204
|
...options.branch && { branch: options.branch },
|
|
236
205
|
...options.status && { status: options.status },
|
|
@@ -238,31 +207,21 @@ var GafferApiClient = class GafferApiClient {
|
|
|
238
207
|
});
|
|
239
208
|
}
|
|
240
209
|
/**
|
|
241
|
-
* Get report files for a test run
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* @returns Report metadata with download URLs for each file
|
|
210
|
+
* Get report files for a test run.
|
|
211
|
+
* User-only: the /user/test-runs/:id/report route has no project-scoped equivalent,
|
|
212
|
+
* so project tokens cannot access raw report downloads.
|
|
245
213
|
*/
|
|
246
214
|
async getReport(testRunId) {
|
|
247
|
-
if (!this.isUserToken()) throw new Error("getReport requires a user API Key (gaf_).
|
|
215
|
+
if (!this.isUserToken()) throw new Error("getReport requires a user API Key (gaf_). Project tokens (gfr_) cannot access reports via API.");
|
|
248
216
|
if (!testRunId) throw new Error("testRunId is required");
|
|
249
217
|
return this.request(`/user/test-runs/${testRunId}/report`);
|
|
250
218
|
}
|
|
251
219
|
/**
|
|
252
220
|
* Get slowest tests for a project
|
|
253
|
-
*
|
|
254
|
-
* @param options - Query options
|
|
255
|
-
* @param options.projectId - The project ID (required)
|
|
256
|
-
* @param options.days - Analysis period in days (default: 30)
|
|
257
|
-
* @param options.limit - Maximum number of results (default: 20)
|
|
258
|
-
* @param options.framework - Filter by test framework
|
|
259
|
-
* @param options.branch - Filter by git branch name
|
|
260
|
-
* @returns Slowest tests sorted by P95 duration
|
|
261
221
|
*/
|
|
262
222
|
async getSlowestTests(options) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return this.request(`/user/projects/${options.projectId}/slowest-tests`, {
|
|
223
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
224
|
+
return this.request(`/user/projects/${projectId}/slowest-tests`, {
|
|
266
225
|
...options.days && { days: options.days },
|
|
267
226
|
...options.limit && { limit: options.limit },
|
|
268
227
|
...options.framework && { framework: options.framework },
|
|
@@ -271,20 +230,11 @@ var GafferApiClient = class GafferApiClient {
|
|
|
271
230
|
}
|
|
272
231
|
/**
|
|
273
232
|
* Get parsed test results for a specific test run
|
|
274
|
-
*
|
|
275
|
-
* @param options - Query options
|
|
276
|
-
* @param options.projectId - The project ID (required)
|
|
277
|
-
* @param options.testRunId - The test run ID (required)
|
|
278
|
-
* @param options.status - Filter by test status ('passed', 'failed', 'skipped')
|
|
279
|
-
* @param options.limit - Maximum number of results (default: 100)
|
|
280
|
-
* @param options.offset - Pagination offset (default: 0)
|
|
281
|
-
* @returns Parsed test cases with pagination
|
|
282
233
|
*/
|
|
283
234
|
async getTestRunDetails(options) {
|
|
284
|
-
if (!this.isUserToken()) throw new Error("getTestRunDetails requires a user API Key (gaf_).");
|
|
285
|
-
if (!options.projectId) throw new Error("projectId is required");
|
|
286
235
|
if (!options.testRunId) throw new Error("testRunId is required");
|
|
287
|
-
|
|
236
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
237
|
+
return this.request(`/user/projects/${projectId}/test-runs/${options.testRunId}/details`, {
|
|
288
238
|
...options.status && { status: options.status },
|
|
289
239
|
...options.limit && { limit: options.limit },
|
|
290
240
|
...options.offset && { offset: options.offset }
|
|
@@ -292,21 +242,11 @@ var GafferApiClient = class GafferApiClient {
|
|
|
292
242
|
}
|
|
293
243
|
/**
|
|
294
244
|
* Compare test metrics between two commits or test runs
|
|
295
|
-
*
|
|
296
|
-
* @param options - Query options
|
|
297
|
-
* @param options.projectId - The project ID (required)
|
|
298
|
-
* @param options.testName - The test name to compare (required)
|
|
299
|
-
* @param options.beforeCommit - Commit SHA for before (use with afterCommit)
|
|
300
|
-
* @param options.afterCommit - Commit SHA for after (use with beforeCommit)
|
|
301
|
-
* @param options.beforeRunId - Test run ID for before (use with afterRunId)
|
|
302
|
-
* @param options.afterRunId - Test run ID for after (use with beforeRunId)
|
|
303
|
-
* @returns Comparison of test metrics
|
|
304
245
|
*/
|
|
305
246
|
async compareTestMetrics(options) {
|
|
306
|
-
if (!this.isUserToken()) throw new Error("compareTestMetrics requires a user API Key (gaf_).");
|
|
307
|
-
if (!options.projectId) throw new Error("projectId is required");
|
|
308
247
|
if (!options.testName) throw new Error("testName is required");
|
|
309
|
-
|
|
248
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
249
|
+
return this.request(`/user/projects/${projectId}/compare-test`, {
|
|
310
250
|
testName: options.testName,
|
|
311
251
|
...options.beforeCommit && { beforeCommit: options.beforeCommit },
|
|
312
252
|
...options.afterCommit && { afterCommit: options.afterCommit },
|
|
@@ -316,35 +256,17 @@ var GafferApiClient = class GafferApiClient {
|
|
|
316
256
|
}
|
|
317
257
|
/**
|
|
318
258
|
* Get coverage summary for a project
|
|
319
|
-
*
|
|
320
|
-
* @param options - Query options
|
|
321
|
-
* @param options.projectId - The project ID (required)
|
|
322
|
-
* @param options.days - Analysis period in days (default: 30)
|
|
323
|
-
* @returns Coverage summary with trends and lowest coverage files
|
|
324
259
|
*/
|
|
325
260
|
async getCoverageSummary(options) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return this.request(`/user/projects/${options.projectId}/coverage-summary`, { ...options.days && { days: options.days } });
|
|
261
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
262
|
+
return this.request(`/user/projects/${projectId}/coverage-summary`, { ...options.days && { days: options.days } });
|
|
329
263
|
}
|
|
330
264
|
/**
|
|
331
265
|
* Get coverage files for a project with filtering
|
|
332
|
-
*
|
|
333
|
-
* @param options - Query options
|
|
334
|
-
* @param options.projectId - The project ID (required)
|
|
335
|
-
* @param options.filePath - Filter to specific file path
|
|
336
|
-
* @param options.minCoverage - Minimum coverage percentage
|
|
337
|
-
* @param options.maxCoverage - Maximum coverage percentage
|
|
338
|
-
* @param options.limit - Maximum number of results
|
|
339
|
-
* @param options.offset - Pagination offset
|
|
340
|
-
* @param options.sortBy - Sort by 'path' or 'coverage'
|
|
341
|
-
* @param options.sortOrder - Sort order 'asc' or 'desc'
|
|
342
|
-
* @returns List of files with coverage data
|
|
343
266
|
*/
|
|
344
267
|
async getCoverageFiles(options) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
return this.request(`/user/projects/${options.projectId}/coverage/files`, {
|
|
268
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
269
|
+
return this.request(`/user/projects/${projectId}/coverage/files`, {
|
|
348
270
|
...options.filePath && { filePath: options.filePath },
|
|
349
271
|
...options.minCoverage !== void 0 && { minCoverage: options.minCoverage },
|
|
350
272
|
...options.maxCoverage !== void 0 && { maxCoverage: options.maxCoverage },
|
|
@@ -356,65 +278,36 @@ var GafferApiClient = class GafferApiClient {
|
|
|
356
278
|
}
|
|
357
279
|
/**
|
|
358
280
|
* Get risk areas (files with low coverage AND test failures)
|
|
359
|
-
*
|
|
360
|
-
* @param options - Query options
|
|
361
|
-
* @param options.projectId - The project ID (required)
|
|
362
|
-
* @param options.days - Analysis period in days (default: 30)
|
|
363
|
-
* @param options.coverageThreshold - Include files below this coverage (default: 80)
|
|
364
|
-
* @returns List of risk areas sorted by risk score
|
|
365
281
|
*/
|
|
366
282
|
async getCoverageRiskAreas(options) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
return this.request(`/user/projects/${options.projectId}/coverage/risk-areas`, {
|
|
283
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
284
|
+
return this.request(`/user/projects/${projectId}/coverage/risk-areas`, {
|
|
370
285
|
...options.days && { days: options.days },
|
|
371
286
|
...options.coverageThreshold !== void 0 && { coverageThreshold: options.coverageThreshold }
|
|
372
287
|
});
|
|
373
288
|
}
|
|
374
289
|
/**
|
|
375
290
|
* Get a browser-navigable URL for viewing a test report
|
|
376
|
-
*
|
|
377
|
-
* @param options - Query options
|
|
378
|
-
* @param options.projectId - The project ID (required)
|
|
379
|
-
* @param options.testRunId - The test run ID (required)
|
|
380
|
-
* @param options.filename - Specific file to open (default: index.html)
|
|
381
|
-
* @returns URL with signed token for browser access
|
|
382
291
|
*/
|
|
383
292
|
async getReportBrowserUrl(options) {
|
|
384
|
-
if (!this.isUserToken()) throw new Error("getReportBrowserUrl requires a user API Key (gaf_).");
|
|
385
|
-
if (!options.projectId) throw new Error("projectId is required");
|
|
386
293
|
if (!options.testRunId) throw new Error("testRunId is required");
|
|
387
|
-
|
|
294
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
295
|
+
return this.request(`/user/projects/${projectId}/reports/${options.testRunId}/browser-url`, { ...options.filename && { filename: options.filename } });
|
|
388
296
|
}
|
|
389
297
|
/**
|
|
390
298
|
* Get failure clusters for a test run
|
|
391
|
-
*
|
|
392
|
-
* @param options - Query options
|
|
393
|
-
* @param options.projectId - The project ID (required)
|
|
394
|
-
* @param options.testRunId - The test run ID (required)
|
|
395
|
-
* @returns Failure clusters grouped by error similarity
|
|
396
299
|
*/
|
|
397
300
|
async getFailureClusters(options) {
|
|
398
|
-
if (!this.isUserToken()) throw new Error("getFailureClusters requires a user API Key (gaf_).");
|
|
399
|
-
if (!options.projectId) throw new Error("projectId is required");
|
|
400
301
|
if (!options.testRunId) throw new Error("testRunId is required");
|
|
401
|
-
|
|
302
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
303
|
+
return this.request(`/user/projects/${projectId}/test-runs/${options.testRunId}/failure-clusters`);
|
|
402
304
|
}
|
|
403
305
|
/**
|
|
404
306
|
* List upload sessions for a project
|
|
405
|
-
*
|
|
406
|
-
* @param options - Query options
|
|
407
|
-
* @param options.projectId - The project ID (required)
|
|
408
|
-
* @param options.commitSha - Filter by commit SHA
|
|
409
|
-
* @param options.branch - Filter by branch name
|
|
410
|
-
* @param options.limit - Maximum number of results (default: 10)
|
|
411
|
-
* @param options.offset - Pagination offset (default: 0)
|
|
412
|
-
* @returns Paginated list of upload sessions
|
|
413
307
|
*/
|
|
414
308
|
async listUploadSessions(options) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return this.request(`/user/projects/${options.projectId}/upload-sessions`, {
|
|
309
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
310
|
+
return this.request(`/user/projects/${projectId}/upload-sessions`, {
|
|
418
311
|
...options.commitSha && { commitSha: options.commitSha },
|
|
419
312
|
...options.branch && { branch: options.branch },
|
|
420
313
|
...options.limit && { limit: options.limit },
|
|
@@ -423,27 +316,133 @@ var GafferApiClient = class GafferApiClient {
|
|
|
423
316
|
}
|
|
424
317
|
/**
|
|
425
318
|
* Get upload session detail with linked results
|
|
426
|
-
*
|
|
427
|
-
* @param options - Query options
|
|
428
|
-
* @param options.projectId - The project ID (required)
|
|
429
|
-
* @param options.sessionId - The upload session ID (required)
|
|
430
|
-
* @returns Upload session details with linked test runs and coverage reports
|
|
431
319
|
*/
|
|
432
320
|
async getUploadSessionDetail(options) {
|
|
433
|
-
if (!this.isUserToken()) throw new Error("getUploadSessionDetail requires a user API Key (gaf_).");
|
|
434
|
-
if (!options.projectId) throw new Error("projectId is required");
|
|
435
321
|
if (!options.sessionId) throw new Error("sessionId is required");
|
|
436
|
-
|
|
322
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
323
|
+
return this.request(`/user/projects/${projectId}/upload-sessions/${options.sessionId}`);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Search across test failures by error message, stack trace, or test name
|
|
327
|
+
*/
|
|
328
|
+
async searchFailures(options) {
|
|
329
|
+
if (!options.query) throw new Error("query is required");
|
|
330
|
+
const projectId = await this.resolveProjectId(options.projectId);
|
|
331
|
+
return this.request(`/user/projects/${projectId}/search-failures`, {
|
|
332
|
+
query: options.query,
|
|
333
|
+
...options.searchIn && { searchIn: options.searchIn },
|
|
334
|
+
...options.days && { days: options.days },
|
|
335
|
+
...options.branch && { branch: options.branch },
|
|
336
|
+
...options.limit && { limit: options.limit }
|
|
337
|
+
});
|
|
437
338
|
}
|
|
438
339
|
};
|
|
439
340
|
|
|
341
|
+
//#endregion
|
|
342
|
+
//#region src/codemode/executor.ts
|
|
343
|
+
/**
|
|
344
|
+
* Patterns blocked from user code as a basic guard.
|
|
345
|
+
* This is NOT a sandbox — determined users can bypass these checks via
|
|
346
|
+
* string concatenation, bracket notation, or constructor access.
|
|
347
|
+
* The real security boundary is the API layer (read-only, user's own token).
|
|
348
|
+
*/
|
|
349
|
+
const BLOCKED_PATTERNS = [
|
|
350
|
+
"globalThis",
|
|
351
|
+
"process",
|
|
352
|
+
"require(",
|
|
353
|
+
"import ",
|
|
354
|
+
"import(",
|
|
355
|
+
"eval(",
|
|
356
|
+
"new Function",
|
|
357
|
+
"Function(",
|
|
358
|
+
"Buffer",
|
|
359
|
+
"__dirname",
|
|
360
|
+
"__filename",
|
|
361
|
+
".constructor",
|
|
362
|
+
"Reflect"
|
|
363
|
+
];
|
|
364
|
+
/** Maximum API calls per execution */
|
|
365
|
+
const MAX_API_CALLS = 20;
|
|
366
|
+
/** Execution timeout in milliseconds */
|
|
367
|
+
const EXECUTION_TIMEOUT_MS = 3e4;
|
|
368
|
+
/**
|
|
369
|
+
* Validate code doesn't contain blocked patterns.
|
|
370
|
+
* Returns the first blocked pattern found, or null if safe.
|
|
371
|
+
*/
|
|
372
|
+
function validateCode(code) {
|
|
373
|
+
for (const pattern of BLOCKED_PATTERNS) if (code.includes(pattern)) return pattern;
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Execute user-provided JavaScript code with access to the codemode namespace.
|
|
378
|
+
*
|
|
379
|
+
* Uses AsyncFunction constructor to run code in an async context.
|
|
380
|
+
* The namespace object is injected as `codemode` — all API calls go through it.
|
|
381
|
+
*
|
|
382
|
+
* Security notes:
|
|
383
|
+
* - Not a true sandbox (no vm2/isolated-vm) — same pattern as Cloudflare code mode
|
|
384
|
+
* - Blocked patterns prevent obvious escape hatches
|
|
385
|
+
* - API call counting prevents resource exhaustion
|
|
386
|
+
* - Timeout prevents infinite loops
|
|
387
|
+
* - The real security boundary is the API itself (read-only, user's own token)
|
|
388
|
+
*/
|
|
389
|
+
async function executeCode(code, namespace) {
|
|
390
|
+
const blocked = validateCode(code);
|
|
391
|
+
if (blocked) throw new Error(`Blocked pattern detected: "${blocked}". Code must not use ${blocked}.`);
|
|
392
|
+
const logs = [];
|
|
393
|
+
const start = Date.now();
|
|
394
|
+
const serialize = (a) => {
|
|
395
|
+
if (typeof a !== "object" || a === null) return String(a);
|
|
396
|
+
try {
|
|
397
|
+
return JSON.stringify(a);
|
|
398
|
+
} catch {
|
|
399
|
+
return String(a);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const safeConsole = {
|
|
403
|
+
log: (...args) => logs.push(args.map(serialize).join(" ")),
|
|
404
|
+
warn: (...args) => logs.push(`[warn] ${args.map(serialize).join(" ")}`),
|
|
405
|
+
error: (...args) => logs.push(`[error] ${args.map(serialize).join(" ")}`)
|
|
406
|
+
};
|
|
407
|
+
let callCount = 0;
|
|
408
|
+
const countedNamespace = {};
|
|
409
|
+
for (const [name, fn] of Object.entries(namespace)) countedNamespace[name] = async (...args) => {
|
|
410
|
+
callCount++;
|
|
411
|
+
if (callCount > MAX_API_CALLS) throw new Error(`API call limit exceeded (max ${MAX_API_CALLS} calls per execution)`);
|
|
412
|
+
return fn(...args);
|
|
413
|
+
};
|
|
414
|
+
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
|
|
415
|
+
const fn = new AsyncFunction("codemode", "console", code);
|
|
416
|
+
let timeoutId;
|
|
417
|
+
const resultPromise = fn(countedNamespace, safeConsole);
|
|
418
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
419
|
+
timeoutId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Execution timed out after ${EXECUTION_TIMEOUT_MS}ms`)), EXECUTION_TIMEOUT_MS);
|
|
420
|
+
});
|
|
421
|
+
try {
|
|
422
|
+
return {
|
|
423
|
+
result: await Promise.race([resultPromise, timeoutPromise]),
|
|
424
|
+
logs,
|
|
425
|
+
durationMs: Date.now() - start
|
|
426
|
+
};
|
|
427
|
+
} catch (error) {
|
|
428
|
+
const durationMs = Date.now() - start;
|
|
429
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
430
|
+
const enrichedError = new Error(message, { cause: error });
|
|
431
|
+
enrichedError.logs = logs;
|
|
432
|
+
enrichedError.durationMs = durationMs;
|
|
433
|
+
throw enrichedError;
|
|
434
|
+
} finally {
|
|
435
|
+
clearTimeout(timeoutId);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
440
439
|
//#endregion
|
|
441
440
|
//#region src/tools/compare-test-metrics.ts
|
|
442
441
|
/**
|
|
443
442
|
* Input schema for compare_test_metrics tool
|
|
444
443
|
*/
|
|
445
444
|
const compareTestMetricsInputSchema = {
|
|
446
|
-
projectId: z.string().describe("Project ID.
|
|
445
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
447
446
|
testName: z.string().describe("The test name to compare. Can be the short name or full name including describe blocks."),
|
|
448
447
|
beforeCommit: z.string().optional().describe("Commit SHA for the \"before\" measurement. Use with afterCommit."),
|
|
449
448
|
afterCommit: z.string().optional().describe("Commit SHA for the \"after\" measurement. Use with beforeCommit."),
|
|
@@ -518,7 +517,7 @@ const compareTestMetricsMetadata = {
|
|
|
518
517
|
Useful for measuring the impact of code changes on test performance or reliability.
|
|
519
518
|
|
|
520
519
|
Parameters:
|
|
521
|
-
- projectId (
|
|
520
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
522
521
|
- testName (required): The test name to compare (short name or full name)
|
|
523
522
|
- Option 1 - Compare by commit:
|
|
524
523
|
- beforeCommit: Commit SHA for "before" measurement
|
|
@@ -553,7 +552,7 @@ Tip: Use get_test_history first to find the commit SHAs or test run IDs you want
|
|
|
553
552
|
* Input schema for find_uncovered_failure_areas tool
|
|
554
553
|
*/
|
|
555
554
|
const findUncoveredFailureAreasInputSchema = {
|
|
556
|
-
projectId: z.string().describe("Project ID.
|
|
555
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
557
556
|
days: z.number().int().min(1).max(365).optional().describe("Number of days to analyze for test failures (default: 30)"),
|
|
558
557
|
coverageThreshold: z.number().min(0).max(100).optional().describe("Include files with coverage below this percentage (default: 80)")
|
|
559
558
|
};
|
|
@@ -601,7 +600,7 @@ areas in your codebase that need attention. Files are ranked by a "risk score"
|
|
|
601
600
|
calculated as: (100 - coverage%) × failureCount.
|
|
602
601
|
|
|
603
602
|
Parameters:
|
|
604
|
-
- projectId:
|
|
603
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
605
604
|
- days: Analysis period for test failures (default: 30)
|
|
606
605
|
- coverageThreshold: Include files below this coverage % (default: 80)
|
|
607
606
|
|
|
@@ -618,7 +617,7 @@ Use this to prioritize which parts of your codebase need better test coverage.`
|
|
|
618
617
|
* Input schema for get_coverage_for_file tool
|
|
619
618
|
*/
|
|
620
619
|
const getCoverageForFileInputSchema = {
|
|
621
|
-
projectId: z.string().describe("Project ID.
|
|
620
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
622
621
|
filePath: z.string().describe("File path to get coverage for. Can be exact path or partial match.")
|
|
623
622
|
};
|
|
624
623
|
/**
|
|
@@ -675,7 +674,7 @@ const getCoverageForFileMetadata = {
|
|
|
675
674
|
description: `Get coverage metrics for a specific file or files matching a path pattern.
|
|
676
675
|
|
|
677
676
|
Parameters:
|
|
678
|
-
- projectId:
|
|
677
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
679
678
|
- filePath: File path to search for (exact or partial match)
|
|
680
679
|
|
|
681
680
|
Returns:
|
|
@@ -701,7 +700,7 @@ Prioritize: high utilization + low coverage = highest impact.`
|
|
|
701
700
|
* Input schema for get_coverage_summary tool
|
|
702
701
|
*/
|
|
703
702
|
const getCoverageSummaryInputSchema = {
|
|
704
|
-
projectId: z.string().describe("Project ID.
|
|
703
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
705
704
|
days: z.number().int().min(1).max(365).optional().describe("Number of days to analyze for trends (default: 30)")
|
|
706
705
|
};
|
|
707
706
|
/**
|
|
@@ -776,7 +775,7 @@ high-value targets in critical code paths rather than just the files with lowest
|
|
|
776
775
|
* Input schema for get_failure_clusters tool
|
|
777
776
|
*/
|
|
778
777
|
const getFailureClustersInputSchema = {
|
|
779
|
-
projectId: z.string().describe("Project ID.
|
|
778
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
780
779
|
testRunId: z.string().describe("Test run ID. Use list_test_runs to find test run IDs.")
|
|
781
780
|
};
|
|
782
781
|
/**
|
|
@@ -814,7 +813,7 @@ const getFailureClustersMetadata = {
|
|
|
814
813
|
description: `Group failed tests by root cause using error message similarity.
|
|
815
814
|
|
|
816
815
|
Parameters:
|
|
817
|
-
- projectId (
|
|
816
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
818
817
|
- testRunId (required): The test run ID to analyze
|
|
819
818
|
|
|
820
819
|
Returns:
|
|
@@ -979,7 +978,7 @@ Use this to understand the current state of your test suite.`
|
|
|
979
978
|
* Input schema for get_report_browser_url tool
|
|
980
979
|
*/
|
|
981
980
|
const getReportBrowserUrlInputSchema = {
|
|
982
|
-
projectId: z.string().describe("Project ID.
|
|
981
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
983
982
|
testRunId: z.string().describe("The test run ID to get the report URL for. Use list_test_runs to find test run IDs."),
|
|
984
983
|
filename: z.string().optional().describe("Specific file to open (default: index.html or first HTML file)")
|
|
985
984
|
};
|
|
@@ -1022,7 +1021,7 @@ Returns a signed URL that can be opened directly in a browser without requiring
|
|
|
1022
1021
|
the user to log in. The URL expires after 30 minutes for security.
|
|
1023
1022
|
|
|
1024
1023
|
Parameters:
|
|
1025
|
-
- projectId:
|
|
1024
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1026
1025
|
- testRunId: The test run to view (required)
|
|
1027
1026
|
- filename: Specific file to open (optional, defaults to index.html)
|
|
1028
1027
|
|
|
@@ -1125,7 +1124,7 @@ Use cases:
|
|
|
1125
1124
|
* Input schema for get_slowest_tests tool
|
|
1126
1125
|
*/
|
|
1127
1126
|
const getSlowestTestsInputSchema = {
|
|
1128
|
-
projectId: z.string().describe("Project ID.
|
|
1127
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
1129
1128
|
days: z.number().int().min(1).max(365).optional().describe("Analysis period in days (default: 30)"),
|
|
1130
1129
|
limit: z.number().int().min(1).max(100).optional().describe("Maximum number of tests to return (default: 20)"),
|
|
1131
1130
|
framework: z.string().optional().describe("Filter by test framework (e.g., \"playwright\", \"vitest\", \"jest\")"),
|
|
@@ -1184,7 +1183,7 @@ const getSlowestTestsMetadata = {
|
|
|
1184
1183
|
description: `Get the slowest tests in a project, sorted by P95 duration.
|
|
1185
1184
|
|
|
1186
1185
|
Parameters:
|
|
1187
|
-
- projectId (
|
|
1186
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1188
1187
|
- days (optional): Analysis period in days (default: 30, max: 365)
|
|
1189
1188
|
- limit (optional): Max tests to return (default: 20, max: 100)
|
|
1190
1189
|
- framework (optional): Filter by framework (e.g., "playwright", "vitest")
|
|
@@ -1302,7 +1301,7 @@ Use this to investigate flaky tests or understand test stability.`
|
|
|
1302
1301
|
*/
|
|
1303
1302
|
const getTestRunDetailsInputSchema = {
|
|
1304
1303
|
testRunId: z.string().describe("The test run ID to get details for. Use list_test_runs to find test run IDs."),
|
|
1305
|
-
projectId: z.string().describe("Project ID.
|
|
1304
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
1306
1305
|
status: z.enum([
|
|
1307
1306
|
"passed",
|
|
1308
1307
|
"failed",
|
|
@@ -1378,7 +1377,7 @@ const getTestRunDetailsMetadata = {
|
|
|
1378
1377
|
|
|
1379
1378
|
Parameters:
|
|
1380
1379
|
- testRunId (required): The test run ID to get details for
|
|
1381
|
-
- projectId (
|
|
1380
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1382
1381
|
- status (optional): Filter by test status: "passed", "failed", or "skipped"
|
|
1383
1382
|
- limit (optional): Max tests to return (default: 100, max: 500)
|
|
1384
1383
|
- offset (optional): Pagination offset (default: 0)
|
|
@@ -1416,7 +1415,7 @@ use get_test_history, get_flaky_tests, or get_slowest_tests instead.`
|
|
|
1416
1415
|
* Input schema for get_untested_files tool
|
|
1417
1416
|
*/
|
|
1418
1417
|
const getUntestedFilesInputSchema = {
|
|
1419
|
-
projectId: z.string().describe("Project ID.
|
|
1418
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
1420
1419
|
maxCoverage: z.number().min(0).max(100).optional().describe("Maximum coverage percentage to include (default: 10 for \"untested\")"),
|
|
1421
1420
|
limit: z.number().int().min(1).max(100).optional().describe("Maximum number of files to return (default: 20)")
|
|
1422
1421
|
};
|
|
@@ -1483,7 +1482,7 @@ Returns files sorted by coverage percentage (lowest first), filtered
|
|
|
1483
1482
|
to only include files below a coverage threshold.
|
|
1484
1483
|
|
|
1485
1484
|
Parameters:
|
|
1486
|
-
- projectId:
|
|
1485
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1487
1486
|
- maxCoverage: Include files with coverage at or below this % (default: 10)
|
|
1488
1487
|
- limit: Maximum number of files to return (default: 20, max: 100)
|
|
1489
1488
|
|
|
@@ -1507,7 +1506,7 @@ for those specific paths.`
|
|
|
1507
1506
|
* Input schema for get_upload_status tool
|
|
1508
1507
|
*/
|
|
1509
1508
|
const getUploadStatusInputSchema = {
|
|
1510
|
-
projectId: z.string().describe("Project ID.
|
|
1509
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
1511
1510
|
sessionId: z.string().optional().describe("Specific upload session ID. If provided, returns detailed status for that session. Otherwise, lists recent sessions."),
|
|
1512
1511
|
commitSha: z.string().optional().describe("Filter sessions by commit SHA. Useful for checking if results for a specific commit are ready."),
|
|
1513
1512
|
branch: z.string().optional().describe("Filter sessions by branch name.")
|
|
@@ -1581,7 +1580,7 @@ const getUploadStatusMetadata = {
|
|
|
1581
1580
|
Use this tool to answer "are my test results ready?" after pushing code.
|
|
1582
1581
|
|
|
1583
1582
|
Parameters:
|
|
1584
|
-
- projectId (
|
|
1583
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1585
1584
|
- sessionId (optional): Specific upload session ID for detailed status
|
|
1586
1585
|
- commitSha (optional): Filter by commit SHA to find uploads for a specific commit
|
|
1587
1586
|
- branch (optional): Filter by branch name
|
|
@@ -1612,63 +1611,6 @@ Returns (detail mode):
|
|
|
1612
1611
|
- coverageReports: Linked coverage report summaries (id, format)`
|
|
1613
1612
|
};
|
|
1614
1613
|
|
|
1615
|
-
//#endregion
|
|
1616
|
-
//#region src/tools/list-projects.ts
|
|
1617
|
-
/**
|
|
1618
|
-
* Input schema for list_projects tool
|
|
1619
|
-
*/
|
|
1620
|
-
const listProjectsInputSchema = {
|
|
1621
|
-
organizationId: z.string().optional().describe("Filter by organization ID (optional)"),
|
|
1622
|
-
limit: z.number().int().min(1).max(100).optional().describe("Maximum number of projects to return (default: 50)")
|
|
1623
|
-
};
|
|
1624
|
-
/**
|
|
1625
|
-
* Output schema for list_projects tool
|
|
1626
|
-
*/
|
|
1627
|
-
const listProjectsOutputSchema = {
|
|
1628
|
-
projects: z.array(z.object({
|
|
1629
|
-
id: z.string(),
|
|
1630
|
-
name: z.string(),
|
|
1631
|
-
description: z.string().nullable().optional(),
|
|
1632
|
-
organization: z.object({
|
|
1633
|
-
id: z.string(),
|
|
1634
|
-
name: z.string(),
|
|
1635
|
-
slug: z.string()
|
|
1636
|
-
})
|
|
1637
|
-
})),
|
|
1638
|
-
total: z.number()
|
|
1639
|
-
};
|
|
1640
|
-
/**
|
|
1641
|
-
* Execute list_projects tool
|
|
1642
|
-
*/
|
|
1643
|
-
async function executeListProjects(client, input) {
|
|
1644
|
-
const response = await client.listProjects({
|
|
1645
|
-
organizationId: input.organizationId,
|
|
1646
|
-
limit: input.limit
|
|
1647
|
-
});
|
|
1648
|
-
return {
|
|
1649
|
-
projects: response.projects.map((p) => ({
|
|
1650
|
-
id: p.id,
|
|
1651
|
-
name: p.name,
|
|
1652
|
-
description: p.description,
|
|
1653
|
-
organization: p.organization
|
|
1654
|
-
})),
|
|
1655
|
-
total: response.pagination.total
|
|
1656
|
-
};
|
|
1657
|
-
}
|
|
1658
|
-
/**
|
|
1659
|
-
* Tool metadata
|
|
1660
|
-
*/
|
|
1661
|
-
const listProjectsMetadata = {
|
|
1662
|
-
name: "list_projects",
|
|
1663
|
-
title: "List Projects",
|
|
1664
|
-
description: `List all projects you have access to.
|
|
1665
|
-
|
|
1666
|
-
Returns a list of projects with their IDs, names, and organization info.
|
|
1667
|
-
Use this to find project IDs for other tools like get_project_health.
|
|
1668
|
-
|
|
1669
|
-
Requires a user API Key (gaf_). Get one from Account Settings in the Gaffer dashboard.`
|
|
1670
|
-
};
|
|
1671
|
-
|
|
1672
1614
|
//#endregion
|
|
1673
1615
|
//#region src/tools/list-test-runs.ts
|
|
1674
1616
|
/**
|
|
@@ -1756,6 +1698,508 @@ Use cases:
|
|
|
1756
1698
|
- "What's the status of tests on my feature branch?"`
|
|
1757
1699
|
};
|
|
1758
1700
|
|
|
1701
|
+
//#endregion
|
|
1702
|
+
//#region src/tools/search-failures.ts
|
|
1703
|
+
/**
|
|
1704
|
+
* Input schema for search_failures tool
|
|
1705
|
+
*/
|
|
1706
|
+
const searchFailuresInputSchema = {
|
|
1707
|
+
projectId: z.string().optional().describe("Project ID. Required for user API keys (gaf_). Not needed for project tokens — omit and it resolves automatically."),
|
|
1708
|
+
query: z.string().min(1).describe("Search query to match against failure messages, error stacks, or test names."),
|
|
1709
|
+
searchIn: z.enum([
|
|
1710
|
+
"errors",
|
|
1711
|
+
"names",
|
|
1712
|
+
"all"
|
|
1713
|
+
]).optional().describe("Where to search: \"errors\" (error messages and stacks), \"names\" (test names), or \"all\" (default: \"all\")."),
|
|
1714
|
+
days: z.number().int().min(1).max(365).optional().describe("Number of days to search back (default: 30)"),
|
|
1715
|
+
branch: z.string().optional().describe("Filter to a specific branch"),
|
|
1716
|
+
limit: z.number().int().min(1).max(100).optional().describe("Maximum number of matches to return (default: 20)")
|
|
1717
|
+
};
|
|
1718
|
+
/**
|
|
1719
|
+
* Output schema for search_failures tool
|
|
1720
|
+
*/
|
|
1721
|
+
const searchFailuresOutputSchema = {
|
|
1722
|
+
matches: z.array(z.object({
|
|
1723
|
+
testName: z.string(),
|
|
1724
|
+
testRunId: z.string(),
|
|
1725
|
+
branch: z.string().nullable(),
|
|
1726
|
+
commitSha: z.string().nullable(),
|
|
1727
|
+
errorMessage: z.string().nullable(),
|
|
1728
|
+
errorStack: z.string().nullable(),
|
|
1729
|
+
createdAt: z.string()
|
|
1730
|
+
})),
|
|
1731
|
+
total: z.number(),
|
|
1732
|
+
query: z.string()
|
|
1733
|
+
};
|
|
1734
|
+
/**
|
|
1735
|
+
* Execute search_failures tool
|
|
1736
|
+
*/
|
|
1737
|
+
async function executeSearchFailures(client, input) {
|
|
1738
|
+
return client.searchFailures(input);
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Tool metadata
|
|
1742
|
+
*/
|
|
1743
|
+
const searchFailuresMetadata = {
|
|
1744
|
+
name: "search_failures",
|
|
1745
|
+
title: "Search Failures",
|
|
1746
|
+
description: `Search across test failures by error message, stack trace, or test name.
|
|
1747
|
+
|
|
1748
|
+
Use this to find specific failures across test runs — like grep for your test history.
|
|
1749
|
+
|
|
1750
|
+
Examples:
|
|
1751
|
+
- "TypeError: Cannot read properties of undefined" → find all occurrences of this error
|
|
1752
|
+
- "timeout" → find timeout-related failures
|
|
1753
|
+
- "auth" with searchIn="names" → find failing auth tests
|
|
1754
|
+
|
|
1755
|
+
Returns matching failures with test run context (branch, commit, timestamp) for investigation.`
|
|
1756
|
+
};
|
|
1757
|
+
|
|
1758
|
+
//#endregion
|
|
1759
|
+
//#region src/codemode/register-tools.ts
|
|
1760
|
+
const TOOLS = [
|
|
1761
|
+
{
|
|
1762
|
+
metadata: getProjectHealthMetadata,
|
|
1763
|
+
inputSchema: getProjectHealthInputSchema,
|
|
1764
|
+
execute: executeGetProjectHealth,
|
|
1765
|
+
category: "health",
|
|
1766
|
+
keywords: [
|
|
1767
|
+
"health",
|
|
1768
|
+
"score",
|
|
1769
|
+
"pass rate",
|
|
1770
|
+
"trend",
|
|
1771
|
+
"overview"
|
|
1772
|
+
]
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
metadata: getTestHistoryMetadata,
|
|
1776
|
+
inputSchema: getTestHistoryInputSchema,
|
|
1777
|
+
execute: executeGetTestHistory,
|
|
1778
|
+
category: "testing",
|
|
1779
|
+
keywords: [
|
|
1780
|
+
"history",
|
|
1781
|
+
"pass",
|
|
1782
|
+
"fail",
|
|
1783
|
+
"stability",
|
|
1784
|
+
"regression"
|
|
1785
|
+
]
|
|
1786
|
+
},
|
|
1787
|
+
{
|
|
1788
|
+
metadata: getFlakyTestsMetadata,
|
|
1789
|
+
inputSchema: getFlakyTestsInputSchema,
|
|
1790
|
+
execute: executeGetFlakyTests,
|
|
1791
|
+
category: "testing",
|
|
1792
|
+
keywords: [
|
|
1793
|
+
"flaky",
|
|
1794
|
+
"flip",
|
|
1795
|
+
"inconsistent",
|
|
1796
|
+
"non-deterministic"
|
|
1797
|
+
]
|
|
1798
|
+
},
|
|
1799
|
+
{
|
|
1800
|
+
metadata: listTestRunsMetadata,
|
|
1801
|
+
inputSchema: listTestRunsInputSchema,
|
|
1802
|
+
execute: executeListTestRuns,
|
|
1803
|
+
category: "testing",
|
|
1804
|
+
keywords: [
|
|
1805
|
+
"runs",
|
|
1806
|
+
"list",
|
|
1807
|
+
"commit",
|
|
1808
|
+
"branch",
|
|
1809
|
+
"recent"
|
|
1810
|
+
]
|
|
1811
|
+
},
|
|
1812
|
+
{
|
|
1813
|
+
metadata: getReportMetadata,
|
|
1814
|
+
inputSchema: getReportInputSchema,
|
|
1815
|
+
execute: executeGetReport,
|
|
1816
|
+
category: "reports",
|
|
1817
|
+
keywords: [
|
|
1818
|
+
"report",
|
|
1819
|
+
"files",
|
|
1820
|
+
"download",
|
|
1821
|
+
"artifacts"
|
|
1822
|
+
]
|
|
1823
|
+
},
|
|
1824
|
+
{
|
|
1825
|
+
metadata: getSlowestTestsMetadata,
|
|
1826
|
+
inputSchema: getSlowestTestsInputSchema,
|
|
1827
|
+
execute: executeGetSlowestTests,
|
|
1828
|
+
category: "testing",
|
|
1829
|
+
keywords: [
|
|
1830
|
+
"slow",
|
|
1831
|
+
"performance",
|
|
1832
|
+
"duration",
|
|
1833
|
+
"p95",
|
|
1834
|
+
"bottleneck"
|
|
1835
|
+
]
|
|
1836
|
+
},
|
|
1837
|
+
{
|
|
1838
|
+
metadata: getTestRunDetailsMetadata,
|
|
1839
|
+
inputSchema: getTestRunDetailsInputSchema,
|
|
1840
|
+
execute: executeGetTestRunDetails,
|
|
1841
|
+
category: "testing",
|
|
1842
|
+
keywords: [
|
|
1843
|
+
"details",
|
|
1844
|
+
"results",
|
|
1845
|
+
"errors",
|
|
1846
|
+
"stack traces",
|
|
1847
|
+
"test cases"
|
|
1848
|
+
]
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
metadata: getFailureClustersMetadata,
|
|
1852
|
+
inputSchema: getFailureClustersInputSchema,
|
|
1853
|
+
execute: executeGetFailureClusters,
|
|
1854
|
+
category: "testing",
|
|
1855
|
+
keywords: [
|
|
1856
|
+
"failure",
|
|
1857
|
+
"clusters",
|
|
1858
|
+
"root cause",
|
|
1859
|
+
"error grouping"
|
|
1860
|
+
]
|
|
1861
|
+
},
|
|
1862
|
+
{
|
|
1863
|
+
metadata: compareTestMetricsMetadata,
|
|
1864
|
+
inputSchema: compareTestMetricsInputSchema,
|
|
1865
|
+
execute: executeCompareTestMetrics,
|
|
1866
|
+
category: "testing",
|
|
1867
|
+
keywords: [
|
|
1868
|
+
"compare",
|
|
1869
|
+
"before",
|
|
1870
|
+
"after",
|
|
1871
|
+
"regression",
|
|
1872
|
+
"delta"
|
|
1873
|
+
]
|
|
1874
|
+
},
|
|
1875
|
+
{
|
|
1876
|
+
metadata: getCoverageSummaryMetadata,
|
|
1877
|
+
inputSchema: getCoverageSummaryInputSchema,
|
|
1878
|
+
execute: executeGetCoverageSummary,
|
|
1879
|
+
category: "coverage",
|
|
1880
|
+
keywords: [
|
|
1881
|
+
"coverage",
|
|
1882
|
+
"summary",
|
|
1883
|
+
"lines",
|
|
1884
|
+
"branches",
|
|
1885
|
+
"functions"
|
|
1886
|
+
]
|
|
1887
|
+
},
|
|
1888
|
+
{
|
|
1889
|
+
metadata: getCoverageForFileMetadata,
|
|
1890
|
+
inputSchema: getCoverageForFileInputSchema,
|
|
1891
|
+
execute: executeGetCoverageForFile,
|
|
1892
|
+
category: "coverage",
|
|
1893
|
+
keywords: [
|
|
1894
|
+
"coverage",
|
|
1895
|
+
"file",
|
|
1896
|
+
"path",
|
|
1897
|
+
"lines",
|
|
1898
|
+
"branches"
|
|
1899
|
+
]
|
|
1900
|
+
},
|
|
1901
|
+
{
|
|
1902
|
+
metadata: findUncoveredFailureAreasMetadata,
|
|
1903
|
+
inputSchema: findUncoveredFailureAreasInputSchema,
|
|
1904
|
+
execute: executeFindUncoveredFailureAreas,
|
|
1905
|
+
category: "coverage",
|
|
1906
|
+
keywords: [
|
|
1907
|
+
"risk",
|
|
1908
|
+
"uncovered",
|
|
1909
|
+
"failures",
|
|
1910
|
+
"low coverage"
|
|
1911
|
+
]
|
|
1912
|
+
},
|
|
1913
|
+
{
|
|
1914
|
+
metadata: getUntestedFilesMetadata,
|
|
1915
|
+
inputSchema: getUntestedFilesInputSchema,
|
|
1916
|
+
execute: executeGetUntestedFiles,
|
|
1917
|
+
category: "coverage",
|
|
1918
|
+
keywords: [
|
|
1919
|
+
"untested",
|
|
1920
|
+
"zero coverage",
|
|
1921
|
+
"missing tests"
|
|
1922
|
+
]
|
|
1923
|
+
},
|
|
1924
|
+
{
|
|
1925
|
+
metadata: getReportBrowserUrlMetadata,
|
|
1926
|
+
inputSchema: getReportBrowserUrlInputSchema,
|
|
1927
|
+
execute: executeGetReportBrowserUrl,
|
|
1928
|
+
category: "reports",
|
|
1929
|
+
keywords: [
|
|
1930
|
+
"browser",
|
|
1931
|
+
"url",
|
|
1932
|
+
"view",
|
|
1933
|
+
"report",
|
|
1934
|
+
"signed"
|
|
1935
|
+
]
|
|
1936
|
+
},
|
|
1937
|
+
{
|
|
1938
|
+
metadata: getUploadStatusMetadata,
|
|
1939
|
+
inputSchema: getUploadStatusInputSchema,
|
|
1940
|
+
execute: executeGetUploadStatus,
|
|
1941
|
+
category: "uploads",
|
|
1942
|
+
keywords: [
|
|
1943
|
+
"upload",
|
|
1944
|
+
"status",
|
|
1945
|
+
"processing",
|
|
1946
|
+
"CI",
|
|
1947
|
+
"ready"
|
|
1948
|
+
]
|
|
1949
|
+
},
|
|
1950
|
+
{
|
|
1951
|
+
metadata: searchFailuresMetadata,
|
|
1952
|
+
inputSchema: searchFailuresInputSchema,
|
|
1953
|
+
execute: executeSearchFailures,
|
|
1954
|
+
category: "testing",
|
|
1955
|
+
keywords: [
|
|
1956
|
+
"search",
|
|
1957
|
+
"failure",
|
|
1958
|
+
"error message",
|
|
1959
|
+
"grep",
|
|
1960
|
+
"find"
|
|
1961
|
+
]
|
|
1962
|
+
}
|
|
1963
|
+
];
|
|
1964
|
+
/**
|
|
1965
|
+
* Register all tool functions in the codemode registry.
|
|
1966
|
+
*/
|
|
1967
|
+
function registerAllTools(registry) {
|
|
1968
|
+
for (const tool of TOOLS) registry.register({
|
|
1969
|
+
name: tool.metadata.name,
|
|
1970
|
+
description: tool.metadata.description,
|
|
1971
|
+
category: tool.category,
|
|
1972
|
+
keywords: tool.keywords,
|
|
1973
|
+
inputSchema: tool.inputSchema,
|
|
1974
|
+
execute: tool.execute
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
//#endregion
|
|
1979
|
+
//#region src/codemode/type-gen.ts
|
|
1980
|
+
/**
|
|
1981
|
+
* Convert a Zod schema to a TypeScript type string.
|
|
1982
|
+
* Handles the subset of Zod types used in our tool schemas.
|
|
1983
|
+
*/
|
|
1984
|
+
function zodToTs(schema) {
|
|
1985
|
+
if (schema instanceof z.ZodEffects) return zodToTs(schema.innerType());
|
|
1986
|
+
if (schema instanceof z.ZodOptional) return `${zodToTs(schema.unwrap())} | undefined`;
|
|
1987
|
+
if (schema instanceof z.ZodNullable) return `${zodToTs(schema.unwrap())} | null`;
|
|
1988
|
+
if (schema instanceof z.ZodDefault) return zodToTs(schema.removeDefault());
|
|
1989
|
+
if (schema instanceof z.ZodString) return "string";
|
|
1990
|
+
if (schema instanceof z.ZodNumber) return "number";
|
|
1991
|
+
if (schema instanceof z.ZodBoolean) return "boolean";
|
|
1992
|
+
if (schema instanceof z.ZodEnum) return schema.options.map((v) => `'${v}'`).join(" | ");
|
|
1993
|
+
if (schema instanceof z.ZodLiteral) {
|
|
1994
|
+
const val = schema.value;
|
|
1995
|
+
return typeof val === "string" ? `'${val}'` : String(val);
|
|
1996
|
+
}
|
|
1997
|
+
if (schema instanceof z.ZodArray) {
|
|
1998
|
+
const inner = zodToTs(schema.element);
|
|
1999
|
+
if (inner.includes("|")) return `(${inner})[]`;
|
|
2000
|
+
return `${inner}[]`;
|
|
2001
|
+
}
|
|
2002
|
+
if (schema instanceof z.ZodObject) {
|
|
2003
|
+
const shape = schema.shape;
|
|
2004
|
+
const entries = Object.entries(shape);
|
|
2005
|
+
if (entries.length === 0) return "{}";
|
|
2006
|
+
return `{ ${entries.map(([key, fieldSchema]) => formatField(key, fieldSchema)).join("; ")} }`;
|
|
2007
|
+
}
|
|
2008
|
+
if (schema instanceof z.ZodRecord) return `Record<string, ${zodToTs(schema.valueSchema)}>`;
|
|
2009
|
+
if (schema instanceof z.ZodUnion) return schema.options.map((o) => zodToTs(o)).join(" | ");
|
|
2010
|
+
console.error(`[gaffer-mcp] zodToTs: unhandled Zod type "${schema.constructor.name}", falling back to "unknown"`);
|
|
2011
|
+
return "unknown";
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Format a single field as "name?: type" (with ? for optionals, unwrapping the inner type).
|
|
2015
|
+
*/
|
|
2016
|
+
function formatField(key, schema) {
|
|
2017
|
+
const isOptional = schema instanceof z.ZodOptional;
|
|
2018
|
+
return `${key}${isOptional ? "?" : ""}: ${isOptional ? zodToTs(schema.unwrap()) : zodToTs(schema)}`;
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Generate a TypeScript function declaration from a function name,
|
|
2022
|
+
* description, and Zod input schema (object shape).
|
|
2023
|
+
*/
|
|
2024
|
+
function generateDeclaration(name, description, inputSchema) {
|
|
2025
|
+
const entries = Object.entries(inputSchema);
|
|
2026
|
+
if (entries.length === 0) return `/** ${description} */\n${name}(): Promise<any>`;
|
|
2027
|
+
return `/** ${description} */\n${name}(input: { ${entries.map(([key, schema]) => formatField(key, schema)).join("; ")} }): Promise<any>`;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
//#endregion
|
|
2031
|
+
//#region src/codemode/registry.ts
|
|
2032
|
+
/**
|
|
2033
|
+
* Registry of codemode functions.
|
|
2034
|
+
* Wraps existing tool execute functions with metadata for discovery and namespace building.
|
|
2035
|
+
*/
|
|
2036
|
+
var FunctionRegistry = class {
|
|
2037
|
+
entries = /* @__PURE__ */ new Map();
|
|
2038
|
+
/**
|
|
2039
|
+
* Register a function in the registry
|
|
2040
|
+
*/
|
|
2041
|
+
register(entry) {
|
|
2042
|
+
this.entries.set(entry.name, entry);
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Get all registered function entries
|
|
2046
|
+
*/
|
|
2047
|
+
getAll() {
|
|
2048
|
+
return Array.from(this.entries.values());
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Get a single entry by name
|
|
2052
|
+
*/
|
|
2053
|
+
get(name) {
|
|
2054
|
+
return this.entries.get(name);
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Build the namespace object that gets injected into the executor.
|
|
2058
|
+
* Each function validates input via Zod then calls the tool's execute function.
|
|
2059
|
+
*/
|
|
2060
|
+
buildNamespace(client) {
|
|
2061
|
+
const namespace = {};
|
|
2062
|
+
for (const entry of this.entries.values()) namespace[entry.name] = async (input = {}) => {
|
|
2063
|
+
const result = z.object(entry.inputSchema).safeParse(input);
|
|
2064
|
+
if (!result.success) {
|
|
2065
|
+
const issues = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
2066
|
+
throw new Error(`Invalid input for ${entry.name}: ${issues}`);
|
|
2067
|
+
}
|
|
2068
|
+
try {
|
|
2069
|
+
return await entry.execute(client, result.data);
|
|
2070
|
+
} catch (error) {
|
|
2071
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2072
|
+
throw new Error(`${entry.name} failed: ${message}`, { cause: error });
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
return namespace;
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Generate TypeScript declarations for all registered functions.
|
|
2079
|
+
* Used in the execute_code tool description so the LLM knows available functions.
|
|
2080
|
+
*/
|
|
2081
|
+
generateAllDeclarations() {
|
|
2082
|
+
return this.getAll().map((entry) => generateDeclaration(entry.name, entry.description, entry.inputSchema)).join("\n\n");
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Generate a declaration for a single function
|
|
2086
|
+
*/
|
|
2087
|
+
generateDeclaration(name) {
|
|
2088
|
+
const entry = this.entries.get(name);
|
|
2089
|
+
if (!entry) return null;
|
|
2090
|
+
return generateDeclaration(entry.name, entry.description, entry.inputSchema);
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* Search for functions matching a query.
|
|
2094
|
+
* Scores: name match (10) > category match (5) > keyword match (3) > description match (1)
|
|
2095
|
+
*/
|
|
2096
|
+
search(query) {
|
|
2097
|
+
if (!query.trim()) return this.listAll();
|
|
2098
|
+
const terms = query.toLowerCase().split(/\s+/);
|
|
2099
|
+
const scored = [];
|
|
2100
|
+
for (const entry of this.entries.values()) {
|
|
2101
|
+
let score = 0;
|
|
2102
|
+
const nameLower = entry.name.toLowerCase();
|
|
2103
|
+
const categoryLower = entry.category.toLowerCase();
|
|
2104
|
+
const descLower = entry.description.toLowerCase();
|
|
2105
|
+
const keywordsLower = entry.keywords.map((k) => k.toLowerCase());
|
|
2106
|
+
for (const term of terms) {
|
|
2107
|
+
if (nameLower.includes(term)) score += 10;
|
|
2108
|
+
if (categoryLower.includes(term)) score += 5;
|
|
2109
|
+
if (keywordsLower.some((k) => k.includes(term))) score += 3;
|
|
2110
|
+
if (descLower.includes(term)) score += 1;
|
|
2111
|
+
}
|
|
2112
|
+
if (score > 0) scored.push({
|
|
2113
|
+
entry,
|
|
2114
|
+
score
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
scored.sort((a, b) => b.score - a.score);
|
|
2118
|
+
return scored.map(({ entry }) => this.toSearchResult(entry));
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* List all functions (used when search query is empty)
|
|
2122
|
+
*/
|
|
2123
|
+
listAll() {
|
|
2124
|
+
return Array.from(this.entries.values()).map((entry) => this.toSearchResult(entry));
|
|
2125
|
+
}
|
|
2126
|
+
toSearchResult(entry) {
|
|
2127
|
+
return {
|
|
2128
|
+
name: entry.name,
|
|
2129
|
+
description: entry.description,
|
|
2130
|
+
category: entry.category,
|
|
2131
|
+
declaration: generateDeclaration(entry.name, entry.description, entry.inputSchema)
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
|
|
2136
|
+
//#endregion
|
|
2137
|
+
//#region src/codemode/search.ts
|
|
2138
|
+
const searchToolsInputSchema = { query: z.string().optional().describe("Search query to find relevant functions. Leave empty to list all available functions.") };
|
|
2139
|
+
/**
|
|
2140
|
+
* Execute search_tools: find functions by keyword matching
|
|
2141
|
+
*/
|
|
2142
|
+
function executeSearchTools(registry, input) {
|
|
2143
|
+
return { functions: input.query ? registry.search(input.query) : registry.listAll() };
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
//#endregion
|
|
2147
|
+
//#region src/tools/list-projects.ts
|
|
2148
|
+
/**
|
|
2149
|
+
* Input schema for list_projects tool
|
|
2150
|
+
*/
|
|
2151
|
+
const listProjectsInputSchema = {
|
|
2152
|
+
organizationId: z.string().optional().describe("Filter by organization ID (optional)"),
|
|
2153
|
+
limit: z.number().int().min(1).max(100).optional().describe("Maximum number of projects to return (default: 50)")
|
|
2154
|
+
};
|
|
2155
|
+
/**
|
|
2156
|
+
* Output schema for list_projects tool
|
|
2157
|
+
*/
|
|
2158
|
+
const listProjectsOutputSchema = {
|
|
2159
|
+
projects: z.array(z.object({
|
|
2160
|
+
id: z.string(),
|
|
2161
|
+
name: z.string(),
|
|
2162
|
+
description: z.string().nullable().optional(),
|
|
2163
|
+
organization: z.object({
|
|
2164
|
+
id: z.string(),
|
|
2165
|
+
name: z.string(),
|
|
2166
|
+
slug: z.string()
|
|
2167
|
+
})
|
|
2168
|
+
})),
|
|
2169
|
+
total: z.number()
|
|
2170
|
+
};
|
|
2171
|
+
/**
|
|
2172
|
+
* Execute list_projects tool
|
|
2173
|
+
*/
|
|
2174
|
+
async function executeListProjects(client, input) {
|
|
2175
|
+
const response = await client.listProjects({
|
|
2176
|
+
organizationId: input.organizationId,
|
|
2177
|
+
limit: input.limit
|
|
2178
|
+
});
|
|
2179
|
+
return {
|
|
2180
|
+
projects: response.projects.map((p) => ({
|
|
2181
|
+
id: p.id,
|
|
2182
|
+
name: p.name,
|
|
2183
|
+
description: p.description,
|
|
2184
|
+
organization: p.organization
|
|
2185
|
+
})),
|
|
2186
|
+
total: response.pagination.total
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* Tool metadata
|
|
2191
|
+
*/
|
|
2192
|
+
const listProjectsMetadata = {
|
|
2193
|
+
name: "list_projects",
|
|
2194
|
+
title: "List Projects",
|
|
2195
|
+
description: `List all projects you have access to.
|
|
2196
|
+
|
|
2197
|
+
Returns a list of projects with their IDs, names, and organization info.
|
|
2198
|
+
Use this to find project IDs for other tools like get_project_health.
|
|
2199
|
+
|
|
2200
|
+
Requires a user API Key (gaf_). Get one from Account Settings in the Gaffer dashboard.`
|
|
2201
|
+
};
|
|
2202
|
+
|
|
1759
2203
|
//#endregion
|
|
1760
2204
|
//#region src/index.ts
|
|
1761
2205
|
/**
|
|
@@ -1774,48 +2218,29 @@ function logError(toolName, error) {
|
|
|
1774
2218
|
*/
|
|
1775
2219
|
function handleToolError(toolName, error) {
|
|
1776
2220
|
logError(toolName, error);
|
|
2221
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2222
|
+
const logs = Array.isArray(error?.logs) ? error.logs : void 0;
|
|
2223
|
+
const durationMs = typeof error?.durationMs === "number" ? error.durationMs : void 0;
|
|
2224
|
+
let text = `Error: ${message}`;
|
|
2225
|
+
if (logs?.length) text += `\n\nCaptured logs:\n${logs.join("\n")}`;
|
|
2226
|
+
if (durationMs !== void 0) text += `\n\nDuration: ${durationMs}ms`;
|
|
1777
2227
|
return {
|
|
1778
2228
|
content: [{
|
|
1779
2229
|
type: "text",
|
|
1780
|
-
text
|
|
2230
|
+
text
|
|
1781
2231
|
}],
|
|
1782
2232
|
isError: true
|
|
1783
2233
|
};
|
|
1784
2234
|
}
|
|
1785
2235
|
/**
|
|
1786
|
-
*
|
|
1787
|
-
* Reduces boilerplate by handling error wrapping and response formatting.
|
|
1788
|
-
*/
|
|
1789
|
-
function registerTool(server, client, tool) {
|
|
1790
|
-
server.registerTool(tool.metadata.name, {
|
|
1791
|
-
title: tool.metadata.title,
|
|
1792
|
-
description: tool.metadata.description,
|
|
1793
|
-
inputSchema: tool.inputSchema,
|
|
1794
|
-
outputSchema: tool.outputSchema
|
|
1795
|
-
}, async (input) => {
|
|
1796
|
-
try {
|
|
1797
|
-
const output = await tool.execute(client, input);
|
|
1798
|
-
return {
|
|
1799
|
-
content: [{
|
|
1800
|
-
type: "text",
|
|
1801
|
-
text: JSON.stringify(output, null, 2)
|
|
1802
|
-
}],
|
|
1803
|
-
structuredContent: output
|
|
1804
|
-
};
|
|
1805
|
-
} catch (error) {
|
|
1806
|
-
return handleToolError(tool.metadata.name, error);
|
|
1807
|
-
}
|
|
1808
|
-
});
|
|
1809
|
-
}
|
|
1810
|
-
/**
|
|
1811
|
-
* Gaffer MCP Server
|
|
2236
|
+
* Gaffer MCP Server — Code Mode
|
|
1812
2237
|
*
|
|
1813
|
-
*
|
|
2238
|
+
* Instead of individual tools, exposes 3 tools:
|
|
2239
|
+
* - execute_code: Run JavaScript that calls Gaffer API functions
|
|
2240
|
+
* - search_tools: Find available functions by keyword
|
|
2241
|
+
* - list_projects: List projects (user tokens only)
|
|
1814
2242
|
*
|
|
1815
|
-
*
|
|
1816
|
-
* 1. User API Keys (gaf_) - Read-only access to all user's projects
|
|
1817
|
-
* Set via GAFFER_API_KEY environment variable
|
|
1818
|
-
* 2. Project Upload Tokens (gfr_) - Legacy, single project access
|
|
2243
|
+
* This follows Cloudflare's "code mode" pattern for MCP servers.
|
|
1819
2244
|
*/
|
|
1820
2245
|
async function main() {
|
|
1821
2246
|
if (!process.env.GAFFER_API_KEY) {
|
|
@@ -1832,169 +2257,153 @@ async function main() {
|
|
|
1832
2257
|
process.exit(1);
|
|
1833
2258
|
}
|
|
1834
2259
|
const client = GafferApiClient.fromEnv();
|
|
2260
|
+
const registry = new FunctionRegistry();
|
|
2261
|
+
registerAllTools(registry);
|
|
2262
|
+
const namespace = registry.buildNamespace(client);
|
|
2263
|
+
const declarations = registry.generateAllDeclarations();
|
|
1835
2264
|
const server = new McpServer({
|
|
1836
2265
|
name: "gaffer",
|
|
1837
|
-
version: "0.
|
|
1838
|
-
}, { instructions: `Gaffer provides test analytics and coverage data
|
|
2266
|
+
version: "0.7.0"
|
|
2267
|
+
}, { instructions: `Gaffer provides test analytics and coverage data. This server uses **code mode** — instead of individual tools, write JavaScript that calls functions on the \`codemode\` namespace.
|
|
1839
2268
|
|
|
1840
2269
|
## Authentication
|
|
1841
2270
|
|
|
1842
|
-
${client.isUserToken() ? "You have access to multiple projects. Use `list_projects` to find project IDs, then pass `projectId` to all
|
|
1843
|
-
|
|
1844
|
-
## Coverage Analysis Best Practices
|
|
1845
|
-
|
|
1846
|
-
When helping users improve test coverage, combine coverage data with codebase exploration:
|
|
1847
|
-
|
|
1848
|
-
1. **Understand code utilization first**: Before targeting files by coverage percentage, explore which code is critical:
|
|
1849
|
-
- Find entry points (route definitions, event handlers, exported functions)
|
|
1850
|
-
- Find heavily-imported files (files imported by many others are high-value targets)
|
|
1851
|
-
- Identify critical business logic (auth, payments, data mutations)
|
|
1852
|
-
|
|
1853
|
-
2. **Prioritize by impact**: Low coverage alone doesn't indicate priority. Consider:
|
|
1854
|
-
- High utilization + low coverage = highest priority
|
|
1855
|
-
- Large files with 0% coverage have bigger impact than small files
|
|
1856
|
-
- Use find_uncovered_failure_areas for files with both low coverage AND test failures
|
|
1857
|
-
|
|
1858
|
-
3. **Use path-based queries**: The get_untested_files tool may return many files of a certain type (e.g., UI components). For targeted analysis, use get_coverage_for_file with path prefixes to focus on specific areas of the codebase.
|
|
1859
|
-
|
|
1860
|
-
4. **Iterate**: Get baseline → identify targets → write tests → re-check coverage after CI uploads new results.
|
|
1861
|
-
|
|
1862
|
-
## Finding Invisible Files
|
|
1863
|
-
|
|
1864
|
-
Coverage tools can only report on files that were loaded during test execution. Some files have 0% coverage but don't appear in reports at all - these are "invisible" files that were never imported.
|
|
1865
|
-
|
|
1866
|
-
To find invisible files:
|
|
1867
|
-
1. Use get_coverage_for_file with a path prefix (e.g., "server/") to see what Gaffer tracks
|
|
1868
|
-
2. Use the local Glob tool to list all source files in that path
|
|
1869
|
-
3. Compare the lists - files in local but NOT in Gaffer are invisible
|
|
1870
|
-
4. These files need tests that actually import them
|
|
1871
|
-
|
|
1872
|
-
Example: If get_coverage_for_file("server/api") returns user.ts, auth.ts, but Glob finds user.ts, auth.ts, billing.ts - then billing.ts is invisible and needs tests that import it.
|
|
2271
|
+
${client.isUserToken() ? "You have a user API key with access to multiple projects. Use `list_projects` to find project IDs, then pass `projectId` to all codemode functions." : "Your token is scoped to a single project. Do NOT pass `projectId` — it resolves automatically."}
|
|
1873
2272
|
|
|
1874
|
-
##
|
|
2273
|
+
## How to Use
|
|
1875
2274
|
|
|
1876
|
-
|
|
2275
|
+
1. Use \`search_tools\` to find relevant functions (or check the execute_code description for all declarations)
|
|
2276
|
+
2. Use \`execute_code\` to run JavaScript that calls one or more functions
|
|
2277
|
+
3. Results are returned as JSON — you can chain multiple calls in a single execution
|
|
1877
2278
|
|
|
1878
|
-
|
|
1879
|
-
Skip flaky tests unless the user specifically wants to stabilize them.
|
|
2279
|
+
## Example
|
|
1880
2280
|
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
2281
|
+
\`\`\`javascript
|
|
2282
|
+
// Get project health, then check flaky tests if any exist
|
|
2283
|
+
const health = await codemode.get_project_health({ projectId: "proj_abc" });
|
|
2284
|
+
if (health.flakyTestCount > 0) {
|
|
2285
|
+
const flaky = await codemode.get_flaky_tests({ projectId: "proj_abc" });
|
|
2286
|
+
return { health, flaky };
|
|
2287
|
+
}
|
|
2288
|
+
return { health };
|
|
2289
|
+
\`\`\`
|
|
2290
|
+
|
|
2291
|
+
## Tips
|
|
2292
|
+
|
|
2293
|
+
- Use \`return\` to send data back — the return value becomes the tool result
|
|
2294
|
+
- Use \`console.log()\` for debug output (captured and returned alongside results)
|
|
2295
|
+
- You can make up to 20 API calls per execution
|
|
2296
|
+
- All functions are async — use \`await\`` });
|
|
2297
|
+
server.registerTool("execute_code", {
|
|
2298
|
+
title: "Execute Code",
|
|
2299
|
+
description: `Execute JavaScript code that calls Gaffer API functions via the \`codemode\` namespace.
|
|
2300
|
+
|
|
2301
|
+
Write async JavaScript — all functions are available as \`codemode.<function_name>(input)\`.
|
|
2302
|
+
Use \`return\` to send results back. Use \`console.log()\` for debug output.
|
|
2303
|
+
|
|
2304
|
+
## Available Functions
|
|
2305
|
+
|
|
2306
|
+
\`\`\`typescript
|
|
2307
|
+
${declarations}
|
|
2308
|
+
\`\`\`
|
|
2309
|
+
|
|
2310
|
+
## Examples
|
|
2311
|
+
|
|
2312
|
+
\`\`\`javascript
|
|
2313
|
+
// Single call
|
|
2314
|
+
const health = await codemode.get_project_health({ projectId: "proj_abc" });
|
|
2315
|
+
return health;
|
|
2316
|
+
\`\`\`
|
|
2317
|
+
|
|
2318
|
+
\`\`\`javascript
|
|
2319
|
+
// Multi-step: get flaky tests and check history for each
|
|
2320
|
+
const flaky = await codemode.get_flaky_tests({ projectId: "proj_abc", limit: 5 });
|
|
2321
|
+
const histories = [];
|
|
2322
|
+
for (const test of flaky.flakyTests) {
|
|
2323
|
+
const history = await codemode.get_test_history({ projectId: "proj_abc", testName: test.name, limit: 5 });
|
|
2324
|
+
histories.push({ test: test.name, score: test.flakinessScore, history: history.summary });
|
|
2325
|
+
}
|
|
2326
|
+
return { flaky: flaky.summary, details: histories };
|
|
2327
|
+
\`\`\`
|
|
2328
|
+
|
|
2329
|
+
\`\`\`javascript
|
|
2330
|
+
// Coverage analysis
|
|
2331
|
+
const summary = await codemode.get_coverage_summary({ projectId: "proj_abc" });
|
|
2332
|
+
const lowFiles = await codemode.get_coverage_for_file({ projectId: "proj_abc", maxCoverage: 50, limit: 10 });
|
|
2333
|
+
return { summary, lowCoverageFiles: lowFiles };
|
|
2334
|
+
\`\`\`
|
|
2335
|
+
|
|
2336
|
+
## Constraints
|
|
2337
|
+
|
|
2338
|
+
- Max 20 API calls per execution
|
|
2339
|
+
- 30s timeout
|
|
2340
|
+
- No access to Node.js globals (process, require, etc.)`,
|
|
2341
|
+
inputSchema: { code: z.string().describe("JavaScript code to execute. Use `codemode.<function>()` to call API functions. Use `return` for results.") }
|
|
2342
|
+
}, async (input) => {
|
|
2343
|
+
try {
|
|
2344
|
+
const result = await executeCode(input.code, namespace);
|
|
2345
|
+
const output = {};
|
|
2346
|
+
if (result.result !== void 0) output.result = result.result;
|
|
2347
|
+
if (result.logs.length > 0) output.logs = result.logs;
|
|
2348
|
+
output.durationMs = result.durationMs;
|
|
2349
|
+
let text;
|
|
2350
|
+
try {
|
|
2351
|
+
text = JSON.stringify(output, null, 2);
|
|
2352
|
+
} catch {
|
|
2353
|
+
text = JSON.stringify({
|
|
2354
|
+
error: "Result could not be serialized to JSON (possible circular reference). Use console.log() to inspect the result, or return a simpler object.",
|
|
2355
|
+
logs: result.logs.length > 0 ? result.logs : void 0,
|
|
2356
|
+
durationMs: result.durationMs
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
return { content: [{
|
|
2360
|
+
type: "text",
|
|
2361
|
+
text
|
|
2362
|
+
}] };
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
return handleToolError("execute_code", error);
|
|
2365
|
+
}
|
|
2366
|
+
});
|
|
2367
|
+
server.registerTool("search_tools", {
|
|
2368
|
+
title: "Search Tools",
|
|
2369
|
+
description: `Search for available Gaffer API functions by keyword.
|
|
1897
2370
|
|
|
1898
|
-
|
|
2371
|
+
Returns matching functions with their TypeScript declarations so you can use them with execute_code.
|
|
1899
2372
|
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
registerTool(server, client, {
|
|
1916
|
-
metadata: getFlakyTestsMetadata,
|
|
1917
|
-
inputSchema: getFlakyTestsInputSchema,
|
|
1918
|
-
outputSchema: getFlakyTestsOutputSchema,
|
|
1919
|
-
execute: executeGetFlakyTests
|
|
1920
|
-
});
|
|
1921
|
-
registerTool(server, client, {
|
|
1922
|
-
metadata: listTestRunsMetadata,
|
|
1923
|
-
inputSchema: listTestRunsInputSchema,
|
|
1924
|
-
outputSchema: listTestRunsOutputSchema,
|
|
1925
|
-
execute: executeListTestRuns
|
|
2373
|
+
Examples:
|
|
2374
|
+
- "coverage" → coverage-related functions
|
|
2375
|
+
- "flaky" → flaky test detection
|
|
2376
|
+
- "" (empty) → list all available functions`,
|
|
2377
|
+
inputSchema: searchToolsInputSchema
|
|
2378
|
+
}, async (input) => {
|
|
2379
|
+
try {
|
|
2380
|
+
const result = executeSearchTools(registry, input);
|
|
2381
|
+
return { content: [{
|
|
2382
|
+
type: "text",
|
|
2383
|
+
text: JSON.stringify(result, null, 2)
|
|
2384
|
+
}] };
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
return handleToolError("search_tools", error);
|
|
2387
|
+
}
|
|
1926
2388
|
});
|
|
1927
|
-
if (client.isUserToken()) registerTool(
|
|
1928
|
-
|
|
2389
|
+
if (client.isUserToken()) server.registerTool(listProjectsMetadata.name, {
|
|
2390
|
+
title: listProjectsMetadata.title,
|
|
2391
|
+
description: listProjectsMetadata.description,
|
|
1929
2392
|
inputSchema: listProjectsInputSchema,
|
|
1930
|
-
outputSchema: listProjectsOutputSchema
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
});
|
|
1945
|
-
registerTool(server, client, {
|
|
1946
|
-
metadata: getTestRunDetailsMetadata,
|
|
1947
|
-
inputSchema: getTestRunDetailsInputSchema,
|
|
1948
|
-
outputSchema: getTestRunDetailsOutputSchema,
|
|
1949
|
-
execute: executeGetTestRunDetails
|
|
1950
|
-
});
|
|
1951
|
-
registerTool(server, client, {
|
|
1952
|
-
metadata: getFailureClustersMetadata,
|
|
1953
|
-
inputSchema: getFailureClustersInputSchema,
|
|
1954
|
-
outputSchema: getFailureClustersOutputSchema,
|
|
1955
|
-
execute: executeGetFailureClusters
|
|
1956
|
-
});
|
|
1957
|
-
registerTool(server, client, {
|
|
1958
|
-
metadata: compareTestMetricsMetadata,
|
|
1959
|
-
inputSchema: compareTestMetricsInputSchema,
|
|
1960
|
-
outputSchema: compareTestMetricsOutputSchema,
|
|
1961
|
-
execute: executeCompareTestMetrics
|
|
1962
|
-
});
|
|
1963
|
-
registerTool(server, client, {
|
|
1964
|
-
metadata: getCoverageSummaryMetadata,
|
|
1965
|
-
inputSchema: getCoverageSummaryInputSchema,
|
|
1966
|
-
outputSchema: getCoverageSummaryOutputSchema,
|
|
1967
|
-
execute: executeGetCoverageSummary
|
|
1968
|
-
});
|
|
1969
|
-
registerTool(server, client, {
|
|
1970
|
-
metadata: getCoverageForFileMetadata,
|
|
1971
|
-
inputSchema: getCoverageForFileInputSchema,
|
|
1972
|
-
outputSchema: getCoverageForFileOutputSchema,
|
|
1973
|
-
execute: executeGetCoverageForFile
|
|
1974
|
-
});
|
|
1975
|
-
registerTool(server, client, {
|
|
1976
|
-
metadata: findUncoveredFailureAreasMetadata,
|
|
1977
|
-
inputSchema: findUncoveredFailureAreasInputSchema,
|
|
1978
|
-
outputSchema: findUncoveredFailureAreasOutputSchema,
|
|
1979
|
-
execute: executeFindUncoveredFailureAreas
|
|
1980
|
-
});
|
|
1981
|
-
registerTool(server, client, {
|
|
1982
|
-
metadata: getUntestedFilesMetadata,
|
|
1983
|
-
inputSchema: getUntestedFilesInputSchema,
|
|
1984
|
-
outputSchema: getUntestedFilesOutputSchema,
|
|
1985
|
-
execute: executeGetUntestedFiles
|
|
1986
|
-
});
|
|
1987
|
-
registerTool(server, client, {
|
|
1988
|
-
metadata: getReportBrowserUrlMetadata,
|
|
1989
|
-
inputSchema: getReportBrowserUrlInputSchema,
|
|
1990
|
-
outputSchema: getReportBrowserUrlOutputSchema,
|
|
1991
|
-
execute: executeGetReportBrowserUrl
|
|
1992
|
-
});
|
|
1993
|
-
registerTool(server, client, {
|
|
1994
|
-
metadata: getUploadStatusMetadata,
|
|
1995
|
-
inputSchema: getUploadStatusInputSchema,
|
|
1996
|
-
outputSchema: getUploadStatusOutputSchema,
|
|
1997
|
-
execute: executeGetUploadStatus
|
|
2393
|
+
outputSchema: listProjectsOutputSchema
|
|
2394
|
+
}, async (input) => {
|
|
2395
|
+
try {
|
|
2396
|
+
const output = await executeListProjects(client, input);
|
|
2397
|
+
return {
|
|
2398
|
+
content: [{
|
|
2399
|
+
type: "text",
|
|
2400
|
+
text: JSON.stringify(output, null, 2)
|
|
2401
|
+
}],
|
|
2402
|
+
structuredContent: output
|
|
2403
|
+
};
|
|
2404
|
+
} catch (error) {
|
|
2405
|
+
return handleToolError(listProjectsMetadata.name, error);
|
|
2406
|
+
}
|
|
1998
2407
|
});
|
|
1999
2408
|
const transport = new StdioServerTransport();
|
|
2000
2409
|
await server.connect(transport);
|