@gaffer-sh/mcp 0.6.2 → 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 +786 -256
- 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,
|
|
@@ -30,7 +29,8 @@ function sleep(ms) {
|
|
|
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
|
|
@@ -46,7 +46,7 @@ var GafferApiClient = class GafferApiClient {
|
|
|
46
46
|
apiKey;
|
|
47
47
|
baseUrl;
|
|
48
48
|
tokenType;
|
|
49
|
-
|
|
49
|
+
resolveProjectIdPromise = null;
|
|
50
50
|
constructor(config) {
|
|
51
51
|
this.apiKey = config.apiKey;
|
|
52
52
|
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
@@ -74,15 +74,21 @@ var GafferApiClient = class GafferApiClient {
|
|
|
74
74
|
}
|
|
75
75
|
/**
|
|
76
76
|
* Resolve the project ID for the current token.
|
|
77
|
-
* For project tokens, fetches from /project on first call and caches
|
|
77
|
+
* For project tokens, fetches from /project on first call and caches the Promise
|
|
78
|
+
* to deduplicate concurrent calls.
|
|
78
79
|
* For user tokens, requires explicit projectId.
|
|
79
80
|
*/
|
|
80
81
|
async resolveProjectId(projectId) {
|
|
81
82
|
if (projectId) return projectId;
|
|
82
83
|
if (this.isUserToken()) throw new Error("projectId is required when using a user API Key");
|
|
83
|
-
if (this.
|
|
84
|
-
|
|
85
|
-
|
|
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;
|
|
86
92
|
}
|
|
87
93
|
/**
|
|
88
94
|
* Make authenticated request to Gaffer API with retry logic
|
|
@@ -201,7 +207,9 @@ var GafferApiClient = class GafferApiClient {
|
|
|
201
207
|
});
|
|
202
208
|
}
|
|
203
209
|
/**
|
|
204
|
-
* Get report files for a test run
|
|
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.
|
|
205
213
|
*/
|
|
206
214
|
async getReport(testRunId) {
|
|
207
215
|
if (!this.isUserToken()) throw new Error("getReport requires a user API Key (gaf_). Project tokens (gfr_) cannot access reports via API.");
|
|
@@ -314,8 +322,120 @@ var GafferApiClient = class GafferApiClient {
|
|
|
314
322
|
const projectId = await this.resolveProjectId(options.projectId);
|
|
315
323
|
return this.request(`/user/projects/${projectId}/upload-sessions/${options.sessionId}`);
|
|
316
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
|
+
});
|
|
338
|
+
}
|
|
317
339
|
};
|
|
318
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
|
+
|
|
319
439
|
//#endregion
|
|
320
440
|
//#region src/tools/compare-test-metrics.ts
|
|
321
441
|
/**
|
|
@@ -397,7 +517,7 @@ const compareTestMetricsMetadata = {
|
|
|
397
517
|
Useful for measuring the impact of code changes on test performance or reliability.
|
|
398
518
|
|
|
399
519
|
Parameters:
|
|
400
|
-
- projectId (
|
|
520
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
401
521
|
- testName (required): The test name to compare (short name or full name)
|
|
402
522
|
- Option 1 - Compare by commit:
|
|
403
523
|
- beforeCommit: Commit SHA for "before" measurement
|
|
@@ -480,7 +600,7 @@ areas in your codebase that need attention. Files are ranked by a "risk score"
|
|
|
480
600
|
calculated as: (100 - coverage%) × failureCount.
|
|
481
601
|
|
|
482
602
|
Parameters:
|
|
483
|
-
- projectId:
|
|
603
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
484
604
|
- days: Analysis period for test failures (default: 30)
|
|
485
605
|
- coverageThreshold: Include files below this coverage % (default: 80)
|
|
486
606
|
|
|
@@ -554,7 +674,7 @@ const getCoverageForFileMetadata = {
|
|
|
554
674
|
description: `Get coverage metrics for a specific file or files matching a path pattern.
|
|
555
675
|
|
|
556
676
|
Parameters:
|
|
557
|
-
- projectId:
|
|
677
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
558
678
|
- filePath: File path to search for (exact or partial match)
|
|
559
679
|
|
|
560
680
|
Returns:
|
|
@@ -693,7 +813,7 @@ const getFailureClustersMetadata = {
|
|
|
693
813
|
description: `Group failed tests by root cause using error message similarity.
|
|
694
814
|
|
|
695
815
|
Parameters:
|
|
696
|
-
- projectId (
|
|
816
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
697
817
|
- testRunId (required): The test run ID to analyze
|
|
698
818
|
|
|
699
819
|
Returns:
|
|
@@ -901,7 +1021,7 @@ Returns a signed URL that can be opened directly in a browser without requiring
|
|
|
901
1021
|
the user to log in. The URL expires after 30 minutes for security.
|
|
902
1022
|
|
|
903
1023
|
Parameters:
|
|
904
|
-
- projectId:
|
|
1024
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
905
1025
|
- testRunId: The test run to view (required)
|
|
906
1026
|
- filename: Specific file to open (optional, defaults to index.html)
|
|
907
1027
|
|
|
@@ -1063,7 +1183,7 @@ const getSlowestTestsMetadata = {
|
|
|
1063
1183
|
description: `Get the slowest tests in a project, sorted by P95 duration.
|
|
1064
1184
|
|
|
1065
1185
|
Parameters:
|
|
1066
|
-
- projectId (
|
|
1186
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1067
1187
|
- days (optional): Analysis period in days (default: 30, max: 365)
|
|
1068
1188
|
- limit (optional): Max tests to return (default: 20, max: 100)
|
|
1069
1189
|
- framework (optional): Filter by framework (e.g., "playwright", "vitest")
|
|
@@ -1257,7 +1377,7 @@ const getTestRunDetailsMetadata = {
|
|
|
1257
1377
|
|
|
1258
1378
|
Parameters:
|
|
1259
1379
|
- testRunId (required): The test run ID to get details for
|
|
1260
|
-
- projectId (
|
|
1380
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1261
1381
|
- status (optional): Filter by test status: "passed", "failed", or "skipped"
|
|
1262
1382
|
- limit (optional): Max tests to return (default: 100, max: 500)
|
|
1263
1383
|
- offset (optional): Pagination offset (default: 0)
|
|
@@ -1362,7 +1482,7 @@ Returns files sorted by coverage percentage (lowest first), filtered
|
|
|
1362
1482
|
to only include files below a coverage threshold.
|
|
1363
1483
|
|
|
1364
1484
|
Parameters:
|
|
1365
|
-
- projectId:
|
|
1485
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1366
1486
|
- maxCoverage: Include files with coverage at or below this % (default: 10)
|
|
1367
1487
|
- limit: Maximum number of files to return (default: 20, max: 100)
|
|
1368
1488
|
|
|
@@ -1460,7 +1580,7 @@ const getUploadStatusMetadata = {
|
|
|
1460
1580
|
Use this tool to answer "are my test results ready?" after pushing code.
|
|
1461
1581
|
|
|
1462
1582
|
Parameters:
|
|
1463
|
-
- projectId (
|
|
1583
|
+
- projectId (optional): Project ID — required for user API keys, auto-resolved for project tokens
|
|
1464
1584
|
- sessionId (optional): Specific upload session ID for detailed status
|
|
1465
1585
|
- commitSha (optional): Filter by commit SHA to find uploads for a specific commit
|
|
1466
1586
|
- branch (optional): Filter by branch name
|
|
@@ -1491,63 +1611,6 @@ Returns (detail mode):
|
|
|
1491
1611
|
- coverageReports: Linked coverage report summaries (id, format)`
|
|
1492
1612
|
};
|
|
1493
1613
|
|
|
1494
|
-
//#endregion
|
|
1495
|
-
//#region src/tools/list-projects.ts
|
|
1496
|
-
/**
|
|
1497
|
-
* Input schema for list_projects tool
|
|
1498
|
-
*/
|
|
1499
|
-
const listProjectsInputSchema = {
|
|
1500
|
-
organizationId: z.string().optional().describe("Filter by organization ID (optional)"),
|
|
1501
|
-
limit: z.number().int().min(1).max(100).optional().describe("Maximum number of projects to return (default: 50)")
|
|
1502
|
-
};
|
|
1503
|
-
/**
|
|
1504
|
-
* Output schema for list_projects tool
|
|
1505
|
-
*/
|
|
1506
|
-
const listProjectsOutputSchema = {
|
|
1507
|
-
projects: z.array(z.object({
|
|
1508
|
-
id: z.string(),
|
|
1509
|
-
name: z.string(),
|
|
1510
|
-
description: z.string().nullable().optional(),
|
|
1511
|
-
organization: z.object({
|
|
1512
|
-
id: z.string(),
|
|
1513
|
-
name: z.string(),
|
|
1514
|
-
slug: z.string()
|
|
1515
|
-
})
|
|
1516
|
-
})),
|
|
1517
|
-
total: z.number()
|
|
1518
|
-
};
|
|
1519
|
-
/**
|
|
1520
|
-
* Execute list_projects tool
|
|
1521
|
-
*/
|
|
1522
|
-
async function executeListProjects(client, input) {
|
|
1523
|
-
const response = await client.listProjects({
|
|
1524
|
-
organizationId: input.organizationId,
|
|
1525
|
-
limit: input.limit
|
|
1526
|
-
});
|
|
1527
|
-
return {
|
|
1528
|
-
projects: response.projects.map((p) => ({
|
|
1529
|
-
id: p.id,
|
|
1530
|
-
name: p.name,
|
|
1531
|
-
description: p.description,
|
|
1532
|
-
organization: p.organization
|
|
1533
|
-
})),
|
|
1534
|
-
total: response.pagination.total
|
|
1535
|
-
};
|
|
1536
|
-
}
|
|
1537
|
-
/**
|
|
1538
|
-
* Tool metadata
|
|
1539
|
-
*/
|
|
1540
|
-
const listProjectsMetadata = {
|
|
1541
|
-
name: "list_projects",
|
|
1542
|
-
title: "List Projects",
|
|
1543
|
-
description: `List all projects you have access to.
|
|
1544
|
-
|
|
1545
|
-
Returns a list of projects with their IDs, names, and organization info.
|
|
1546
|
-
Use this to find project IDs for other tools like get_project_health.
|
|
1547
|
-
|
|
1548
|
-
Requires a user API Key (gaf_). Get one from Account Settings in the Gaffer dashboard.`
|
|
1549
|
-
};
|
|
1550
|
-
|
|
1551
1614
|
//#endregion
|
|
1552
1615
|
//#region src/tools/list-test-runs.ts
|
|
1553
1616
|
/**
|
|
@@ -1635,6 +1698,508 @@ Use cases:
|
|
|
1635
1698
|
- "What's the status of tests on my feature branch?"`
|
|
1636
1699
|
};
|
|
1637
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
|
+
|
|
1638
2203
|
//#endregion
|
|
1639
2204
|
//#region src/index.ts
|
|
1640
2205
|
/**
|
|
@@ -1653,48 +2218,29 @@ function logError(toolName, error) {
|
|
|
1653
2218
|
*/
|
|
1654
2219
|
function handleToolError(toolName, error) {
|
|
1655
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`;
|
|
1656
2227
|
return {
|
|
1657
2228
|
content: [{
|
|
1658
2229
|
type: "text",
|
|
1659
|
-
text
|
|
2230
|
+
text
|
|
1660
2231
|
}],
|
|
1661
2232
|
isError: true
|
|
1662
2233
|
};
|
|
1663
2234
|
}
|
|
1664
2235
|
/**
|
|
1665
|
-
*
|
|
1666
|
-
* Reduces boilerplate by handling error wrapping and response formatting.
|
|
1667
|
-
*/
|
|
1668
|
-
function registerTool(server, client, tool) {
|
|
1669
|
-
server.registerTool(tool.metadata.name, {
|
|
1670
|
-
title: tool.metadata.title,
|
|
1671
|
-
description: tool.metadata.description,
|
|
1672
|
-
inputSchema: tool.inputSchema,
|
|
1673
|
-
outputSchema: tool.outputSchema
|
|
1674
|
-
}, async (input) => {
|
|
1675
|
-
try {
|
|
1676
|
-
const output = await tool.execute(client, input);
|
|
1677
|
-
return {
|
|
1678
|
-
content: [{
|
|
1679
|
-
type: "text",
|
|
1680
|
-
text: JSON.stringify(output, null, 2)
|
|
1681
|
-
}],
|
|
1682
|
-
structuredContent: output
|
|
1683
|
-
};
|
|
1684
|
-
} catch (error) {
|
|
1685
|
-
return handleToolError(tool.metadata.name, error);
|
|
1686
|
-
}
|
|
1687
|
-
});
|
|
1688
|
-
}
|
|
1689
|
-
/**
|
|
1690
|
-
* Gaffer MCP Server
|
|
2236
|
+
* Gaffer MCP Server — Code Mode
|
|
1691
2237
|
*
|
|
1692
|
-
*
|
|
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)
|
|
1693
2242
|
*
|
|
1694
|
-
*
|
|
1695
|
-
* 1. User API Keys (gaf_) - Read-only access to all user's projects
|
|
1696
|
-
* Set via GAFFER_API_KEY environment variable
|
|
1697
|
-
* 2. Project Upload Tokens (gfr_) - Legacy, single project access
|
|
2243
|
+
* This follows Cloudflare's "code mode" pattern for MCP servers.
|
|
1698
2244
|
*/
|
|
1699
2245
|
async function main() {
|
|
1700
2246
|
if (!process.env.GAFFER_API_KEY) {
|
|
@@ -1711,169 +2257,153 @@ async function main() {
|
|
|
1711
2257
|
process.exit(1);
|
|
1712
2258
|
}
|
|
1713
2259
|
const client = GafferApiClient.fromEnv();
|
|
2260
|
+
const registry = new FunctionRegistry();
|
|
2261
|
+
registerAllTools(registry);
|
|
2262
|
+
const namespace = registry.buildNamespace(client);
|
|
2263
|
+
const declarations = registry.generateAllDeclarations();
|
|
1714
2264
|
const server = new McpServer({
|
|
1715
2265
|
name: "gaffer",
|
|
1716
|
-
version: "0.
|
|
1717
|
-
}, { 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.
|
|
1718
2268
|
|
|
1719
2269
|
## Authentication
|
|
1720
2270
|
|
|
1721
|
-
${client.isUserToken() ? "You have access to multiple projects. Use `list_projects` to find project IDs, then pass `projectId` to all
|
|
1722
|
-
|
|
1723
|
-
## Coverage Analysis Best Practices
|
|
1724
|
-
|
|
1725
|
-
When helping users improve test coverage, combine coverage data with codebase exploration:
|
|
1726
|
-
|
|
1727
|
-
1. **Understand code utilization first**: Before targeting files by coverage percentage, explore which code is critical:
|
|
1728
|
-
- Find entry points (route definitions, event handlers, exported functions)
|
|
1729
|
-
- Find heavily-imported files (files imported by many others are high-value targets)
|
|
1730
|
-
- Identify critical business logic (auth, payments, data mutations)
|
|
1731
|
-
|
|
1732
|
-
2. **Prioritize by impact**: Low coverage alone doesn't indicate priority. Consider:
|
|
1733
|
-
- High utilization + low coverage = highest priority
|
|
1734
|
-
- Large files with 0% coverage have bigger impact than small files
|
|
1735
|
-
- Use find_uncovered_failure_areas for files with both low coverage AND test failures
|
|
1736
|
-
|
|
1737
|
-
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.
|
|
1738
|
-
|
|
1739
|
-
4. **Iterate**: Get baseline → identify targets → write tests → re-check coverage after CI uploads new results.
|
|
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."}
|
|
1740
2272
|
|
|
1741
|
-
##
|
|
2273
|
+
## How to Use
|
|
1742
2274
|
|
|
1743
|
-
|
|
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
|
|
1744
2278
|
|
|
1745
|
-
|
|
1746
|
-
1. Use get_coverage_for_file with a path prefix (e.g., "server/") to see what Gaffer tracks
|
|
1747
|
-
2. Use the local Glob tool to list all source files in that path
|
|
1748
|
-
3. Compare the lists - files in local but NOT in Gaffer are invisible
|
|
1749
|
-
4. These files need tests that actually import them
|
|
2279
|
+
## Example
|
|
1750
2280
|
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
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.
|
|
1776
2370
|
|
|
1777
|
-
|
|
2371
|
+
Returns matching functions with their TypeScript declarations so you can use them with execute_code.
|
|
1778
2372
|
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
registerTool(server, client, {
|
|
1795
|
-
metadata: getFlakyTestsMetadata,
|
|
1796
|
-
inputSchema: getFlakyTestsInputSchema,
|
|
1797
|
-
outputSchema: getFlakyTestsOutputSchema,
|
|
1798
|
-
execute: executeGetFlakyTests
|
|
1799
|
-
});
|
|
1800
|
-
registerTool(server, client, {
|
|
1801
|
-
metadata: listTestRunsMetadata,
|
|
1802
|
-
inputSchema: listTestRunsInputSchema,
|
|
1803
|
-
outputSchema: listTestRunsOutputSchema,
|
|
1804
|
-
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
|
+
}
|
|
1805
2388
|
});
|
|
1806
|
-
if (client.isUserToken()) registerTool(
|
|
1807
|
-
|
|
2389
|
+
if (client.isUserToken()) server.registerTool(listProjectsMetadata.name, {
|
|
2390
|
+
title: listProjectsMetadata.title,
|
|
2391
|
+
description: listProjectsMetadata.description,
|
|
1808
2392
|
inputSchema: listProjectsInputSchema,
|
|
1809
|
-
outputSchema: listProjectsOutputSchema
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
});
|
|
1824
|
-
registerTool(server, client, {
|
|
1825
|
-
metadata: getTestRunDetailsMetadata,
|
|
1826
|
-
inputSchema: getTestRunDetailsInputSchema,
|
|
1827
|
-
outputSchema: getTestRunDetailsOutputSchema,
|
|
1828
|
-
execute: executeGetTestRunDetails
|
|
1829
|
-
});
|
|
1830
|
-
registerTool(server, client, {
|
|
1831
|
-
metadata: getFailureClustersMetadata,
|
|
1832
|
-
inputSchema: getFailureClustersInputSchema,
|
|
1833
|
-
outputSchema: getFailureClustersOutputSchema,
|
|
1834
|
-
execute: executeGetFailureClusters
|
|
1835
|
-
});
|
|
1836
|
-
registerTool(server, client, {
|
|
1837
|
-
metadata: compareTestMetricsMetadata,
|
|
1838
|
-
inputSchema: compareTestMetricsInputSchema,
|
|
1839
|
-
outputSchema: compareTestMetricsOutputSchema,
|
|
1840
|
-
execute: executeCompareTestMetrics
|
|
1841
|
-
});
|
|
1842
|
-
registerTool(server, client, {
|
|
1843
|
-
metadata: getCoverageSummaryMetadata,
|
|
1844
|
-
inputSchema: getCoverageSummaryInputSchema,
|
|
1845
|
-
outputSchema: getCoverageSummaryOutputSchema,
|
|
1846
|
-
execute: executeGetCoverageSummary
|
|
1847
|
-
});
|
|
1848
|
-
registerTool(server, client, {
|
|
1849
|
-
metadata: getCoverageForFileMetadata,
|
|
1850
|
-
inputSchema: getCoverageForFileInputSchema,
|
|
1851
|
-
outputSchema: getCoverageForFileOutputSchema,
|
|
1852
|
-
execute: executeGetCoverageForFile
|
|
1853
|
-
});
|
|
1854
|
-
registerTool(server, client, {
|
|
1855
|
-
metadata: findUncoveredFailureAreasMetadata,
|
|
1856
|
-
inputSchema: findUncoveredFailureAreasInputSchema,
|
|
1857
|
-
outputSchema: findUncoveredFailureAreasOutputSchema,
|
|
1858
|
-
execute: executeFindUncoveredFailureAreas
|
|
1859
|
-
});
|
|
1860
|
-
registerTool(server, client, {
|
|
1861
|
-
metadata: getUntestedFilesMetadata,
|
|
1862
|
-
inputSchema: getUntestedFilesInputSchema,
|
|
1863
|
-
outputSchema: getUntestedFilesOutputSchema,
|
|
1864
|
-
execute: executeGetUntestedFiles
|
|
1865
|
-
});
|
|
1866
|
-
registerTool(server, client, {
|
|
1867
|
-
metadata: getReportBrowserUrlMetadata,
|
|
1868
|
-
inputSchema: getReportBrowserUrlInputSchema,
|
|
1869
|
-
outputSchema: getReportBrowserUrlOutputSchema,
|
|
1870
|
-
execute: executeGetReportBrowserUrl
|
|
1871
|
-
});
|
|
1872
|
-
registerTool(server, client, {
|
|
1873
|
-
metadata: getUploadStatusMetadata,
|
|
1874
|
-
inputSchema: getUploadStatusInputSchema,
|
|
1875
|
-
outputSchema: getUploadStatusOutputSchema,
|
|
1876
|
-
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
|
+
}
|
|
1877
2407
|
});
|
|
1878
2408
|
const transport = new StdioServerTransport();
|
|
1879
2409
|
await server.connect(transport);
|