#
tokens: 48211/50000 39/137 files (page 2/4)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 4. Use http://codebase.md/circleci-public/mcp-server-circleci?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .circleci
│   └── config.yml
├── .dockerignore
├── .github
│   ├── CODEOWNERS
│   ├── ISSUE_TEMPLATE
│   │   ├── BUG.yml
│   │   └── FEATURE_REQUEST.yml
│   └── PULL_REQUEST_TEMPLATE
│       └── PULL_REQUEST.md
├── .gitignore
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── renovate.json
├── scripts
│   └── create-tool.js
├── smithery.yaml
├── src
│   ├── circleci-tools.ts
│   ├── clients
│   │   ├── circleci
│   │   │   ├── configValidate.ts
│   │   │   ├── deploys.ts
│   │   │   ├── httpClient.test.ts
│   │   │   ├── httpClient.ts
│   │   │   ├── index.ts
│   │   │   ├── insights.ts
│   │   │   ├── jobs.ts
│   │   │   ├── jobsV1.ts
│   │   │   ├── pipelines.ts
│   │   │   ├── projects.ts
│   │   │   ├── tests.ts
│   │   │   ├── usage.ts
│   │   │   └── workflows.ts
│   │   ├── circleci-private
│   │   │   ├── index.ts
│   │   │   ├── jobsPrivate.ts
│   │   │   └── me.ts
│   │   ├── circlet
│   │   │   ├── circlet.ts
│   │   │   └── index.ts
│   │   ├── client.ts
│   │   └── schemas.ts
│   ├── index.ts
│   ├── lib
│   │   ├── flaky-tests
│   │   │   └── getFlakyTests.ts
│   │   ├── getWorkflowIdFromURL.test.ts
│   │   ├── getWorkflowIdFromURL.ts
│   │   ├── latest-pipeline
│   │   │   ├── formatLatestPipelineStatus.ts
│   │   │   └── getLatestPipelineWorkflows.ts
│   │   ├── mcpErrorOutput.test.ts
│   │   ├── mcpErrorOutput.ts
│   │   ├── mcpResponse.test.ts
│   │   ├── mcpResponse.ts
│   │   ├── outputTextTruncated.test.ts
│   │   ├── outputTextTruncated.ts
│   │   ├── pipeline-job-logs
│   │   │   ├── getJobLogs.ts
│   │   │   └── getPipelineJobLogs.ts
│   │   ├── pipeline-job-tests
│   │   │   ├── formatJobTests.ts
│   │   │   └── getJobTests.ts
│   │   ├── project-detection
│   │   │   ├── index.test.ts
│   │   │   ├── index.ts
│   │   │   └── vcsTool.ts
│   │   ├── rateLimitedRequests
│   │   │   ├── index.test.ts
│   │   │   └── index.ts
│   │   └── usage-api
│   │       ├── findUnderusedResourceClasses.test.ts
│   │       ├── findUnderusedResourceClasses.ts
│   │       ├── getUsageApiData.test.ts
│   │       ├── getUsageApiData.ts
│   │       └── parseDateTimeString.ts
│   ├── tools
│   │   ├── analyzeDiff
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── configHelper
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── createPromptTemplate
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── downloadUsageApiData
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── findUnderusedResourceClasses
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── getBuildFailureLogs
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── getFlakyTests
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── getJobTestResults
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── getLatestPipelineStatus
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── listComponentVersions
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── listFollowedProjects
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── recommendPromptTemplateTests
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── rerunWorkflow
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── runEvaluationTests
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── runPipeline
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   ├── runRollbackPipeline
│   │   │   ├── handler.test.ts
│   │   │   ├── handler.ts
│   │   │   ├── inputSchema.ts
│   │   │   └── tool.ts
│   │   └── shared
│   │       └── constants.ts
│   └── transports
│       ├── stdio.ts
│       └── unified.ts
├── tsconfig.json
├── tsconfig.test.json
└── vitest.config.js
```

# Files

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
 1 | #!/usr/bin/env node
 2 | 
 3 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
 4 | 
 5 | import { CCI_HANDLERS, CCI_TOOLS } from './circleci-tools.js';
 6 | import { createUnifiedTransport } from './transports/unified.js';
 7 | import { createStdioTransport } from './transports/stdio.js';
 8 | 
 9 | const server = new McpServer(
10 |   { name: 'mcp-server-circleci', version: '1.0.0' },
11 |   { capabilities: { tools: {}, resources: {} } },
12 | );
13 | 
14 | // ---- DEBUG WRAPPERS --------------------------------------------------
15 | if (process.env.debug === 'true') {
16 |   const srv: any = server;
17 | 
18 |   if (typeof srv.notification === 'function') {
19 |     const origNotify = srv.notification.bind(server);
20 |     srv.notification = async (...args: any[]) => {
21 |       try {
22 |         const [{ method, params }] = args;
23 |         console.error(
24 |           '[DEBUG] outgoing notification:',
25 |           method,
26 |           JSON.stringify(params),
27 |         );
28 |       } catch {
29 |         /* ignore */
30 |       }
31 |       return origNotify(...args);
32 |     };
33 |   }
34 | 
35 |   if (typeof srv.request === 'function') {
36 |     const origRequest = srv.request.bind(server);
37 |     srv.request = async (...args: any[]) => {
38 |       const [payload] = args;
39 |       const result = await origRequest(...args);
40 |       console.error(
41 |         '[DEBUG] response to',
42 |         payload?.method,
43 |         JSON.stringify(result).slice(0, 200),
44 |       );
45 |       return result;
46 |     };
47 |   }
48 | }
49 | 
50 | // Register all CircleCI tools once
51 | if (process.env.debug === 'true') {
52 |   console.error('[DEBUG] [Startup] Registering CircleCI MCP tools...');
53 | }
54 | // Ensure we advertise support for tools/list in capabilities (SDK only sets listChanged)
55 | (server as any).server.registerCapabilities({ tools: { list: true } });
56 | 
57 | CCI_TOOLS.forEach((tool) => {
58 |   const handler = CCI_HANDLERS[tool.name];
59 |   if (!handler) throw new Error(`Handler for tool ${tool.name} not found`);
60 |   if (process.env.debug === 'true') {
61 |     console.error(`[DEBUG] [Startup] Registering tool: ${tool.name}`);
62 |   }
63 |   server.tool(
64 |     tool.name,
65 |     tool.description,
66 |     { params: tool.inputSchema.optional() },
67 |     handler as any,
68 |   );
69 | });
70 | 
71 | async function main() {
72 |   if (process.env.start === 'remote') {
73 |     console.error('Starting CircleCI MCP unified HTTP+SSE server...');
74 |     createUnifiedTransport(server);
75 |   } else {
76 |     console.error('Starting CircleCI MCP server in stdio mode...');
77 |     createStdioTransport(server);
78 |   }
79 | }
80 | 
81 | main().catch((err) => {
82 |   console.error('Server error:', err);
83 |   process.exit(1);
84 | });
85 | 
```

--------------------------------------------------------------------------------
/src/tools/getBuildFailureLogs/tool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { getBuildFailureOutputInputSchema } from './inputSchema.js';
 2 | import { option1DescriptionBranchRequired } from '../shared/constants.js';
 3 | 
 4 | export const getBuildFailureLogsTool = {
 5 |   name: 'get_build_failure_logs' as const,
 6 |   description: `
 7 |     This tool helps debug CircleCI build failures by retrieving failure logs.
 8 | 
 9 |     CRITICAL REQUIREMENTS:
10 |     1. Truncation Handling (HIGHEST PRIORITY):
11 |        - ALWAYS check for <MCPTruncationWarning> in the output
12 |        - When present, you MUST start your response with:
13 |          "WARNING: The logs have been truncated. Only showing the most recent entries. Earlier build failures may not be visible."
14 |        - Only proceed with log analysis after acknowledging the truncation
15 | 
16 |     Input options (EXACTLY ONE of these THREE options must be used):
17 | 
18 |     ${option1DescriptionBranchRequired}
19 | 
20 |     Option 2 - Direct URL (provide ONE of these):
21 |     - projectURL: The URL of the CircleCI project in any of these formats:
22 |       * Project URL: https://app.circleci.com/pipelines/gh/organization/project
23 |       * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123
24 |       * Legacy Job URL: https://circleci.com/pipelines/gh/organization/project/123
25 |       * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def
26 |       * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz
27 | 
28 |     Option 3 - Project Detection (ALL of these must be provided together):
29 |     - workspaceRoot: The absolute path to the workspace root
30 |     - gitRemoteURL: The URL of the git remote repository
31 |     - branch: The name of the current branch
32 |     
33 |     Recommended Workflow:
34 |     1. Use listFollowedProjects tool to get a list of projects
35 |     2. Extract the projectSlug from the chosen project (format: "gh/organization/project")
36 |     3. Use that projectSlug with a branch name for this tool
37 | 
38 |     Additional Requirements:
39 |     - Never call this tool with incomplete parameters
40 |     - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects
41 |     - If using Option 2, the URLs MUST be provided by the user - do not attempt to construct or guess URLs
42 |     - If using Option 3, ALL THREE parameters (workspaceRoot, gitRemoteURL, branch) must be provided
43 |     - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call
44 |     `,
45 |   inputSchema: getBuildFailureOutputInputSchema,
46 | };
47 | 
```

--------------------------------------------------------------------------------
/src/tools/shared/constants.ts:
--------------------------------------------------------------------------------

```typescript
 1 | // SHARED CIRCLECI TOOL DESCRIPTION CONSTANTS
 2 | export const projectSlugDescription = `The project slug from listFollowedProjects tool (e.g., "gh/organization/project"). When using this option, branch must also be provided.`;
 3 | export const projectSlugDescriptionNoBranch = `The project slug from listFollowedProjects tool (e.g., "gh/organization/project").`;
 4 | export const branchDescription = `The name of the branch currently checked out in local workspace. This should match local git branch. For example: "feature/my-branch", "bugfix/123", "main", "master" etc.`;
 5 | export const option1DescriptionBranchRequired = `Option 1 - Project Slug and branch (BOTH required):
 6 |     - projectSlug: The project slug obtained from listFollowedProjects tool (e.g., "gh/organization/project")
 7 |     - branch: The name of the branch (required when using projectSlug)`;
 8 | export const workflowUrlDescription =
 9 |   'The URL of the CircleCI workflow or job. Can be any of these formats:\n' +
10 |   '- Workflow URL: https://app.circleci.com/pipelines/:vcsType/:orgName/:projectName/:pipelineNumber/workflows/:workflowId' +
11 |   '- Job URL: https://app.circleci.com/pipelines/:vcsType/:orgName/:projectName/:pipelineNumber/workflows/:workflowId/jobs/:buildNumber';
12 | 
13 | // PROMPT TEMPLATE ITERATION & TESTING TOOL CONSTANTS
14 | // NOTE: We want to be extremely consistent with the tool names and parameters passed through the Prompt Workbench toolchain, since one tool's output may be used as input for another tool.
15 | export const defaultModel = 'gpt-4.1-mini';
16 | export const defaultTemperature = 1.0;
17 | export const promptsOutputDirectory = './prompts';
18 | export const fileExtension = '.prompt.yml';
19 | export const fileNameTemplate = `<relevant-name>${fileExtension}`;
20 | export const fileNameExample1 = `bedtime-story-generator${fileExtension}`;
21 | export const fileNameExample2 = `plant-care-assistant${fileExtension}`;
22 | export const fileNameExample3 = `customer-support-chatbot${fileExtension}`;
23 | 
24 | export enum PromptWorkbenchToolName {
25 |   create_prompt_template = 'create_prompt_template',
26 |   recommend_prompt_template_tests = 'recommend_prompt_template_tests',
27 | }
28 | 
29 | // What is the origin of the Prompt Workbench toolchain request?
30 | export enum PromptOrigin {
31 |   codebase = 'codebase', // pre-existing prompts in user's codebase
32 |   requirements = 'requirements', // new feature requirements provided by user
33 | }
34 | 
35 | // ANALYZE DIFF TOOL CONSTANTS
36 | export enum FilterBy {
37 |   violations = 'Violations',
38 |   compliants = 'Compliants',
39 |   humanReviewRequired = 'Human Review Required',
40 |   none = 'None',
41 | }
42 | 
```

--------------------------------------------------------------------------------
/src/lib/flaky-tests/getFlakyTests.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { getCircleCIClient } from '../../clients/client.js';
 2 | import { Test } from '../../clients/schemas.js';
 3 | import { rateLimitedRequests } from '../rateLimitedRequests/index.js';
 4 | import outputTextTruncated, { SEPARATOR } from '../outputTextTruncated.js';
 5 | 
 6 | const getFlakyTests = async ({ projectSlug }: { projectSlug: string }) => {
 7 |   const circleci = getCircleCIClient();
 8 |   const flakyTests = await circleci.insights.getProjectFlakyTests({
 9 |     projectSlug,
10 |   });
11 | 
12 |   if (!flakyTests || !flakyTests.flaky_tests) {
13 |     throw new Error('Flaky tests not found');
14 |   }
15 | 
16 |   const flakyTestDetails = [
17 |     ...new Set(
18 |       flakyTests.flaky_tests.map((test) => ({
19 |         jobNumber: test.job_number,
20 |         test_name: test.test_name,
21 |       })),
22 |     ),
23 |   ];
24 | 
25 |   const testsArrays = await rateLimitedRequests(
26 |     flakyTestDetails.map(({ jobNumber, test_name }) => async () => {
27 |       try {
28 |         const tests = await circleci.tests.getJobTests({
29 |           projectSlug,
30 |           jobNumber,
31 |         });
32 |         const matchingTest = tests.find((test) => test.name === test_name);
33 |         if (matchingTest) {
34 |           return matchingTest;
35 |         }
36 |         console.error(`Test ${test_name} not found in job ${jobNumber}`);
37 |         return tests.filter((test) => test.result === 'failure');
38 |       } catch (error) {
39 |         if (error instanceof Error && error.message.includes('404')) {
40 |           console.error(`Job ${jobNumber} not found:`, error);
41 |           return undefined;
42 |         } else if (error instanceof Error && error.message.includes('429')) {
43 |           console.error(`Rate limited for job request ${jobNumber}:`, error);
44 |           return undefined;
45 |         }
46 |         throw error;
47 |       }
48 |     }),
49 |   );
50 | 
51 |   const filteredTestsArrays = testsArrays
52 |     .flat()
53 |     .filter((test) => test !== undefined);
54 | 
55 |   return filteredTestsArrays;
56 | };
57 | 
58 | export const formatFlakyTests = (tests: Test[]) => {
59 |   if (tests.length === 0) {
60 |     return {
61 |       content: [{ type: 'text' as const, text: 'No flaky tests found' }],
62 |     };
63 |   }
64 | 
65 |   const outputText = tests
66 |     .map((test) => {
67 |       const fields = [
68 |         test.file && `File Name: ${test.file}`,
69 |         test.classname && `Classname: ${test.classname}`,
70 |         test.name && `Test name: ${test.name}`,
71 |         test.result && `Result: ${test.result}`,
72 |         test.run_time && `Run time: ${test.run_time}`,
73 |         test.message && `Message: ${test.message}`,
74 |       ].filter(Boolean);
75 |       return `${SEPARATOR}${fields.join('\n')}`;
76 |     })
77 |     .join('\n');
78 | 
79 |   return outputTextTruncated(outputText);
80 | };
81 | 
82 | export default getFlakyTests;
83 | 
```

--------------------------------------------------------------------------------
/src/clients/circleci-private/me.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { z } from 'zod';
 2 | import { HTTPClient } from '../circleci/httpClient.js';
 3 | import { FollowedProject } from '../schemas.js';
 4 | import { defaultPaginationOptions } from '../circleci/index.js';
 5 | 
 6 | const FollowedProjectResponseSchema = z.object({
 7 |   items: z.array(FollowedProject),
 8 |   next_page_token: z.string().nullable(),
 9 | });
10 | 
11 | export class MeAPI {
12 |   protected client: HTTPClient;
13 | 
14 |   constructor(client: HTTPClient) {
15 |     this.client = client;
16 |   }
17 | 
18 |   /**
19 |    * Get the projects that the user is following with pagination support
20 |    * @param options Optional configuration for pagination limits
21 |    * @param options.maxPages Maximum number of pages to fetch (default: 5)
22 |    * @param options.timeoutMs Timeout in milliseconds (default: 10000)
23 |    * @returns All followed projects
24 |    * @throws Error if timeout or max pages reached
25 |    */
26 |   async getFollowedProjects(
27 |     options: {
28 |       maxPages?: number;
29 |       timeoutMs?: number;
30 |     } = {},
31 |   ): Promise<{
32 |     projects: FollowedProject[];
33 |     reachedMaxPagesOrTimeout: boolean;
34 |   }> {
35 |     const { maxPages = 20, timeoutMs = defaultPaginationOptions.timeoutMs } =
36 |       options;
37 | 
38 |     const startTime = Date.now();
39 |     const allProjects: FollowedProject[] = [];
40 | 
41 |     let nextPageToken: string | null = null;
42 |     let previousPageToken: string | null = null;
43 |     let pageCount = 0;
44 | 
45 |     do {
46 |       // Check timeout
47 |       if (Date.now() - startTime > timeoutMs) {
48 |         return {
49 |           projects: allProjects,
50 |           reachedMaxPagesOrTimeout: true,
51 |         };
52 |       }
53 | 
54 |       // Check page limit
55 |       if (pageCount >= maxPages) {
56 |         return {
57 |           projects: allProjects,
58 |           reachedMaxPagesOrTimeout: true,
59 |         };
60 |       }
61 | 
62 |       const params = nextPageToken ? { 'page-token': nextPageToken } : {};
63 |       const rawResult = await this.client.get<unknown>(
64 |         '/me/followed-projects',
65 |         params,
66 |       );
67 | 
68 |       // Validate the response against our schema
69 |       const result = FollowedProjectResponseSchema.parse(rawResult);
70 | 
71 |       pageCount++;
72 | 
73 |       allProjects.push(...result.items);
74 | 
75 |       // Store the current token before updating
76 |       previousPageToken = nextPageToken;
77 |       nextPageToken = result.next_page_token;
78 | 
79 |       // Break if we received the same token as before (stuck in a loop)
80 |       if (nextPageToken && nextPageToken === previousPageToken) {
81 |         return {
82 |           projects: allProjects,
83 |           reachedMaxPagesOrTimeout: true,
84 |         };
85 |       }
86 |     } while (nextPageToken);
87 | 
88 |     return {
89 |       projects: allProjects,
90 |       reachedMaxPagesOrTimeout: false,
91 |     };
92 |   }
93 | }
94 | 
```

--------------------------------------------------------------------------------
/src/clients/circleci/jobs.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { Job } from '../schemas.js';
 2 | import { HTTPClient } from './httpClient.js';
 3 | import { defaultPaginationOptions } from './index.js';
 4 | import { z } from 'zod';
 5 | 
 6 | const WorkflowJobResponseSchema = z.object({
 7 |   items: z.array(Job),
 8 |   next_page_token: z.string().nullable(),
 9 | });
10 | 
11 | export class JobsAPI {
12 |   protected client: HTTPClient;
13 | 
14 |   constructor(httpClient: HTTPClient) {
15 |     this.client = httpClient;
16 |   }
17 | 
18 |   /**
19 |    * Get job details by job number
20 |    * @param params Configuration parameters
21 |    * @param params.projectSlug The project slug (e.g., "gh/CircleCI-Public/api-preview-docs")
22 |    * @param params.jobNumber The number of the job
23 |    * @returns Job details
24 |    */
25 |   async getJobByNumber({
26 |     projectSlug,
27 |     jobNumber,
28 |   }: {
29 |     projectSlug: string;
30 |     jobNumber: number;
31 |   }): Promise<Job> {
32 |     const rawResult = await this.client.get<unknown>(
33 |       `/project/${projectSlug}/job/${jobNumber}`,
34 |     );
35 |     // Validate the response against our Job schema
36 |     return Job.parse(rawResult);
37 |   }
38 | 
39 |   /**
40 |    * Get jobs for a workflow with pagination support
41 |    * @param params Configuration parameters
42 |    * @param params.workflowId The ID of the workflow
43 |    * @param params.options Optional configuration for pagination limits
44 |    * @param params.options.maxPages Maximum number of pages to fetch (default: 5)
45 |    * @param params.options.timeoutMs Timeout in milliseconds (default: 10000)
46 |    * @returns All jobs for the workflow
47 |    * @throws Error if timeout or max pages reached
48 |    */
49 |   async getWorkflowJobs({
50 |     workflowId,
51 |     options = {},
52 |   }: {
53 |     workflowId: string;
54 |     options?: {
55 |       maxPages?: number;
56 |       timeoutMs?: number;
57 |     };
58 |   }): Promise<Job[]> {
59 |     const {
60 |       maxPages = defaultPaginationOptions.maxPages,
61 |       timeoutMs = defaultPaginationOptions.timeoutMs,
62 |     } = options;
63 | 
64 |     const startTime = Date.now();
65 |     const allJobs: Job[] = [];
66 |     let nextPageToken: string | null = null;
67 |     let pageCount = 0;
68 | 
69 |     do {
70 |       // Check timeout
71 |       if (Date.now() - startTime > timeoutMs) {
72 |         throw new Error(`Timeout reached after ${timeoutMs}ms`);
73 |       }
74 | 
75 |       // Check page limit
76 |       if (pageCount >= maxPages) {
77 |         throw new Error(`Maximum number of pages (${maxPages}) reached`);
78 |       }
79 | 
80 |       const params = nextPageToken ? { 'page-token': nextPageToken } : {};
81 |       const rawResult = await this.client.get<unknown>(
82 |         `/workflow/${workflowId}/job`,
83 |         params,
84 |       );
85 | 
86 |       // Validate the response against our WorkflowJobResponse schema
87 |       const result = WorkflowJobResponseSchema.parse(rawResult);
88 | 
89 |       pageCount++;
90 |       allJobs.push(...result.items);
91 |       nextPageToken = result.next_page_token;
92 |     } while (nextPageToken);
93 | 
94 |     return allJobs;
95 |   }
96 | }
97 | 
```

--------------------------------------------------------------------------------
/src/clients/circleci/httpClient.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { HTTPClient } from './httpClient.js';
 2 | import { expect, vi, describe, it, beforeEach, afterEach } from 'vitest';
 3 | 
 4 | describe('HTTPClient', () => {
 5 |   let client: HTTPClient;
 6 |   const apiPath = '/api/v2';
 7 |   const headers = { 'Content-Type': 'application/json' };
 8 |   const defaultBaseURL = 'https://circleci.com';
 9 |   const baseURL = defaultBaseURL + apiPath;
10 | 
11 |   beforeEach(() => {
12 |     // Clear any environment variables before each test
13 |     delete process.env.CIRCLECI_BASE_URL;
14 |     client = new HTTPClient(defaultBaseURL, apiPath, { headers });
15 |     global.fetch = vi.fn();
16 |   });
17 | 
18 |   afterEach(() => {
19 |     vi.resetAllMocks();
20 |     // Clean up environment variables
21 |     delete process.env.CIRCLECI_BASE_URL;
22 |   });
23 | 
24 |   describe('constructor', () => {
25 |     it('should use default base URL when CIRCLECI_BASE_URL is not set', () => {
26 |       const url = (client as any).buildURL('/test');
27 |       expect(url.toString()).toBe(`${defaultBaseURL}${apiPath}/test`);
28 |     });
29 | 
30 |     it('should use CIRCLECI_BASE_URL when set', () => {
31 |       const customBaseURL = 'https://custom-circleci.example.com';
32 |       process.env.CIRCLECI_BASE_URL = customBaseURL;
33 |       const customClient = new HTTPClient(customBaseURL, apiPath, { headers });
34 |       const url = (customClient as any).buildURL('/test');
35 |       expect(url.toString()).toBe(`${customBaseURL}${apiPath}/test`);
36 |     });
37 |   });
38 | 
39 |   describe('buildURL', () => {
40 |     it('should build URL without params', () => {
41 |       const path = '/test';
42 |       const url = (client as any).buildURL(path);
43 |       expect(url.toString()).toBe(`${baseURL}${path}`);
44 |     });
45 | 
46 |     it('should build URL with simple params', () => {
47 |       const path = '/test';
48 |       const params = { key: 'value' };
49 |       const url = (client as any).buildURL(path, params);
50 |       expect(url.toString()).toBe(`${baseURL}${path}?key=value`);
51 |     });
52 | 
53 |     it('should handle array params', () => {
54 |       const path = '/test';
55 |       const params = { arr: ['value1', 'value2'] };
56 |       const url = (client as any).buildURL(path, params);
57 |       expect(url.toString()).toBe(`${baseURL}${path}?arr=value1&arr=value2`);
58 |     });
59 |   });
60 | 
61 |   describe('handleResponse', () => {
62 |     it('should handle successful response', async () => {
63 |       const mockData = { success: true };
64 |       const response = new Response(JSON.stringify(mockData), { status: 200 });
65 |       const result = await (client as any).handleResponse(response);
66 |       expect(result).toEqual(mockData);
67 |     });
68 | 
69 |     it('should handle error response', async () => {
70 |       const errorMessage = 'Not Found';
71 |       const response = new Response(JSON.stringify({ message: errorMessage }), {
72 |         status: 404,
73 |       });
74 |       await expect((client as any).handleResponse(response)).rejects.toThrow(
75 |         'CircleCI API Error',
76 |       );
77 |     });
78 |   });
79 | });
80 | 
```

--------------------------------------------------------------------------------
/src/tools/downloadUsageApiData/tool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { downloadUsageApiDataInputSchema } from './inputSchema.js';
 2 | 
 3 | export const downloadUsageApiDataTool = {
 4 |   name: 'download_usage_api_data' as const,
 5 |   description: `
 6 |     ⚠️ **MANDATORY: The handler will REJECT any call that does not include BOTH outputDir and originalUserMessage. These parameters are REQUIRED for all tool calls.**
 7 |     
 8 |     ⚠️ **MANDATORY OUTPUT DIRECTORY SELECTION FOR AI AGENTS:**
 9 |     1. If the project root (workspace root) is available (e.g., via \`workspaceRoot\` or known repository context), you MUST pass it as the \`outputDir\` parameter.
10 |     2. If the project root is not available, you MUST use the user's Downloads folder (e.g., \`~/Downloads\` or \`%USERPROFILE%\\Downloads\`) as the \`outputDir\` parameter.
11 |     3. Only if neither is available, use the current working directory (\`process.cwd()\`).
12 |     4. **Never omit the \`outputDir\` parameter. Always make the output location explicit.**
13 |     5. **Omitting \`outputDir\` is a critical error. Tool calls without \`outputDir\` may be rejected or flagged as incorrect. Repeated violations may be treated as a bug in the AI agent.**
14 |     6. **AI agents MUST validate their tool calls to ensure \`outputDir\` is present before execution.**
15 | 
16 |     Downloads usage data from the CircleCI Usage API for a given organization and date range.
17 |     This tool both starts the export job and downloads the resulting CSV file when ready.
18 |     Required parameters: orgId, startDate, endDate, outputDir.
19 | 
20 |     **outputDir (required):**
21 |     The directory where the usage data CSV will be saved.
22 |     - You MUST provide \`outputDir\` for every tool call.
23 |     - The file will be saved in the specified directory.
24 |     - Omitting \`outputDir\` will result in an error.
25 | 
26 |     **Directory Selection Instructions for AI Agents:**
27 |     - If the project root is available (e.g., via \`workspaceRoot\`, \`outputDir\`, or known repository context), always use it as the output directory for file outputs.
28 |     - If no project root is available (e.g., running in the user's home directory or a generic environment), use the user's Downloads folder (e.g., \`~/Downloads\` or \`%USERPROFILE%\\Downloads\`)
29 |     - If neither is available, fall back to the current working directory.
30 |     - Never place output files in a location that is hard to discover for the user.
31 |     - **Always double-check that \`outputDir\` is present in your tool call.**
32 |     - **Always double-check that \`originalUserMessage\` is present in your tool call.**
33 | 
34 |     This ensures that downloaded usage data is always saved in a location that is relevant and easy for the user to find, and that the output is always copy-paste friendly for status checks, regardless of the environment in which the tool is run.
35 |   `,
36 |   inputSchema: downloadUsageApiDataInputSchema,
37 | }; 
```

--------------------------------------------------------------------------------
/src/tools/runEvaluationTests/tool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import {
 2 |   option1DescriptionBranchRequired,
 3 |   promptsOutputDirectory,
 4 | } from '../shared/constants.js';
 5 | import { runEvaluationTestsInputSchema } from './inputSchema.js';
 6 | 
 7 | export const runEvaluationTestsTool = {
 8 |   name: 'run_evaluation_tests' as const,
 9 |   description: `
10 |     This tool allows the users to run evaluation tests on a circleci pipeline.
11 |     They can be referred to as "Prompt Tests" or "Evaluation Tests".
12 | 
13 |     This tool triggers a new CircleCI pipeline and returns the URL to monitor its progress.
14 |     The tool will generate an appropriate circleci configuration file and trigger a pipeline using this temporary configuration.
15 |     The tool will return the project slug.
16 | 
17 |     Input options (EXACTLY ONE of these THREE options must be used):
18 | 
19 |     ${option1DescriptionBranchRequired}
20 | 
21 |     Option 2 - Direct URL (provide ONE of these):
22 |     - projectURL: The URL of the CircleCI project in any of these formats:
23 |       * Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch
24 |       * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123
25 |       * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def
26 |       * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz
27 | 
28 |     Option 3 - Project Detection (ALL of these must be provided together):
29 |     - workspaceRoot: The absolute path to the workspace root
30 |     - gitRemoteURL: The URL of the git remote repository
31 |     - branch: The name of the current branch
32 | 
33 |     Test Files:
34 |     - promptFiles: Array of prompt template file objects from the ${promptsOutputDirectory} directory, each containing:
35 |       * fileName: The name of the prompt template file
36 |       * fileContent: The contents of the prompt template file
37 | 
38 |     Pipeline Selection:
39 |     - If the project has multiple pipeline definitions, the tool will return a list of available pipelines
40 |     - You must then make another call with the chosen pipeline name using the pipelineChoiceName parameter
41 |     - The pipelineChoiceName must exactly match one of the pipeline names returned by the tool
42 |     - If the project has only one pipeline definition, pipelineChoiceName is not needed
43 | 
44 |     Additional Requirements:
45 |     - Never call this tool with incomplete parameters
46 |     - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects
47 |     - If using Option 2, the URLs MUST be provided by the user - do not attempt to construct or guess URLs
48 |     - If using Option 3, ALL THREE parameters (workspaceRoot, gitRemoteURL, branch) must be provided
49 |     - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call
50 | 
51 |     Returns:
52 |     - A URL to the newly triggered pipeline that can be used to monitor its progress
53 |     `,
54 |   inputSchema: runEvaluationTestsInputSchema,
55 | };
56 | 
```

--------------------------------------------------------------------------------
/src/clients/circleci/deploys.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { DeployComponentsResponse, DeployComponentVersionsResponse, DeployEnvironmentResponse, DeploySettingsResponse, RollbackProjectRequest, RollbackProjectResponse } from '../schemas.js';
  2 | import { HTTPClient } from './httpClient.js';
  3 | 
  4 | export class DeploysAPI {
  5 |   protected client: HTTPClient;
  6 | 
  7 |   constructor(httpClient: HTTPClient) {
  8 |     this.client = httpClient;
  9 |   }
 10 | 
 11 |   async runRollbackPipeline({
 12 |     projectID,
 13 |     rollbackRequest,
 14 |   }: {
 15 |     projectID: string;
 16 |     rollbackRequest: RollbackProjectRequest;
 17 |   }): Promise<RollbackProjectResponse> {
 18 |     const rawResult = await this.client.post<unknown>(
 19 |       `/projects/${projectID}/rollback`,
 20 |       rollbackRequest,
 21 |     );
 22 | 
 23 |     const parsedResult = RollbackProjectResponse.safeParse(rawResult);
 24 |     if (!parsedResult.success) {
 25 |       throw new Error(`Failed to parse rollback response: ${parsedResult.error.message}`);
 26 |     }
 27 | 
 28 |     return parsedResult.data;
 29 |   }
 30 | 
 31 |   async fetchComponentVersions({
 32 |     componentID,
 33 |     environmentID,
 34 |   }: {
 35 |     componentID: string;
 36 |     environmentID: string;
 37 |   }): Promise<DeployComponentVersionsResponse> {
 38 |     const rawResult = await this.client.get<unknown>(
 39 |       `/deploy/components/${componentID}/versions?environment-id=${environmentID}`
 40 |     );
 41 | 
 42 |     const parsedResult = DeployComponentVersionsResponse.safeParse(rawResult);
 43 |     if (!parsedResult.success) {
 44 |       throw new Error(`Failed to parse component versions: ${parsedResult.error.message}`);
 45 |     }
 46 | 
 47 |     return parsedResult.data;
 48 |   }
 49 | 
 50 |   async fetchEnvironments({
 51 |     orgID,
 52 |   }: {
 53 |     orgID: string;
 54 |   }): Promise<DeployEnvironmentResponse> {
 55 |     const rawResult = await this.client.get<unknown>(
 56 |       `/deploy/environments?org-id=${orgID}`
 57 |     );
 58 | 
 59 |     const parsedResult = DeployEnvironmentResponse.safeParse(rawResult);
 60 |     if (!parsedResult.success) {
 61 |       throw new Error(`Failed to parse environments: ${parsedResult.error.message}`);
 62 |     }
 63 | 
 64 |     return parsedResult.data;
 65 |   }
 66 | 
 67 |   async fetchProjectComponents({
 68 |     projectID,
 69 |     orgID,
 70 |   }: {
 71 |     projectID: string;
 72 |     orgID: string;
 73 |   }): Promise<DeployComponentsResponse> {
 74 |     const rawResult = await this.client.get<unknown>(
 75 |       `/deploy/components?org-id=${orgID}&project-id=${projectID}`
 76 |     );
 77 | 
 78 |     const parsedResult = DeployComponentsResponse.safeParse(rawResult);
 79 |     if (!parsedResult.success) {
 80 |       throw new Error(`Failed to parse components: ${parsedResult.error.message}`);
 81 |     }
 82 | 
 83 |     return parsedResult.data;
 84 |   }
 85 | 
 86 |   async fetchProjectDeploySettings({
 87 |     projectID,
 88 |   }: {
 89 |     projectID: string;
 90 |   }): Promise<DeploySettingsResponse> {
 91 |     const rawResult = await this.client.get<unknown>(
 92 |       `/deploy/projects/${projectID}/settings`
 93 |     );
 94 | 
 95 |     const parsedResult = DeploySettingsResponse.safeParse(rawResult);
 96 |     if (!parsedResult.success) {
 97 |       throw new Error(`Failed to parse project deploy settings: ${parsedResult.error.message}`);
 98 |     }
 99 | 
100 |     return parsedResult.data;
101 |   }
102 | }
103 | 
104 | 
```

--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------

```javascript
 1 | import js from '@eslint/js';
 2 | import * as tseslint from 'typescript-eslint';
 3 | import prettierConfig from 'eslint-config-prettier';
 4 | 
 5 | // @ts-check
 6 | export default tseslint.config(
 7 |   {
 8 |     // Default configuration for all files
 9 |     ignores: ['dist/**', 'node_modules/**'],
10 |   },
11 |   {
12 |     // For JavaScript files including the config file
13 |     files: ['**/*.js', '**/*.mjs'],
14 |     extends: [js.configs.recommended],
15 |   },
16 |   {
17 |     // For TypeScript files (excluding tests)
18 |     files: ['**/*.ts'],
19 |     ignores: ['**/*.test.ts', '**/*.spec.ts'],
20 |     extends: [...tseslint.configs.recommended, ...tseslint.configs.stylistic],
21 |     languageOptions: {
22 |       parserOptions: {
23 |         project: './tsconfig.json',
24 |         tsconfigRootDir: import.meta.dirname,
25 |       },
26 |     },
27 |     rules: {
28 |       // No output to stdout that isn't MCP, allow stderr
29 |       'no-console': ['error', { allow: ['error'] }],
30 |       '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
31 |       '@typescript-eslint/no-explicit-any': 'off',
32 |       '@typescript-eslint/no-unsafe-member-access': 'off',
33 |       '@typescript-eslint/no-unsafe-argument': 'off',
34 |       '@typescript-eslint/no-unsafe-assignment': 'off',
35 |       '@typescript-eslint/no-unsafe-return': 'off',
36 |       '@typescript-eslint/no-unsafe-call': 'off',
37 |       '@typescript-eslint/non-nullable-type-assertion-style': 'off',
38 |       '@typescript-eslint/prefer-nullish-coalescing': 'off',
39 |       '@typescript-eslint/no-unnecessary-condition': 'off',
40 |       '@typescript-eslint/restrict-template-expressions': [
41 |         'error',
42 |         {
43 |           allowAny: true,
44 |           allowBoolean: true,
45 |           allowNullish: true,
46 |           allowNumber: true,
47 |           allowRegExp: true,
48 |         },
49 |       ],
50 |     },
51 |   },
52 |   {
53 |     // For TypeScript test files
54 |     files: ['**/*.test.ts', '**/*.spec.ts'],
55 |     extends: [...tseslint.configs.recommended, ...tseslint.configs.stylistic],
56 |     languageOptions: {
57 |       parserOptions: {
58 |         project: './tsconfig.test.json',
59 |         tsconfigRootDir: import.meta.dirname,
60 |       },
61 |     },
62 |     rules: {
63 |       'no-console': 'off',
64 |       '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
65 |       '@typescript-eslint/no-explicit-any': 'off',
66 |       '@typescript-eslint/no-unsafe-member-access': 'off',
67 |       '@typescript-eslint/no-unsafe-argument': 'off',
68 |       '@typescript-eslint/no-unsafe-assignment': 'off',
69 |       '@typescript-eslint/no-unsafe-return': 'off',
70 |       '@typescript-eslint/no-unsafe-call': 'off',
71 |       '@typescript-eslint/non-nullable-type-assertion-style': 'off',
72 |       '@typescript-eslint/prefer-nullish-coalescing': 'off',
73 |       '@typescript-eslint/no-unnecessary-condition': 'off',
74 |       '@typescript-eslint/restrict-template-expressions': [
75 |         'error',
76 |         {
77 |           allowAny: true,
78 |           allowBoolean: true,
79 |           allowNullish: true,
80 |           allowNumber: true,
81 |           allowRegExp: true,
82 |         },
83 |       ],
84 |     },
85 |   },
86 |   prettierConfig,
87 | );
88 | 
```

--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/BUG.yml:
--------------------------------------------------------------------------------

```yaml
 1 | name: "\U0001F41E  Bug Report"
 2 | description: Report any identified bugs in the CircleCI MCP Server.
 3 | title: 'Bug: '
 4 | labels: [bug]
 5 | # assignees: ''
 6 | body:
 7 |   - type: checkboxes
 8 |     attributes:
 9 |       label: 'Is there an existing issue for this?'
10 |       description: 'Please search [here](https://github.com/CircleCI-Public/mcp-server-circleci/issues?q=is%3Aissue) to see if an issue already exists for the bug you encountered'
11 |       options:
12 |         - label: 'I have searched the existing issues'
13 |           required: true
14 | 
15 |   - type: textarea
16 |     validations:
17 |       required: true
18 |     attributes:
19 |       label: 'Current behavior'
20 |       description: 'How does the issue manifest? What MCP tool or functionality is affected?'
21 | 
22 |   - type: input
23 |     validations:
24 |       required: true
25 |     attributes:
26 |       label: 'Minimum reproduction code'
27 |       description: 'An URL to some git repository or gist which contains the minimum needed code to reproduce the error, or the exact MCP tool that triggers the issue'
28 |       placeholder: 'https://github.com/... or tool: Find the latest failed pipeline on my branch'
29 | 
30 |   - type: textarea
31 |     attributes:
32 |       label: 'Steps to reproduce'
33 |       description: |
34 |         Detail the steps to take to replicate the issue.
35 |         Include the exact MCP tools used and any relevant context.
36 |       placeholder: |
37 |         1. Set up CircleCI API token
38 |         2. Run MCP server with tool X
39 |         3. Try to execute tool Y
40 |         4. See error...
41 | 
42 |   - type: textarea
43 |     validations:
44 |       required: true
45 |     attributes:
46 |       label: 'Expected behavior'
47 |       description: 'A clear and concise description of what you expected to happen'
48 | 
49 |   - type: markdown
50 |     attributes:
51 |       value: |
52 |         ---
53 | 
54 |   - type: input
55 |     attributes:
56 |       label: 'MCP Server CircleCI version'
57 |       description: |
58 |         Which version of `@circleci/mcp-server-circleci` are you using?
59 |       placeholder: '0.1.0'
60 | 
61 |   - type: input
62 |     attributes:
63 |       label: 'Node.js version'
64 |       description: 'Which version of Node.js are you using? Note: This project requires Node.js >= v18.0.0'
65 |       placeholder: '18.0.0'
66 | 
67 |   - type: input
68 |     attributes:
69 |       label: 'CircleCI API Token'
70 |       description: 'Do you have a valid CircleCI API token configured? (Do not share the actual token)'
71 |       placeholder: 'Yes/No'
72 | 
73 |   - type: checkboxes
74 |     attributes:
75 |       label: 'In which agents have you tested?'
76 |       options:
77 |         - label: Cursor
78 |         - label: Windsurf
79 |         - label: Claude Code
80 |         - label: Other
81 | 
82 |   - type: markdown
83 |     attributes:
84 |       value: |
85 |         ---
86 | 
87 |   - type: textarea
88 |     attributes:
89 |       label: 'Additional context'
90 |       description: |
91 |         Anything else relevant? eg: 
92 |         - Error logs
93 |         - OS version
94 |         - IDE/Editor being used
95 |         - Package manager (pnpm version)
96 |         - MCP Client details (e.g., Cursor version)
97 |         **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in.
98 | 
```

--------------------------------------------------------------------------------
/src/lib/pipeline-job-tests/getJobTests.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { getCircleCIClient } from '../../clients/client.js';
  2 | import { Pipeline } from '../../clients/schemas.js';
  3 | import { rateLimitedRequests } from '../rateLimitedRequests/index.js';
  4 | 
  5 | /**
  6 |  * Retrieves test metadata for a specific job or all jobs in the latest pipeline
  7 |  *
  8 |  * @param {Object} params - The parameters for the job tests retrieval
  9 |  * @param {string} params.projectSlug - The slug of the CircleCI project
 10 |  * @param {number} [params.pipelineNumber] - The pipeline number to fetch tests for
 11 |  * @param {number} [params.jobNumber] - The job number to fetch tests for
 12 |  * @param {string} [params.branch] - The branch to fetch tests for
 13 |  * @param {string} [params.filterByTestsResult] - The result of the tests to filter by
 14 |  */
 15 | export const getJobTests = async ({
 16 |   projectSlug,
 17 |   pipelineNumber,
 18 |   jobNumber,
 19 |   branch,
 20 |   filterByTestsResult,
 21 | }: {
 22 |   projectSlug: string;
 23 |   pipelineNumber?: number;
 24 |   jobNumber?: number;
 25 |   branch?: string;
 26 |   filterByTestsResult?: 'failure' | 'success';
 27 | }) => {
 28 |   const circleci = getCircleCIClient();
 29 |   let pipeline: Pipeline | undefined;
 30 | 
 31 |   // If jobNumber is provided, fetch the tests for the specific job
 32 |   if (jobNumber) {
 33 |     const tests = await circleci.tests.getJobTests({
 34 |       projectSlug,
 35 |       jobNumber,
 36 |     });
 37 | 
 38 |     if (!filterByTestsResult) {
 39 |       return tests;
 40 |     }
 41 | 
 42 |     return tests.filter((test) => test.result === filterByTestsResult);
 43 |   }
 44 | 
 45 |   if (pipelineNumber) {
 46 |     pipeline = await circleci.pipelines.getPipelineByNumber({
 47 |       projectSlug,
 48 |       pipelineNumber,
 49 |     });
 50 |   }
 51 | 
 52 |   // If pipelineNumber is not provided, fetch the tests for the latest pipeline
 53 |   if (!pipeline) {
 54 |     const pipelines = await circleci.pipelines.getPipelines({
 55 |       projectSlug,
 56 |       branch,
 57 |     });
 58 | 
 59 |     pipeline = pipelines?.[0];
 60 |     if (!pipeline) {
 61 |       throw new Error('Pipeline not found');
 62 |     }
 63 |   }
 64 | 
 65 |   const workflows = await circleci.workflows.getPipelineWorkflows({
 66 |     pipelineId: pipeline.id,
 67 |   });
 68 | 
 69 |   const jobs = (
 70 |     await Promise.all(
 71 |       workflows.map(async (workflow) => {
 72 |         return await circleci.jobs.getWorkflowJobs({
 73 |           workflowId: workflow.id,
 74 |         });
 75 |       }),
 76 |     )
 77 |   ).flat();
 78 | 
 79 |   const testsArrays = await rateLimitedRequests(
 80 |     jobs.map((job) => async () => {
 81 |       if (!job.job_number) {
 82 |         console.error(`Job ${job.id} has no job number`);
 83 |         return [];
 84 |       }
 85 | 
 86 |       try {
 87 |         const tests = await circleci.tests.getJobTests({
 88 |           projectSlug,
 89 |           jobNumber: job.job_number,
 90 |         });
 91 |         return tests;
 92 |       } catch (error) {
 93 |         if (error instanceof Error && error.message.includes('404')) {
 94 |           console.error(`Job ${job.job_number} not found:`, error);
 95 |           return [];
 96 |         }
 97 |         if (error instanceof Error && error.message.includes('429')) {
 98 |           console.error(
 99 |             `Rate limited for job request ${job.job_number}:`,
100 |             error,
101 |           );
102 |           return [];
103 |         }
104 |         throw error;
105 |       }
106 |     }),
107 |   );
108 | 
109 |   const tests = testsArrays.flat();
110 | 
111 |   if (!filterByTestsResult) {
112 |     return tests;
113 |   }
114 | 
115 |   return tests.filter((test) => test.result === filterByTestsResult);
116 | };
117 | 
```

--------------------------------------------------------------------------------
/src/tools/runRollbackPipeline/handler.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
  2 | import { runRollbackPipelineInputSchema } from './inputSchema.js';
  3 | import mcpErrorOutput from '../../lib/mcpErrorOutput.js';
  4 | import { getCircleCIClient } from '../../clients/client.js';
  5 | 
  6 | export const runRollbackPipeline: ToolCallback<{
  7 |   params: typeof runRollbackPipelineInputSchema;
  8 | }> = async (args: any) => {
  9 |   const {
 10 |     projectSlug,
 11 |     projectID: providedProjectID,
 12 |     environmentName,
 13 |     componentName,
 14 |     currentVersion,
 15 |     targetVersion,
 16 |     namespace,
 17 |     reason,
 18 |     parameters,
 19 |   } = args.params ?? {};
 20 | 
 21 | 
 22 |   // Init the client and get the base URL
 23 |   const circleci = getCircleCIClient();
 24 |   
 25 |   // Resolve project ID from projectSlug or use provided projectID
 26 |   let projectID: string;
 27 |   try {
 28 |     if (providedProjectID) {
 29 |       projectID = providedProjectID;
 30 |     } else if (projectSlug) {
 31 |       const { id: resolvedProjectId } = await circleci.projects.getProject({
 32 |         projectSlug,
 33 |       });
 34 |       projectID = resolvedProjectId;
 35 |     } else {
 36 |       return mcpErrorOutput('Either projectSlug or projectID must be provided');
 37 |     }
 38 |   } catch (error) {
 39 |     const errorMessage = projectSlug
 40 |       ? `Failed to resolve project information for ${projectSlug}. Please verify the project slug is correct.`
 41 |       : `Failed to resolve project information for project ID ${providedProjectID}. Please verify the project ID is correct.`;
 42 |     
 43 |     return mcpErrorOutput(`${errorMessage} ${error instanceof Error ? error.message : 'Unknown error'}`);
 44 |   }
 45 |   
 46 |   // First, check if the project has a rollback pipeline definition configured
 47 |   try {
 48 |     const deploySettings = await circleci.deploys.fetchProjectDeploySettings({
 49 |       projectID,
 50 |     });
 51 | 
 52 |     if (!deploySettings.rollback_pipeline_definition_id) {
 53 |       return {
 54 |         content: [
 55 |           {
 56 |             type: 'text',
 57 |             text: 'No rollback pipeline definition found for this project. You may need to configure a rollback pipeline first using https://circleci.com/docs/deploy/rollback-a-project-using-the-rollback-pipeline/ or you can trigger a rollback by workflow rerun.',
 58 |           },
 59 |         ],
 60 |       };
 61 |     }
 62 |   } catch (error) {
 63 |     return mcpErrorOutput(
 64 |       `Failed to fetch rollback pipeline definition: ${error instanceof Error ? error.message : 'Unknown error'}`,
 65 |     );
 66 |   }
 67 |   
 68 |   // Check if this is a new rollback request with required fields
 69 | 
 70 |   const rollbackRequest = {
 71 |     environment_name: environmentName,
 72 |     component_name: componentName,
 73 |     current_version: currentVersion,
 74 |     target_version: targetVersion,
 75 |     ...(namespace && { namespace }),
 76 |     ...(reason && { reason }),
 77 |     ...(parameters && { parameters }),
 78 |   };
 79 | 
 80 |   try {
 81 |     const rollbackResponse = await circleci.deploys.runRollbackPipeline({
 82 |       projectID,
 83 |       rollbackRequest,
 84 |     });
 85 | 
 86 |     return {
 87 |       content: [
 88 |         {
 89 |           type: 'text',
 90 |           text: `Rollback initiated successfully. ID: ${rollbackResponse.id}, Type: ${rollbackResponse.rollback_type}`,
 91 |         },
 92 |       ],
 93 |     };
 94 |   } catch (error) {
 95 |     return mcpErrorOutput(
 96 |       `Failed to initiate rollback: ${error instanceof Error ? error.message : 'Unknown error'}`,
 97 |     );
 98 |   }
 99 | };
100 | 
```

--------------------------------------------------------------------------------
/src/tools/getJobTestResults/tool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { getJobTestResultsInputSchema } from './inputSchema.js';
 2 | import { option1DescriptionBranchRequired } from '../shared/constants.js';
 3 | 
 4 | export const getJobTestResultsTool = {
 5 |   name: 'get_job_test_results' as const,
 6 |   description: `
 7 |     This tool retrieves test metadata for a CircleCI job.
 8 | 
 9 |     PRIORITY USE CASE:
10 |     - When asked "are tests passing in CI?" or similar questions about test status
11 |     - When asked to "fix failed tests in CI" or help with CI test failures
12 |     - Use this tool to check if tests are passing in CircleCI and identify failed tests
13 |     
14 |     Common use cases:
15 |     - Get test metadata for a specific job
16 |     - Get test metadata for all jobs in a project
17 |     - Get test metadata for a specific branch
18 |     - Get test metadata for a specific pipeline
19 |     - Get test metadata for a specific workflow
20 |     - Get test metadata for a specific job
21 | 
22 |     CRITICAL REQUIREMENTS:
23 |     1. Truncation Handling (HIGHEST PRIORITY):
24 |        - ALWAYS check for <MCPTruncationWarning> in the output
25 |        - When present, you MUST start your response with:
26 |          "WARNING: The test results have been truncated. Only showing the most recent entries. Some test data may not be visible."
27 |        - Only proceed with test result analysis after acknowledging the truncation
28 | 
29 |     2. Test Result Filtering:
30 |        - Use filterByTestsResult parameter to filter test results:
31 |          * filterByTestsResult: 'failure' - Show only failed tests
32 |          * filterByTestsResult: 'success' - Show only successful tests
33 |        - When looking for failed tests, ALWAYS set filterByTestsResult to 'failure'
34 |        - When checking if tests are passing, set filterByTestsResult to 'success'
35 | 
36 |     Input options (EXACTLY ONE of these THREE options must be used):
37 | 
38 |     ${option1DescriptionBranchRequired}
39 | 
40 |     Option 2 - Direct URL (provide ONE of these):
41 |     - projectURL: The URL of the CircleCI job in any of these formats:
42 |       * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/789
43 |       * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def
44 |       * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123
45 | 
46 |     Option 3 - Project Detection (ALL of these must be provided together):
47 |     - workspaceRoot: The absolute path to the workspace root
48 |     - gitRemoteURL: The URL of the git remote repository
49 |     - branch: The name of the current branch
50 |     
51 |     For simple test status checks (e.g., "are tests passing in CI?") or fixing failed tests, prefer Option 1 with a recent pipeline URL if available.
52 | 
53 |     Additional Requirements:
54 |     - Never call this tool with incomplete parameters
55 |     - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects and include the branch parameter
56 |     - If using Option 2, the URL MUST be provided by the user - do not attempt to construct or guess URLs
57 |     - If using Option 3, ALL THREE parameters (workspaceRoot, gitRemoteURL, branch) must be provided
58 |     - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call
59 |     `,
60 |   inputSchema: getJobTestResultsInputSchema,
61 | };
62 | 
```

--------------------------------------------------------------------------------
/src/tools/listFollowedProjects/handler.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
 2 | import { listFollowedProjects } from './handler.js';
 3 | import * as clientModule from '../../clients/client.js';
 4 | 
 5 | vi.mock('../../clients/client.js');
 6 | 
 7 | describe('listFollowedProjects handler', () => {
 8 |   const mockCircleCIPrivateClient = {
 9 |     me: {
10 |       getFollowedProjects: vi.fn(),
11 |     },
12 |   };
13 | 
14 |   beforeEach(() => {
15 |     vi.resetAllMocks();
16 |     vi.spyOn(clientModule, 'getCircleCIPrivateClient').mockReturnValue(
17 |       mockCircleCIPrivateClient as any,
18 |     );
19 |   });
20 | 
21 |   it('should return an error when no projects are found', async () => {
22 |     mockCircleCIPrivateClient.me.getFollowedProjects.mockResolvedValue({
23 |       projects: [],
24 |       reachedMaxPagesOrTimeout: false,
25 |     });
26 | 
27 |     const args = { params: {} } as any;
28 |     const controller = new AbortController();
29 |     const response = await listFollowedProjects(args, {
30 |       signal: controller.signal,
31 |     });
32 | 
33 |     expect(response).toHaveProperty('content');
34 |     expect(response).toHaveProperty('isError', true);
35 |     expect(Array.isArray(response.content)).toBe(true);
36 |     expect(response.content[0]).toHaveProperty('type', 'text');
37 |     expect(typeof response.content[0].text).toBe('string');
38 |     expect(response.content[0].text).toContain('No projects found');
39 |   });
40 | 
41 |   it('should return a list of followed projects', async () => {
42 |     const mockProjects = [
43 |       { name: 'Project 1', slug: 'gh/org/project1' },
44 |       { name: 'Project 2', slug: 'gh/org/project2' },
45 |     ];
46 | 
47 |     mockCircleCIPrivateClient.me.getFollowedProjects.mockResolvedValue({
48 |       projects: mockProjects,
49 |       reachedMaxPagesOrTimeout: false,
50 |     });
51 | 
52 |     const args = { params: {} } as any;
53 |     const controller = new AbortController();
54 |     const response = await listFollowedProjects(args, {
55 |       signal: controller.signal,
56 |     });
57 | 
58 |     expect(response).toHaveProperty('content');
59 |     expect(Array.isArray(response.content)).toBe(true);
60 |     expect(response.content[0]).toHaveProperty('type', 'text');
61 |     expect(typeof response.content[0].text).toBe('string');
62 |     expect(response.content[0].text).toContain('Projects followed:');
63 |     expect(response.content[0].text).toContain('Project 1');
64 |     expect(response.content[0].text).toContain('gh/org/project1');
65 |     expect(response.content[0].text).toContain('Project 2');
66 |     expect(response.content[0].text).toContain('gh/org/project2');
67 |   });
68 | 
69 |   it('should add a warning when not all projects were included', async () => {
70 |     const mockProjects = [{ name: 'Project 1', slug: 'gh/org/project1' }];
71 | 
72 |     mockCircleCIPrivateClient.me.getFollowedProjects.mockResolvedValue({
73 |       projects: mockProjects,
74 |       reachedMaxPagesOrTimeout: true,
75 |     });
76 | 
77 |     const args = { params: {} } as any;
78 |     const controller = new AbortController();
79 |     const response = await listFollowedProjects(args, {
80 |       signal: controller.signal,
81 |     });
82 | 
83 |     expect(response).toHaveProperty('content');
84 |     expect(Array.isArray(response.content)).toBe(true);
85 |     expect(response.content[0]).toHaveProperty('type', 'text');
86 |     expect(typeof response.content[0].text).toBe('string');
87 |     expect(response.content[0].text).toContain(
88 |       'WARNING: Not all projects were included',
89 |     );
90 |     expect(response.content[0].text).toContain('Project 1');
91 |     expect(response.content[0].text).toContain('gh/org/project1');
92 |   });
93 | });
94 | 
```

--------------------------------------------------------------------------------
/src/clients/circleci/workflows.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { Workflow, RerunWorkflow } from '../schemas.js';
  2 | import { HTTPClient } from './httpClient.js';
  3 | import { defaultPaginationOptions } from './index.js';
  4 | import { z } from 'zod';
  5 | 
  6 | const WorkflowResponseSchema = z.object({
  7 |   items: z.array(Workflow),
  8 |   next_page_token: z.string().nullable(),
  9 | });
 10 | 
 11 | export class WorkflowsAPI {
 12 |   protected client: HTTPClient;
 13 | 
 14 |   constructor(httpClient: HTTPClient) {
 15 |     this.client = httpClient;
 16 |   }
 17 | 
 18 |   /**
 19 |    * Get all workflows for a pipeline with pagination support
 20 |    * @param params Configuration parameters
 21 |    * @param params.pipelineId The pipeline ID
 22 |    * @param params.options Optional configuration for pagination limits
 23 |    * @param params.options.maxPages Maximum number of pages to fetch (default: 5)
 24 |    * @param params.options.timeoutMs Timeout in milliseconds (default: 10000)
 25 |    * @returns All workflows from the pipeline
 26 |    * @throws Error if timeout or max pages reached
 27 |    */
 28 |   async getPipelineWorkflows({
 29 |     pipelineId,
 30 |     options = {},
 31 |   }: {
 32 |     pipelineId: string;
 33 |     options?: {
 34 |       maxPages?: number;
 35 |       timeoutMs?: number;
 36 |     };
 37 |   }): Promise<Workflow[]> {
 38 |     const {
 39 |       maxPages = defaultPaginationOptions.maxPages,
 40 |       timeoutMs = defaultPaginationOptions.timeoutMs,
 41 |     } = options;
 42 | 
 43 |     const startTime = Date.now();
 44 |     const allWorkflows: Workflow[] = [];
 45 |     let nextPageToken: string | null = null;
 46 |     let pageCount = 0;
 47 | 
 48 |     do {
 49 |       // Check timeout
 50 |       if (Date.now() - startTime > timeoutMs) {
 51 |         throw new Error(`Timeout reached after ${timeoutMs}ms`);
 52 |       }
 53 | 
 54 |       // Check page limit
 55 |       if (pageCount >= maxPages) {
 56 |         throw new Error(`Maximum number of pages (${maxPages}) reached`);
 57 |       }
 58 | 
 59 |       const params = nextPageToken ? { 'page-token': nextPageToken } : {};
 60 |       const rawResult = await this.client.get<unknown>(
 61 |         `/pipeline/${pipelineId}/workflow`,
 62 |         params,
 63 |       );
 64 | 
 65 |       // Validate the response against our WorkflowResponse schema
 66 |       const result = WorkflowResponseSchema.parse(rawResult);
 67 | 
 68 |       pageCount++;
 69 |       allWorkflows.push(...result.items);
 70 |       nextPageToken = result.next_page_token;
 71 |     } while (nextPageToken);
 72 | 
 73 |     return allWorkflows;
 74 |   }
 75 | 
 76 |   /**
 77 |    * Get a workflow
 78 |    * @param workflowId The workflowId
 79 |    * @returns Information about the workflow
 80 |    * @throws Error if the request fails
 81 |    */
 82 |   async getWorkflow({ workflowId }: { workflowId: string }): Promise<Workflow> {
 83 |     const rawResult = await this.client.get<unknown>(`/workflow/${workflowId}`);
 84 | 
 85 |     const parsedResult = Workflow.safeParse(rawResult);
 86 |     if (!parsedResult.success) {
 87 |       console.error('Parse error:', parsedResult.error);
 88 |       throw new Error('Failed to parse workflow response');
 89 |     }
 90 |     return parsedResult.data;
 91 |   }
 92 | 
 93 |   /**
 94 |    * Rerun workflow from failed job or start
 95 |    * @param workflowId The workflowId
 96 |    * @param fromFailed Whether to rerun from failed job or start
 97 |    * @returns A new workflowId
 98 |    * @throws Error if the request fails
 99 |    */
100 |   async rerunWorkflow({
101 |     workflowId,
102 |     fromFailed,
103 |   }: {
104 |     workflowId: string;
105 |     fromFailed?: boolean;
106 |   }): Promise<RerunWorkflow> {
107 |     const rawResult = await this.client.post<unknown>(
108 |       `/workflow/${workflowId}/rerun`,
109 |       {
110 |         from_failed: fromFailed,
111 |       },
112 |     );
113 |     const parsedResult = RerunWorkflow.safeParse(rawResult);
114 |     if (!parsedResult.success) {
115 |       throw new Error('Failed to parse workflow response');
116 |     }
117 | 
118 |     return parsedResult.data;
119 |   }
120 | }
121 | 
```

--------------------------------------------------------------------------------
/src/tools/downloadUsageApiData/handler.test.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
 2 | import { downloadUsageApiData } from './handler.js';
 3 | import * as getUsageApiDataModule from '../../lib/usage-api/getUsageApiData.js';
 4 | 
 5 | vi.mock('../../lib/usage-api/getUsageApiData.js');
 6 | 
 7 | describe('downloadUsageApiData handler', () => {
 8 |   const ORG_ID = 'org123';
 9 |   const OUTPUT_DIR = '/tmp';
10 | 
11 |   let getUsageApiDataSpy: any;
12 | 
13 |   beforeEach(() => {
14 |     vi.clearAllMocks();
15 |     getUsageApiDataSpy = vi.spyOn(getUsageApiDataModule, 'getUsageApiData').mockResolvedValue({
16 |       content: [{ type: 'text', text: 'Success' }],
17 |     } as any);
18 |   });
19 | 
20 |   it('should call getUsageApiData with correctly formatted dates', async () => {
21 |     const startDate = '2024-06-01';
22 |     const endDate = '2024-06-15';
23 |     
24 |     await downloadUsageApiData({ params: { orgId: ORG_ID, startDate, endDate, outputDir: OUTPUT_DIR } }, undefined as any);
25 | 
26 |     expect(getUsageApiDataSpy).toHaveBeenCalledWith({
27 |       orgId: ORG_ID,
28 |       startDate: '2024-06-01T00:00:00Z',
29 |       endDate: '2024-06-15T23:59:59Z',
30 |       outputDir: OUTPUT_DIR,
31 |       jobId: undefined,
32 |     });
33 |   });
34 | 
35 |   it('should return an error if the date range is over 32 days', async () => {
36 |     const startDate = '2024-01-01';
37 |     const endDate = '2024-02-02';
38 | 
39 |     const result = await downloadUsageApiData({ params: { orgId: ORG_ID, startDate, endDate, outputDir: OUTPUT_DIR } }, undefined as any);
40 | 
41 |     expect(getUsageApiDataSpy).not.toHaveBeenCalled();
42 |     expect((result as any).isError).toBe(true);
43 |     expect((result as any).content[0].text).toContain('maximum allowed date range for the usage API is 32 days');
44 |   });
45 | 
46 |   it('should return an error for an invalid date format', async () => {
47 |     const startDate = 'not-a-date';
48 |     const endDate = '2024-06-15';
49 | 
50 |     const result = await downloadUsageApiData({ params: { orgId: ORG_ID, startDate, endDate, outputDir: OUTPUT_DIR } }, undefined as any);
51 | 
52 |     expect(getUsageApiDataSpy).not.toHaveBeenCalled();
53 |     expect((result as any).isError).toBe(true);
54 |     expect((result as any).content[0].text).toContain('Invalid date format');
55 |   });
56 | 
57 |   it('should return an error if the end date is before the start date', async () => {
58 |     const startDate = '2024-06-15';
59 |     const endDate = '2024-06-01';
60 | 
61 |     const result = await downloadUsageApiData({ params: { orgId: ORG_ID, startDate, endDate, outputDir: OUTPUT_DIR } }, undefined as any);
62 | 
63 |     expect(getUsageApiDataSpy).not.toHaveBeenCalled();
64 |     expect((result as any).isError).toBe(true);
65 |     expect((result as any).content[0].text).toContain('end date must be after or equal to the start date');
66 |   });
67 | 
68 |   it('should allow polling existing job with only jobId and no dates', async () => {
69 |     const result = await downloadUsageApiData(
70 |       { params: { orgId: ORG_ID, jobId: 'job-abc', outputDir: OUTPUT_DIR } },
71 |       undefined as any,
72 |     );
73 | 
74 |     expect(getUsageApiDataSpy).toHaveBeenCalledWith({
75 |       orgId: ORG_ID,
76 |       startDate: undefined,
77 |       endDate: undefined,
78 |       outputDir: OUTPUT_DIR,
79 |       jobId: 'job-abc',
80 |     });
81 |     expect((result as any).content[0].text).toContain('Success');
82 |   });
83 | 
84 |   it('should error when neither jobId nor both dates are provided', async () => {
85 |     const result = await downloadUsageApiData(
86 |       { params: { orgId: ORG_ID, outputDir: OUTPUT_DIR } },
87 |       undefined as any,
88 |     );
89 |     expect(getUsageApiDataSpy).not.toHaveBeenCalled();
90 |     expect((result as any).isError).toBe(true);
91 |     expect((result as any).content[0].text).toContain('Provide either jobId');
92 |   });
93 | }); 
```

--------------------------------------------------------------------------------
/src/tools/createPromptTemplate/handler.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 | import {
  3 |   contextSchemaKey,
  4 |   createPromptTemplate,
  5 |   promptOriginKey,
  6 |   promptTemplateKey,
  7 |   modelKey,
  8 | } from './handler.js';
  9 | import { CircletClient } from '../../clients/circlet/index.js';
 10 | import {
 11 |   defaultModel,
 12 |   PromptOrigin,
 13 |   PromptWorkbenchToolName,
 14 | } from '../shared/constants.js';
 15 | 
 16 | // Mock dependencies
 17 | vi.mock('../../clients/circlet/index.js');
 18 | 
 19 | describe('createPromptTemplate handler', () => {
 20 |   beforeEach(() => {
 21 |     vi.resetAllMocks();
 22 |   });
 23 | 
 24 |   it('should return a valid MCP response with template, context schema, and prompt origin', async () => {
 25 |     const mockCreatePromptTemplate = vi.fn().mockResolvedValue({
 26 |       template: 'This is a test template with {{variable}}',
 27 |       contextSchema: {
 28 |         variable: 'Description of the variable',
 29 |       },
 30 |     });
 31 | 
 32 |     const mockCircletInstance = {
 33 |       circlet: {
 34 |         createPromptTemplate: mockCreatePromptTemplate,
 35 |       },
 36 |     };
 37 | 
 38 |     vi.mocked(CircletClient).mockImplementation(
 39 |       () => mockCircletInstance as any,
 40 |     );
 41 | 
 42 |     const args = {
 43 |       params: {
 44 |         prompt: 'Create a test prompt template',
 45 |         promptOrigin: PromptOrigin.requirements,
 46 |         model: defaultModel,
 47 |       },
 48 |     };
 49 | 
 50 |     const controller = new AbortController();
 51 |     const response = await createPromptTemplate(args, {
 52 |       signal: controller.signal,
 53 |     });
 54 | 
 55 |     expect(mockCreatePromptTemplate).toHaveBeenCalledWith(
 56 |       'Create a test prompt template',
 57 |       PromptOrigin.requirements,
 58 |     );
 59 | 
 60 |     expect(response).toHaveProperty('content');
 61 |     expect(Array.isArray(response.content)).toBe(true);
 62 |     expect(response.content[0]).toHaveProperty('type', 'text');
 63 |     expect(typeof response.content[0].text).toBe('string');
 64 | 
 65 |     const responseText = response.content[0].text;
 66 | 
 67 |     // Verify promptOrigin is included
 68 |     expect(responseText).toContain(
 69 |       `${promptOriginKey}: ${PromptOrigin.requirements}`,
 70 |     );
 71 | 
 72 |     // Verify model is included
 73 |     expect(responseText).toContain(`${modelKey}: ${defaultModel}`);
 74 | 
 75 |     // Verify template and schema are present
 76 |     expect(responseText).toContain(
 77 |       `${promptTemplateKey}: This is a test template with {{variable}}`,
 78 |     );
 79 |     expect(responseText).toContain(`${contextSchemaKey}: {`);
 80 |     expect(responseText).toContain('"variable": "Description of the variable"');
 81 | 
 82 |     // Verify next steps format
 83 |     expect(responseText).toContain('NEXT STEP:');
 84 |     expect(responseText).toContain(
 85 |       `${PromptWorkbenchToolName.recommend_prompt_template_tests}`,
 86 |     );
 87 |     expect(responseText).toContain(
 88 |       `template: the \`${promptTemplateKey}\` above`,
 89 |     );
 90 |     expect(responseText).toContain(
 91 |       `${contextSchemaKey}: the \`${contextSchemaKey}\` above`,
 92 |     );
 93 |     expect(responseText).toContain(
 94 |       `${promptOriginKey}: the \`${promptOriginKey}\` above`,
 95 |     );
 96 |     expect(responseText).toContain(`${modelKey}: the \`${modelKey}\` above`);
 97 |   });
 98 | 
 99 |   it('should handle errors from CircletClient', async () => {
100 |     const mockCircletInstance = {
101 |       circlet: {
102 |         createPromptTemplate: vi.fn().mockRejectedValue(new Error('API error')),
103 |       },
104 |     };
105 | 
106 |     vi.mocked(CircletClient).mockImplementation(
107 |       () => mockCircletInstance as any,
108 |     );
109 | 
110 |     const args = {
111 |       params: {
112 |         prompt: 'Create a test prompt template',
113 |         promptOrigin: PromptOrigin.requirements,
114 |         model: defaultModel,
115 |       },
116 |     };
117 | 
118 |     const controller = new AbortController();
119 | 
120 |     await expect(
121 |       createPromptTemplate(args, { signal: controller.signal }),
122 |     ).rejects.toThrow('API error');
123 |   });
124 | });
125 | 
```

--------------------------------------------------------------------------------
/src/clients/circleci/httpClient.ts:
--------------------------------------------------------------------------------

```typescript
  1 | export class HTTPClient {
  2 |   protected baseURL: string;
  3 |   protected headers: HeadersInit;
  4 | 
  5 |   constructor(
  6 |     baseURL: string,
  7 |     apiPath: string,
  8 |     options?: {
  9 |       headers?: HeadersInit;
 10 |     },
 11 |   ) {
 12 |     const { headers } = options || {};
 13 |     this.baseURL = baseURL + apiPath;
 14 |     this.headers = headers || {};
 15 |   }
 16 | 
 17 |   /**
 18 |    * Helper method to build URL with query parameters
 19 |    */
 20 |   protected buildURL(path: string, params?: Record<string, any>): URL {
 21 |     const url = new URL(`${this.baseURL}${path}`);
 22 |     if (params && typeof params === 'object') {
 23 |       Object.entries(params).forEach(([key, value]) => {
 24 |         if (value !== undefined) {
 25 |           if (Array.isArray(value)) {
 26 |             value.forEach((v) => {
 27 |               url.searchParams.append(key, String(v));
 28 |             });
 29 |           } else if (typeof value === 'object') {
 30 |             url.searchParams.append(key, JSON.stringify(value));
 31 |           } else {
 32 |             url.searchParams.append(key, String(value));
 33 |           }
 34 |         }
 35 |       });
 36 |     }
 37 |     return url;
 38 |   }
 39 | 
 40 |   /**
 41 |    * Helper method to handle API responses
 42 |    */
 43 |   protected async handleResponse<T>(response: Response): Promise<T> {
 44 |     if (!response.ok) {
 45 |       const errorData = await response.json().catch(() => ({}));
 46 |       if (response.status >= 400 && response.status < 600) {
 47 |         throw new Error(
 48 |           `CircleCI API Error: ${response.status} \nURL: ${response.url} \nMessage: ${errorData.message || response.statusText}`,
 49 |         );
 50 |       }
 51 |       throw new Error('No response received from CircleCI API');
 52 |     }
 53 | 
 54 |     return response.text().then((text) => {
 55 |       try {
 56 |         return JSON.parse(text) as T;
 57 |       } catch {
 58 |         return text as unknown as T;
 59 |       }
 60 |     });
 61 |   }
 62 | 
 63 |   /**
 64 |    * Helper method to make GET requests
 65 |    */
 66 |   async get<T>(path: string, params?: Record<string, any>) {
 67 |     const url = this.buildURL(path, params);
 68 |     const response = await fetch(url.toString(), {
 69 |       method: 'GET',
 70 |       headers: this.headers,
 71 |     });
 72 | 
 73 |     return this.handleResponse<T>(response);
 74 |   }
 75 | 
 76 |   /**
 77 |    * Helper method to make POST requests
 78 |    */
 79 |   async post<T>(
 80 |     path: string,
 81 |     data?: Record<string, any>,
 82 |     params?: Record<string, any>,
 83 |   ) {
 84 |     const url = this.buildURL(path, params);
 85 |     const response = await fetch(url.toString(), {
 86 |       method: 'POST',
 87 |       headers: this.headers,
 88 |       body: data ? JSON.stringify(data) : undefined,
 89 |     });
 90 | 
 91 |     return this.handleResponse<T>(response);
 92 |   }
 93 | 
 94 |   /**
 95 |    * Helper method to make DELETE requests
 96 |    */
 97 |   async delete<T>(path: string, params?: Record<string, any>) {
 98 |     const url = this.buildURL(path, params);
 99 |     const response = await fetch(url.toString(), {
100 |       method: 'DELETE',
101 |       headers: this.headers,
102 |     });
103 | 
104 |     return this.handleResponse<T>(response);
105 |   }
106 | 
107 |   /**
108 |    * Helper method to make PUT requests
109 |    */
110 |   async put<T>(
111 |     path: string,
112 |     data?: Record<string, any>,
113 |     params?: Record<string, any>,
114 |   ) {
115 |     const url = this.buildURL(path, params);
116 |     const response = await fetch(url.toString(), {
117 |       method: 'PUT',
118 |       headers: this.headers,
119 |       body: data ? JSON.stringify(data) : undefined,
120 |     });
121 | 
122 |     return this.handleResponse<T>(response);
123 |   }
124 | 
125 |   /**
126 |    * Helper method to make PATCH requests
127 |    */
128 |   async patch<T>(
129 |     path: string,
130 |     data?: Record<string, any>,
131 |     params?: Record<string, any>,
132 |   ) {
133 |     const url = this.buildURL(path, params);
134 |     const response = await fetch(url.toString(), {
135 |       method: 'PATCH',
136 |       headers: this.headers,
137 |       body: data ? JSON.stringify(data) : undefined,
138 |     });
139 | 
140 |     return this.handleResponse<T>(response);
141 |   }
142 | }
143 | 
```

--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------

```yaml
  1 | version: 2.1
  2 | 
  3 | orbs:
  4 |   node: circleci/[email protected]
  5 |   docker: circleci/[email protected]
  6 | 
  7 | commands:
  8 |   setup:
  9 |     steps:
 10 |       - checkout
 11 |       - run:
 12 |           name: Extract package info
 13 |           command: |
 14 |             PACKAGE_NAME="$(jq --raw-output .name package.json)"
 15 |             PACKAGE_VERSION="$(jq --raw-output .version package.json)"
 16 |             FULL_IDENTIFIER="$PACKAGE_NAME@$PACKAGE_VERSION"
 17 |             
 18 |             echo "export PACKAGE_NAME=$PACKAGE_NAME" >> $BASH_ENV
 19 |             echo "export PACKAGE_VERSION=$PACKAGE_VERSION" >> $BASH_ENV
 20 |             echo "export FULL_IDENTIFIER=$FULL_IDENTIFIER" >> $BASH_ENV
 21 |             
 22 |             echo "Package: $PACKAGE_NAME"
 23 |             echo "Version: $PACKAGE_VERSION"
 24 |             echo "Full identifier: $FULL_IDENTIFIER"
 25 | 
 26 |   login:
 27 |     steps:
 28 |       - run: echo "//registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN" >> ~/.npmrc
 29 | 
 30 |   install-deps:
 31 |     steps:
 32 |       - node/install-packages:
 33 |           pkg-manager: pnpm
 34 |           cache-path: node_modules
 35 |           override-ci-command: pnpm install
 36 | 
 37 | executors:
 38 |   node-executor:
 39 |     docker:
 40 |       - image: cimg/node:22.14
 41 |   
 42 |   docker-executor:
 43 |     machine:
 44 |       image: ubuntu-2404:current
 45 |       docker_layer_caching: true
 46 | 
 47 | jobs:
 48 |   build:
 49 |     executor: node-executor
 50 |     steps:
 51 |       - setup
 52 |       - install-deps
 53 |       - run:
 54 |           name: Build
 55 |           command: pnpm build
 56 |       - persist_to_workspace:
 57 |           root: .
 58 |           paths:
 59 |             - .
 60 | 
 61 |   test:
 62 |     executor: node-executor
 63 |     steps:
 64 |       - attach_workspace:
 65 |           at: .
 66 |       - install-deps
 67 |       - run:
 68 |           name: Run Tests
 69 |           command: pnpm test:run
 70 | 
 71 |   lint:
 72 |     executor: node-executor
 73 |     steps:
 74 |       - attach_workspace:
 75 |           at: .
 76 |       - install-deps
 77 |       - run:
 78 |           name: Run Linting
 79 |           command: pnpm lint
 80 |       - run:
 81 |           name: Type Check
 82 |           command: pnpm typecheck
 83 | 
 84 |   publish-release:
 85 |     executor: node-executor
 86 |     steps:
 87 |       - setup
 88 |       - install-deps
 89 |       - attach_workspace:
 90 |           at: .
 91 |       - login
 92 |       - run:
 93 |           name: Publish npm Package
 94 |           command: |
 95 |             echo "Checking for published version: $FULL_IDENTIFIER..."
 96 |             if ! pnpm view $FULL_IDENTIFIER --json > /dev/null 2>&1; then
 97 |               echo "Publishing $FULL_IDENTIFIER…"
 98 |               pnpm publish --no-git-checks
 99 |             else
100 |               echo "$FULL_IDENTIFIER already published. Doing nothing."
101 |             fi
102 | 
103 |   publish-docker-image:
104 |     executor: docker-executor
105 |     steps:
106 |       - setup
107 |       - attach_workspace:
108 |           at: .
109 |       - run:
110 |           name: Set up Docker Buildx
111 |           command: |
112 |             docker buildx create --name multiarch --use
113 |             docker buildx inspect --bootstrap
114 |       - docker/check
115 |       - run:
116 |           name: Build and push multi-architecture Docker image
117 |           command: |
118 |             docker buildx build --platform linux/amd64,linux/arm64 \
119 |               -t ${DOCKER_NAMESPACE}/mcp-server-circleci:latest \
120 |               -t ${DOCKER_NAMESPACE}/mcp-server-circleci:${PACKAGE_VERSION} \
121 |               -t ${DOCKER_NAMESPACE}/mcp-server-circleci:${CIRCLE_SHA1} \
122 |               --push .
123 | 
124 | workflows:
125 |   build-and-test:
126 |     jobs:
127 |       - build
128 |       - test:
129 |           requires:
130 |             - build
131 |       - lint:
132 |           requires:
133 |             - build
134 |       - publish-release:
135 |           context: npm-registry-public
136 |           filters:
137 |             branches:
138 |               only: main
139 |           requires:
140 |             - build
141 |             - lint
142 |             - test
143 |       - publish-docker-image:
144 |           context: mcp-server-docker-publish
145 |           filters:
146 |             branches:
147 |               only: main
148 |           requires:
149 |             - build
150 |             - lint
151 |             - test
152 | 
```

--------------------------------------------------------------------------------
/src/tools/runPipeline/handler.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
  2 | import {
  3 |   getBranchFromURL,
  4 |   getProjectSlugFromURL,
  5 |   identifyProjectSlug,
  6 | } from '../../lib/project-detection/index.js';
  7 | import { runPipelineInputSchema } from './inputSchema.js';
  8 | import mcpErrorOutput from '../../lib/mcpErrorOutput.js';
  9 | import { getCircleCIClient } from '../../clients/client.js';
 10 | import { getAppURL } from '../../clients/circleci/index.js';
 11 | 
 12 | export const runPipeline: ToolCallback<{
 13 |   params: typeof runPipelineInputSchema;
 14 | }> = async (args) => {
 15 |   const {
 16 |     workspaceRoot,
 17 |     gitRemoteURL,
 18 |     branch,
 19 |     configContent,
 20 |     projectURL,
 21 |     pipelineChoiceName,
 22 |     projectSlug: inputProjectSlug,
 23 |   } = args.params ?? {};
 24 | 
 25 |   let projectSlug: string | undefined;
 26 |   let branchFromURL: string | undefined;
 27 |   const baseURL = getAppURL();
 28 |   if (inputProjectSlug) {
 29 |     if (!branch) {
 30 |       return mcpErrorOutput(
 31 |         'Branch not provided. When using projectSlug, a branch must also be specified.',
 32 |       );
 33 |     }
 34 |     projectSlug = inputProjectSlug;
 35 |   } else if (projectURL) {
 36 |     projectSlug = getProjectSlugFromURL(projectURL);
 37 |     branchFromURL = getBranchFromURL(projectURL);
 38 |   } else if (workspaceRoot && gitRemoteURL && branch) {
 39 |     projectSlug = await identifyProjectSlug({
 40 |       gitRemoteURL,
 41 |     });
 42 |   } else {
 43 |     return mcpErrorOutput(
 44 |       'Missing required inputs. Please provide either: 1) projectSlug with branch, 2) projectURL, or 3) workspaceRoot with gitRemoteURL and branch.',
 45 |     );
 46 |   }
 47 | 
 48 |   if (!projectSlug) {
 49 |     return mcpErrorOutput(`
 50 |           Project not found. Ask the user to provide the inputs user can provide based on the tool description.
 51 | 
 52 |           Project slug: ${projectSlug}
 53 |           Git remote URL: ${gitRemoteURL}
 54 |           Branch: ${branch}
 55 |           `);
 56 |   }
 57 |   const foundBranch = branchFromURL || branch;
 58 |   if (!foundBranch) {
 59 |     return mcpErrorOutput(
 60 |       'No branch provided. Ask the user to provide the branch.',
 61 |     );
 62 |   }
 63 | 
 64 |   const circleci = getCircleCIClient();
 65 |   const { id: projectId } = await circleci.projects.getProject({
 66 |     projectSlug,
 67 |   });
 68 |   const pipelineDefinitions = await circleci.pipelines.getPipelineDefinitions({
 69 |     projectId,
 70 |   });
 71 | 
 72 |   const pipelineChoices = [
 73 |     ...pipelineDefinitions.map((definition) => ({
 74 |       name: definition.name,
 75 |       definitionId: definition.id,
 76 |     })),
 77 |   ];
 78 | 
 79 |   if (pipelineChoices.length === 0) {
 80 |     return mcpErrorOutput(
 81 |       'No pipeline definitions found. Please make sure your project is set up on CircleCI to run pipelines.',
 82 |     );
 83 |   }
 84 | 
 85 |   const formattedPipelineChoices = pipelineChoices
 86 |     .map(
 87 |       (pipeline, index) =>
 88 |         `${index + 1}. ${pipeline.name} (definitionId: ${pipeline.definitionId})`,
 89 |     )
 90 |     .join('\n');
 91 | 
 92 |   if (pipelineChoices.length > 1 && !pipelineChoiceName) {
 93 |     return {
 94 |       content: [
 95 |         {
 96 |           type: 'text',
 97 |           text: `Multiple pipeline definitions found. Please choose one of the following:\n${formattedPipelineChoices}`,
 98 |         },
 99 |       ],
100 |     };
101 |   }
102 | 
103 |   const chosenPipeline = pipelineChoiceName
104 |     ? pipelineChoices.find((pipeline) => pipeline.name === pipelineChoiceName)
105 |     : undefined;
106 | 
107 |   if (pipelineChoiceName && !chosenPipeline) {
108 |     return mcpErrorOutput(
109 |       `Pipeline definition with name ${pipelineChoiceName} not found. Please choose one of the following:\n${formattedPipelineChoices}`,
110 |     );
111 |   }
112 | 
113 |   const runPipelineDefinitionId =
114 |     chosenPipeline?.definitionId || pipelineChoices[0].definitionId;
115 | 
116 |   const runPipelineResponse = await circleci.pipelines.runPipeline({
117 |     projectSlug,
118 |     branch: foundBranch,
119 |     definitionId: runPipelineDefinitionId,
120 |     configContent,
121 |   });
122 | 
123 |   return {
124 |     content: [
125 |       {
126 |         type: 'text',
127 |         text: `Pipeline run successfully. View it at: ${baseURL}/pipelines/${projectSlug}/${runPipelineResponse.number}`,
128 |       },
129 |     ],
130 |   };
131 | };
132 | 
```

--------------------------------------------------------------------------------
/src/lib/rateLimitedRequests/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | type BatchState = {
  2 |   batchItemsToFire: any[];
  3 |   totalRequests: number;
  4 |   completedRequests: number;
  5 | };
  6 | 
  7 | type BatchOptions = {
  8 |   batchSize?: number;
  9 |   onProgress?: (progress: {
 10 |     totalRequests: number;
 11 |     completedRequests: number;
 12 |   }) => void;
 13 |   onBatchComplete?: (result: {
 14 |     startIndex: number;
 15 |     stopIndex: number;
 16 |     results: any[];
 17 |   }) => void;
 18 | };
 19 | 
 20 | type BatchResult = {
 21 |   startIndex: number;
 22 |   stopIndex: number;
 23 |   results: any[];
 24 | };
 25 | 
 26 | const RATE_LIMIT_INTERVAL = 2000;
 27 | const RATE_LIMIT_MAX_REQUESTS = 40;
 28 | 
 29 | const ifAllItemsArePopulated = (
 30 |   batchState: BatchState,
 31 |   startIndex: number,
 32 |   endIndex: number,
 33 | ): boolean => {
 34 |   for (let i = startIndex; i < endIndex; i++) {
 35 |     if (
 36 |       i < batchState.batchItemsToFire.length &&
 37 |       batchState.batchItemsToFire[i] === undefined
 38 |     ) {
 39 |       return false;
 40 |     }
 41 |   }
 42 |   return true;
 43 | };
 44 | 
 45 | const onProgressFired = (
 46 |   batchState: BatchState,
 47 |   startIndex: number,
 48 |   endIndex: number,
 49 |   onProgress: (data: {
 50 |     totalRequests: number;
 51 |     completedRequests: number;
 52 |   }) => void,
 53 | ): void => {
 54 |   batchState.completedRequests += endIndex - startIndex;
 55 |   const data = {
 56 |     totalRequests: batchState.totalRequests,
 57 |     completedRequests: batchState.completedRequests,
 58 |   };
 59 |   onProgress(data);
 60 | };
 61 | 
 62 | const onBatchCompleteFired = (
 63 |   batchState: BatchState,
 64 |   batchItems: any[],
 65 |   startIndex: number,
 66 |   endIndex: number,
 67 |   batchSize: number,
 68 |   onBatchComplete: (result: BatchResult) => void,
 69 | ): void => {
 70 |   for (let i = startIndex; i < endIndex; i++) {
 71 |     batchState.batchItemsToFire[i] = batchItems[i - startIndex];
 72 |   }
 73 | 
 74 |   for (let i = 0; i < batchState.batchItemsToFire.length; i = i + batchSize) {
 75 |     const batchEndIndex = i + batchSize;
 76 |     const allItemsArePopulated = ifAllItemsArePopulated(
 77 |       batchState,
 78 |       i,
 79 |       batchEndIndex,
 80 |     );
 81 |     if (allItemsArePopulated) {
 82 |       const batch = batchState.batchItemsToFire.slice(i, batchEndIndex);
 83 |       const result = {
 84 |         startIndex: i,
 85 |         stopIndex: Math.min(
 86 |           batchEndIndex - 1,
 87 |           batchState.batchItemsToFire.length - 1,
 88 |         ),
 89 |         results: batch,
 90 |       };
 91 |       for (let j = 0; j < batchEndIndex; j++) {
 92 |         batchState.batchItemsToFire[j] = undefined;
 93 |       }
 94 |       onBatchComplete(result);
 95 |     }
 96 |   }
 97 | };
 98 | 
 99 | const onBatchFinish = (
100 |   batchState: BatchState,
101 |   batch: Promise<any>[],
102 |   options: BatchOptions | undefined,
103 |   startIndex: number,
104 |   endIndex: number,
105 | ): void => {
106 |   Promise.all(batch).then((batchItems) => {
107 |     if (options?.batchSize && options.onBatchComplete) {
108 |       onBatchCompleteFired(
109 |         batchState,
110 |         batchItems,
111 |         startIndex,
112 |         endIndex,
113 |         options.batchSize,
114 |         options.onBatchComplete,
115 |       );
116 |     }
117 |     if (options?.onProgress) {
118 |       onProgressFired(batchState, startIndex, endIndex, options.onProgress);
119 |     }
120 |   });
121 | };
122 | 
123 | export const rateLimitedRequests = async <T>(
124 |   requests: (() => Promise<T>)[],
125 |   maxRequests = RATE_LIMIT_MAX_REQUESTS,
126 |   interval = RATE_LIMIT_INTERVAL,
127 |   options?: BatchOptions,
128 | ): Promise<T[]> => {
129 |   const batchState: BatchState = {
130 |     batchItemsToFire: new Array(requests.length),
131 |     totalRequests: requests.length,
132 |     completedRequests: 0,
133 |   };
134 | 
135 |   const result = new Array(requests.length);
136 |   const promises: Promise<T>[] = [];
137 | 
138 |   for (
139 |     let startIndex = 0;
140 |     startIndex < requests.length;
141 |     startIndex += maxRequests
142 |   ) {
143 |     const endIndex = Math.min(startIndex + maxRequests, requests.length);
144 |     const batch = requests.slice(startIndex, endIndex).map((execute, index) =>
145 |       Promise.resolve(execute()).then((res) => {
146 |         result[startIndex + index] = res;
147 |         return res;
148 |       }),
149 |     );
150 | 
151 |     onBatchFinish(batchState, batch, options, startIndex, endIndex);
152 |     promises.push(...batch);
153 | 
154 |     if (endIndex < requests.length) {
155 |       await new Promise((resolve) => setTimeout(resolve, interval));
156 |     }
157 |   }
158 | 
159 |   await Promise.all(promises);
160 |   return result;
161 | };
162 | 
```

--------------------------------------------------------------------------------
/src/lib/usage-api/findUnderusedResourceClasses.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, type Mock } from 'vitest';
  2 | import {
  3 |   readAndParseCSV,
  4 |   validateCSVColumns,
  5 |   groupRecordsByJob,
  6 |   analyzeJobGroups,
  7 |   generateReport,
  8 | } from './findUnderusedResourceClasses.js';
  9 | import * as fs from 'fs';
 10 | 
 11 | vi.mock('fs');
 12 | 
 13 | describe('findUnderusedResourceClasses library functions', () => {
 14 | 
 15 |   const CSV_HEADERS = 'project_name,workflow_name,job_name,resource_class,median_cpu_utilization_pct,max_cpu_utilization_pct,median_ram_utilization_pct,max_ram_utilization_pct,compute_credits';
 16 |   const CSV_ROW_UNDER = 'proj,flow,build,medium,10,20,15,18,100';
 17 |   const CSV_ROW_OVER = 'proj,flow,test,large,50,60,55,58,200';
 18 |   const CSV_CONTENT = `${CSV_HEADERS}\n${CSV_ROW_UNDER}\n${CSV_ROW_OVER}`;
 19 |   const mockRecords = [
 20 |       {
 21 |         project_name: 'proj',
 22 |         workflow_name: 'flow',
 23 |         job_name: 'build',
 24 |         resource_class: 'medium',
 25 |         median_cpu_utilization_pct: '10',
 26 |         max_cpu_utilization_pct: '20',
 27 |         median_ram_utilization_pct: '15',
 28 |         max_ram_utilization_pct: '18',
 29 |         compute_credits: '100'
 30 |       },
 31 |       {
 32 |         project_name: 'proj',
 33 |         workflow_name: 'flow',
 34 |         job_name: 'test',
 35 |         resource_class: 'large',
 36 |         median_cpu_utilization_pct: '50',
 37 |         max_cpu_utilization_pct: '60',
 38 |         median_ram_utilization_pct: '55',
 39 |         max_ram_utilization_pct: '58',
 40 |         compute_credits: '200'
 41 |       }
 42 |   ];
 43 | 
 44 |   describe('readAndParseCSV', () => {
 45 |     it('should read and parse a CSV file correctly', () => {
 46 |       (fs.readFileSync as Mock).mockReturnValue(CSV_CONTENT);
 47 |       const records = readAndParseCSV('dummy/path.csv');
 48 |       expect(records).toHaveLength(2);
 49 |       expect(records[0].project_name).toBe('proj');
 50 |     });
 51 | 
 52 |     it('should throw an error if file read fails', () => {
 53 |         (fs.readFileSync as Mock).mockImplementation(() => {
 54 |             throw new Error('File not found');
 55 |         });
 56 |         expect(() => readAndParseCSV('bad/path.csv')).toThrow('Could not read CSV file');
 57 |     });
 58 |   });
 59 | 
 60 |   describe('validateCSVColumns', () => {
 61 |     it('should not throw an error for valid records', () => {
 62 |       expect(() => validateCSVColumns(mockRecords)).not.toThrow();
 63 |     });
 64 | 
 65 |     it('should throw an error for missing required columns', () => {
 66 |       const invalidRecords = [{ project_name: 'proj' }];
 67 |       expect(() => validateCSVColumns(invalidRecords)).toThrow('CSV is missing required columns');
 68 |     });
 69 |   });
 70 | 
 71 |   describe('groupRecordsByJob', () => {
 72 |     it('should group records by job identifier', () => {
 73 |       const grouped = groupRecordsByJob(mockRecords);
 74 |       expect(grouped.size).toBe(2);
 75 |       expect(grouped.has('proj|||flow|||build|||medium')).toBe(true);
 76 |     });
 77 |   });
 78 | 
 79 |   describe('analyzeJobGroups', () => {
 80 |     it('should identify underused jobs', () => {
 81 |       const grouped = groupRecordsByJob(mockRecords);
 82 |       const underused = analyzeJobGroups(grouped, 40);
 83 |       expect(underused).toHaveLength(1);
 84 |       expect(underused[0].job).toBe('build');
 85 |     });
 86 |     
 87 |     it('should return an empty array if no jobs are underused', () => {
 88 |         const grouped = groupRecordsByJob([mockRecords[1]]);
 89 |         const underused = analyzeJobGroups(grouped, 40);
 90 |         expect(underused).toHaveLength(0);
 91 |     });
 92 |   });
 93 | 
 94 |   describe('generateReport', () => {
 95 |     it('should generate a report for underused jobs', () => {
 96 |       const underusedJobs = [{
 97 |         projectName: 'proj',
 98 |         workflowName: 'flow',
 99 |         job: 'build',
100 |         resourceClass: 'medium',
101 |         avgCpu: 10,
102 |         maxCpu: 20,
103 |         avgRam: 15,
104 |         maxRam: 18,
105 |         count: 1,
106 |         totalComputeCredits: 100
107 |       }];
108 |       const report = generateReport(underusedJobs, 40);
109 |       expect(report).toContain('Underused resource classes');
110 |       expect(report).toContain('Project: proj');
111 |     });
112 | 
113 |     it('should generate a message when no jobs are underused', () => {
114 |       const report = generateReport([], 40);
115 |       expect(report).toBe('No underused resource classes found (threshold: 40%).');
116 |     });
117 |   });
118 | });
119 | 
```

--------------------------------------------------------------------------------
/src/tools/recommendPromptTemplateTests/handler.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
 2 | import { recommendPromptTemplateTestsInputSchema } from './inputSchema.js';
 3 | import { CircletClient } from '../../clients/circlet/index.js';
 4 | import {
 5 |   fileExtension,
 6 |   fileNameExample1,
 7 |   fileNameExample2,
 8 |   fileNameExample3,
 9 |   fileNameTemplate,
10 |   PromptOrigin,
11 |   promptsOutputDirectory,
12 | } from '../shared/constants.js';
13 | 
14 | const promptTemplateKey = 'promptTemplate';
15 | const modelKey = 'model';
16 | const temperatureKey = 'temperature';
17 | const contextSchemaKey = 'contextSchema';
18 | const promptOriginKey = 'promptOrigin';
19 | const recommendedTestsKey = 'recommendedTests';
20 | 
21 | export const recommendPromptTemplateTests: ToolCallback<{
22 |   params: typeof recommendPromptTemplateTestsInputSchema;
23 | }> = async (args) => {
24 |   const { template, contextSchema, promptOrigin } = args.params ?? {};
25 | 
26 |   const circlet = new CircletClient();
27 |   const result = await circlet.circlet.recommendPromptTemplateTests({
28 |     template,
29 |     contextSchema,
30 |   });
31 | 
32 |   const baseInstructions = `${recommendedTestsKey}: ${JSON.stringify(result, null, 2)}
33 | 
34 | NEXT STEP:
35 | - Immediately save the \`${promptTemplateKey}\`, \`${contextSchemaKey}\`, and \`${recommendedTestsKey}\` to a single file containing the prompt template, context schema, and tests in a simple structured format (e.g. YAML, JSON, or whatever is most appropriate for the language of the current repository).
36 |   - The ${fileExtension} file should be named in the format '${fileNameTemplate}' (e.g. '${fileNameExample1}', '${fileNameExample2}', '${fileNameExample3}', etc.)
37 |   - The file should have the following keys:
38 |     - \`name\`: string (the name of the prompt template)
39 |     - \`description\`: string (a description of the prompt template)
40 |     - \`version\`: string (the semantic version of the prompt template, e.g. "1.0.0")
41 |     - \`${promptOriginKey}\`: string (the origin of the prompt template, e.g. "${PromptOrigin.codebase}" or "${PromptOrigin.requirements}")
42 |     - \`${modelKey}\`: string (the model used for generating the prompt template and tests)
43 |     - \`${temperatureKey}\`: number (the temperature used for generating the prompt template and tests)
44 |     - \`template\`: multi-line string (the prompt template)
45 |     - \`${contextSchemaKey}\`: object (the \`${contextSchemaKey}\`)
46 |     - \`tests\`: array of objects (based on the \`${recommendedTestsKey}\`)
47 |       - \`name\`: string (a relevant "Title Case" name for the test, based on the content of the \`${recommendedTestsKey}\` array item)
48 |       - \`description\`: string (taken directly from string array item in \`${recommendedTestsKey}\`)
49 |     - \`sampleInputs\`: object[] (the sample inputs for the \`${promptTemplateKey}\` and any tests within \`${recommendedTestsKey}\`)
50 | 
51 | RULES FOR SAVING FILES:
52 | - The files should be saved in the \`${promptsOutputDirectory}\` directory at the root of the project.
53 | - Files should be written with respect to the prevailing conventions of the current repository.
54 | - The prompt files should be documented with a README description of what they do, and how they work.
55 |   - If a README already exists in the \`${promptsOutputDirectory}\` directory, update it with the new prompt template information.
56 |   - If a README does not exist in the \`${promptsOutputDirectory}\` directory, create one.
57 | - The files should be formatted using the user's preferred conventions.
58 | - Only save the following files (and nothing else):
59 |   - \`${fileNameTemplate}\`
60 |   - \`README.md\``;
61 | 
62 |   const integrationInstructions =
63 |     promptOrigin === PromptOrigin.codebase
64 |       ? `
65 | 
66 | FINALLY, ONCE ALL THE FILES ARE SAVED:
67 | 1. Ask user if they want to integrate the new templates into their app as a more tested and trustworthy replacement for their pre-existing prompt implementations. (Yes/No)
68 | 2. If yes, import the \`${promptsOutputDirectory}\` files into their app, following codebase conventions
69 | 3. Only use existing dependencies - no new imports
70 | 4. Ensure integration is error-free and builds successfully`
71 |       : '';
72 | 
73 |   return {
74 |     content: [
75 |       {
76 |         type: 'text',
77 |         text: baseInstructions + integrationInstructions,
78 |       },
79 |     ],
80 |   };
81 | };
82 | 
```

--------------------------------------------------------------------------------
/src/lib/pipeline-job-logs/getJobLogs.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { getCircleCIPrivateClient } from '../../clients/client.js';
  2 | import { getCircleCIClient } from '../../clients/client.js';
  3 | import { rateLimitedRequests } from '../rateLimitedRequests/index.js';
  4 | import { JobDetails } from '../../clients/schemas.js';
  5 | import outputTextTruncated, { SEPARATOR } from '../outputTextTruncated.js';
  6 | 
  7 | export type GetJobLogsParams = {
  8 |   projectSlug: string;
  9 |   jobNumbers: number[];
 10 |   failedStepsOnly?: boolean;
 11 | };
 12 | 
 13 | type StepLog = {
 14 |   stepName: string;
 15 |   logs: {
 16 |     output: string;
 17 |     error: string;
 18 |   };
 19 | };
 20 | 
 21 | type JobWithStepLogs = {
 22 |   jobName: string;
 23 |   steps: (StepLog | null)[];
 24 | };
 25 | 
 26 | /**
 27 |  * Retrieves job logs from CircleCI
 28 |  * @param params Object containing project slug, job numbers, and optional flag to filter for failed steps only
 29 |  * @param params.projectSlug The slug of the project to retrieve logs for
 30 |  * @param params.jobNumbers The numbers of the jobs to retrieve logs for
 31 |  * @param params.failedStepsOnly Whether to filter for failed steps only
 32 |  * @returns Array of job logs with step information
 33 |  */
 34 | const getJobLogs = async ({
 35 |   projectSlug,
 36 |   jobNumbers,
 37 |   failedStepsOnly = true,
 38 | }: GetJobLogsParams): Promise<JobWithStepLogs[]> => {
 39 |   const circleci = getCircleCIClient();
 40 |   const circleciPrivate = getCircleCIPrivateClient();
 41 | 
 42 |   const jobsDetails = (
 43 |     await rateLimitedRequests(
 44 |       jobNumbers.map((jobNumber) => async () => {
 45 |         try {
 46 |           return await circleci.jobsV1.getJobDetails({
 47 |             projectSlug,
 48 |             jobNumber,
 49 |           });
 50 |         } catch (error) {
 51 |           if (error instanceof Error && error.message.includes('404')) {
 52 |             console.error(`Job ${jobNumber} not found:`, error);
 53 |             // some jobs might not be found, return null in that case
 54 |             return null;
 55 |           } else if (error instanceof Error && error.message.includes('429')) {
 56 |             console.error(`Rate limited for job request ${jobNumber}:`, error);
 57 |             // some requests might be rate limited, return null in that case
 58 |             return null;
 59 |           }
 60 |           throw error;
 61 |         }
 62 |       }),
 63 |     )
 64 |   ).filter((job): job is JobDetails => job !== null);
 65 | 
 66 |   const allLogs = await Promise.all(
 67 |     jobsDetails.map(async (job) => {
 68 |       // Get logs for all steps and their actions
 69 |       const stepLogs = await Promise.all(
 70 |         job.steps.flatMap((step) => {
 71 |           let actions = step.actions;
 72 |           if (failedStepsOnly) {
 73 |             actions = actions.filter((action) => action.failed === true);
 74 |           }
 75 |           return actions.map(async (action) => {
 76 |             try {
 77 |               const logs = await circleciPrivate.jobs.getStepOutput({
 78 |                 projectSlug,
 79 |                 jobNumber: job.build_num,
 80 |                 taskIndex: action.index,
 81 |                 stepId: action.step,
 82 |               });
 83 |               return {
 84 |                 stepName: step.name,
 85 |                 logs,
 86 |               };
 87 |             } catch (error) {
 88 |               console.error('error in step', step.name, error);
 89 |               // Some steps might not have logs, return null in that case
 90 |               return null;
 91 |             }
 92 |           });
 93 |         }),
 94 |       );
 95 | 
 96 |       return {
 97 |         jobName: job.workflows.job_name,
 98 |         steps: stepLogs.filter(Boolean), // Remove any null entries
 99 |       };
100 |     }),
101 |   );
102 | 
103 |   return allLogs;
104 | };
105 | 
106 | export default getJobLogs;
107 | 
108 | /**
109 |  * Formats job logs into a standardized output structure
110 |  * @param logs Array of job logs containing step information
111 |  * @returns Formatted output object with text content
112 |  */
113 | export function formatJobLogs(jobStepLogs: JobWithStepLogs[]) {
114 |   if (jobStepLogs.length === 0) {
115 |     return {
116 |       content: [
117 |         {
118 |           type: 'text' as const,
119 |           text: 'No logs found.',
120 |         },
121 |       ],
122 |     };
123 |   }
124 |   const outputText = jobStepLogs
125 |     .map((log) => `${SEPARATOR}Job: ${log.jobName}\n` + formatSteps(log))
126 |     .join('\n');
127 |   return outputTextTruncated(outputText);
128 | }
129 | 
130 | const formatSteps = (jobStepLog: JobWithStepLogs) => {
131 |   if (jobStepLog.steps.length === 0) {
132 |     return 'No steps found.';
133 |   }
134 |   return jobStepLog.steps
135 |     .map(
136 |       (step) =>
137 |         `Step: ${step?.stepName}\n` + `Logs: ${JSON.stringify(step?.logs)}`,
138 |     )
139 |     .join('\n');
140 | };
141 | 
```

--------------------------------------------------------------------------------
/src/circleci-tools.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
 2 | import { getBuildFailureLogsTool } from './tools/getBuildFailureLogs/tool.js';
 3 | import { getBuildFailureLogs } from './tools/getBuildFailureLogs/handler.js';
 4 | import { getFlakyTestLogsTool } from './tools/getFlakyTests/tool.js';
 5 | import { getFlakyTestLogs } from './tools/getFlakyTests/handler.js';
 6 | import { getLatestPipelineStatusTool } from './tools/getLatestPipelineStatus/tool.js';
 7 | import { getLatestPipelineStatus } from './tools/getLatestPipelineStatus/handler.js';
 8 | import { getJobTestResultsTool } from './tools/getJobTestResults/tool.js';
 9 | import { getJobTestResults } from './tools/getJobTestResults/handler.js';
10 | import { configHelper } from './tools/configHelper/handler.js';
11 | import { configHelperTool } from './tools/configHelper/tool.js';
12 | import { createPromptTemplate } from './tools/createPromptTemplate/handler.js';
13 | import { createPromptTemplateTool } from './tools/createPromptTemplate/tool.js';
14 | import { recommendPromptTemplateTestsTool } from './tools/recommendPromptTemplateTests/tool.js';
15 | import { recommendPromptTemplateTests } from './tools/recommendPromptTemplateTests/handler.js';
16 | import { runPipeline } from './tools/runPipeline/handler.js';
17 | import { runPipelineTool } from './tools/runPipeline/tool.js';
18 | import { listFollowedProjectsTool } from './tools/listFollowedProjects/tool.js';
19 | import { listFollowedProjects } from './tools/listFollowedProjects/handler.js';
20 | import { runEvaluationTestsTool } from './tools/runEvaluationTests/tool.js';
21 | import { runEvaluationTests } from './tools/runEvaluationTests/handler.js';
22 | import { rerunWorkflowTool } from './tools/rerunWorkflow/tool.js';
23 | import { rerunWorkflow } from './tools/rerunWorkflow/handler.js';
24 | import { downloadUsageApiDataTool } from './tools/downloadUsageApiData/tool.js';
25 | import { downloadUsageApiData } from './tools/downloadUsageApiData/handler.js';
26 | import { findUnderusedResourceClassesTool } from './tools/findUnderusedResourceClasses/tool.js';
27 | import { findUnderusedResourceClasses } from './tools/findUnderusedResourceClasses/handler.js';
28 | import { analyzeDiffTool } from './tools/analyzeDiff/tool.js';
29 | import { analyzeDiff } from './tools/analyzeDiff/handler.js';
30 | import { runRollbackPipelineTool } from './tools/runRollbackPipeline/tool.js';
31 | import { runRollbackPipeline } from './tools/runRollbackPipeline/handler.js';
32 | 
33 | import { listComponentVersionsTool } from './tools/listComponentVersions/tool.js';
34 | import { listComponentVersions } from './tools/listComponentVersions/handler.js';
35 | 
36 | // Define the tools with their configurations
37 | export const CCI_TOOLS = [
38 |   getBuildFailureLogsTool,
39 |   getFlakyTestLogsTool,
40 |   getLatestPipelineStatusTool,
41 |   getJobTestResultsTool,
42 |   configHelperTool,
43 |   createPromptTemplateTool,
44 |   recommendPromptTemplateTestsTool,
45 |   runPipelineTool,
46 |   listFollowedProjectsTool,
47 |   runEvaluationTestsTool,
48 |   rerunWorkflowTool,
49 |   downloadUsageApiDataTool,
50 |   findUnderusedResourceClassesTool,
51 |   analyzeDiffTool,
52 |   runRollbackPipelineTool,
53 |   listComponentVersionsTool,
54 | ];
55 | 
56 | // Extract the tool names as a union type
57 | type CCIToolName = (typeof CCI_TOOLS)[number]['name'];
58 | 
59 | export type ToolHandler<T extends CCIToolName> = ToolCallback<{
60 |   params: Extract<(typeof CCI_TOOLS)[number], { name: T }>['inputSchema'];
61 | }>;
62 | 
63 | // Create a type for the tool handlers that directly maps each tool to its appropriate input schema
64 | type ToolHandlers = {
65 |   [K in CCIToolName]: ToolHandler<K>;
66 | };
67 | 
68 | export const CCI_HANDLERS = {
69 |   get_build_failure_logs: getBuildFailureLogs,
70 |   find_flaky_tests: getFlakyTestLogs,
71 |   get_latest_pipeline_status: getLatestPipelineStatus,
72 |   get_job_test_results: getJobTestResults,
73 |   config_helper: configHelper,
74 |   create_prompt_template: createPromptTemplate,
75 |   recommend_prompt_template_tests: recommendPromptTemplateTests,
76 |   run_pipeline: runPipeline,
77 |   list_followed_projects: listFollowedProjects,
78 |   run_evaluation_tests: runEvaluationTests,
79 |   rerun_workflow: rerunWorkflow,
80 |   download_usage_api_data: downloadUsageApiData,
81 |   find_underused_resource_classes: findUnderusedResourceClasses,
82 |   analyze_diff: analyzeDiff,
83 |   run_rollback_pipeline: runRollbackPipeline,
84 |   list_component_versions: listComponentVersions,
85 | } satisfies ToolHandlers;
86 | 
```

--------------------------------------------------------------------------------
/scripts/create-tool.js:
--------------------------------------------------------------------------------

```javascript
  1 | #!/usr/bin/env node
  2 | /* eslint-disable no-undef */
  3 | import fs from 'fs';
  4 | import path from 'path';
  5 | import { fileURLToPath } from 'url';
  6 | 
  7 | // Get the current file's directory name
  8 | const __filename = fileURLToPath(import.meta.url);
  9 | const __dirname = path.dirname(__filename);
 10 | 
 11 | // Get tool name from command line arguments
 12 | const toolName = process.argv[2];
 13 | 
 14 | if (!toolName) {
 15 |   console.error('Please provide a tool name');
 16 |   console.error('Example: node scripts/create-tool.js myNewTool');
 17 |   process.exit(1);
 18 | }
 19 | 
 20 | // Convert toolName to snake_case for tool name and camelCase for variables
 21 | const snakeCaseName = toolName
 22 |   .replace(/([a-z])([A-Z])/g, '$1_$2')
 23 |   .toLowerCase();
 24 | 
 25 | const camelCaseName = snakeCaseName.replace(/_([a-z])/g, (_, letter) =>
 26 |   letter.toUpperCase(),
 27 | );
 28 | 
 29 | // Create directory for the tool
 30 | const toolDir = path.join(
 31 |   path.resolve(__dirname, '..'),
 32 |   'src',
 33 |   'tools',
 34 |   toolName,
 35 | );
 36 | 
 37 | if (fs.existsSync(toolDir)) {
 38 |   console.error(`Tool directory already exists: ${toolDir}`);
 39 |   process.exit(1);
 40 | }
 41 | 
 42 | fs.mkdirSync(toolDir, { recursive: true });
 43 | 
 44 | // Create inputSchema.ts
 45 | const inputSchemaContent = `import { z } from 'zod';
 46 | 
 47 | export const ${camelCaseName}InputSchema = z.object({
 48 |   message: z
 49 |     .string()
 50 |     .describe(
 51 |       'A message to echo back to the user.',
 52 |     ),
 53 | });
 54 | `;
 55 | 
 56 | // Create tool.ts
 57 | const toolContent = `import { ${camelCaseName}InputSchema } from './inputSchema.js';
 58 | 
 59 | export const ${camelCaseName}Tool = {
 60 |   name: '${snakeCaseName}' as const,
 61 |   description: \`
 62 |   This tool is a basic "hello world" tool that echoes back a message provided by the user.
 63 | 
 64 |   Parameters:
 65 |   - params: An object containing:
 66 |     - message: string - A message provided by the user that will be echoed back.
 67 | 
 68 |   Example usage:
 69 |   {
 70 |     "params": {
 71 |       "message": "Hello, world!"
 72 |     }
 73 |   }
 74 | 
 75 |   Returns:
 76 |   - The message provided by the user.
 77 |   \`,
 78 |   inputSchema: ${camelCaseName}InputSchema,
 79 | };
 80 | `;
 81 | 
 82 | // Create handler.ts
 83 | const handlerContent = `import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
 84 | import { ${camelCaseName}InputSchema } from './inputSchema.js';
 85 | 
 86 | export const ${camelCaseName}: ToolCallback<{
 87 |   params: typeof ${camelCaseName}InputSchema;
 88 | }> = async (args) => {
 89 |   const { message } = args.params;
 90 | 
 91 |   return {
 92 |     content: [
 93 |       {
 94 |         type: 'text',
 95 |         text: \`Received message: \${message}\`,
 96 |       },
 97 |     ],
 98 |   };
 99 | };
100 | `;
101 | 
102 | // Create handler.test.ts
103 | const testContent = `import { describe, it, expect } from 'vitest';
104 | import { ${camelCaseName} } from './handler.js';
105 | 
106 | describe('${camelCaseName}', () => {
107 |   it('should return the message provided by the user', async () => {
108 |     const controller = new AbortController();
109 |     const result = await ${camelCaseName}(
110 |       {
111 |         params: {
112 |           message: 'Hello, world!',
113 |         },
114 |       },
115 |       {
116 |         signal: controller.signal,
117 |       }
118 |     );
119 | 
120 |     expect(result).toEqual({
121 |       content: [
122 |         {
123 |           type: 'text',
124 |           text: 'Received message: Hello, world!',
125 |         },
126 |       ],
127 |     });
128 |   });
129 | });
130 | `;
131 | 
132 | // Write files
133 | fs.writeFileSync(path.join(toolDir, 'inputSchema.ts'), inputSchemaContent);
134 | fs.writeFileSync(path.join(toolDir, 'tool.ts'), toolContent);
135 | fs.writeFileSync(path.join(toolDir, 'handler.ts'), handlerContent);
136 | fs.writeFileSync(path.join(toolDir, 'handler.test.ts'), testContent);
137 | 
138 | console.log(`
139 | ✅ Tool created successfully!
140 | 
141 | 📂 Location: ${toolDir}
142 | 
143 | The following files were created:
144 | - inputSchema.ts - Defines the input schema for the tool
145 | - tool.ts - Defines the tool itself, including its name, description, and schema
146 | - handler.ts - Contains the main logic for the tool
147 | - handler.test.ts - Contains unit tests for the tool
148 | 
149 | Next steps:
150 | 1. Implement your tool's logic in handler.ts
151 | 2. Add the tool to src/circleci-tools.ts with these steps:
152 |    a. Import your tool:
153 |       import { ${camelCaseName}Tool } from './tools/${toolName}/tool.js';
154 |       import { ${camelCaseName} } from './tools/${toolName}/handler.js';
155 |    b. Add your tool to the CCI_TOOLS array:
156 |       export const CCI_TOOLS = [
157 |         ...,
158 |         ${camelCaseName}Tool,
159 |       ];
160 |    c. Add your handler to the CCI_HANDLERS object:
161 |       export const CCI_HANDLERS = {
162 |         ...,
163 |         ${snakeCaseName}: ${camelCaseName},
164 |       } satisfies ToolHandlers;
165 | 3. Write appropriate tests in handler.test.ts
166 | `);
167 | 
```

--------------------------------------------------------------------------------
/src/clients/circleci/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { HTTPClient } from './httpClient.js';
  2 | import { JobsAPI } from './jobs.js';
  3 | import { JobsV1API } from './jobsV1.js';
  4 | import { InsightsAPI } from './insights.js';
  5 | import { PipelinesAPI } from './pipelines.js';
  6 | import { WorkflowsAPI } from './workflows.js';
  7 | import { TestsAPI } from './tests.js';
  8 | import { ConfigValidateAPI } from './configValidate.js';
  9 | import { ProjectsAPI } from './projects.js';
 10 | import { UsageAPI } from './usage.js';
 11 | import { DeploysAPI } from './deploys.js';
 12 | export type TCircleCIClient = InstanceType<typeof CircleCIClients>;
 13 | 
 14 | export const getBaseURL = (useAPISubdomain = false) => {
 15 |   let baseURL = process.env.CIRCLECI_BASE_URL || 'https://circleci.com';
 16 | 
 17 |   if (useAPISubdomain) {
 18 |     baseURL = baseURL.replace('https://', 'https://api.');
 19 |   }
 20 | 
 21 |   return baseURL;
 22 | };
 23 | 
 24 | export const getAppURL = () => {
 25 |   const baseURL = process.env.CIRCLECI_BASE_URL || 'https://circleci.com';
 26 | 
 27 |   return baseURL.replace('https://', 'https://app.');
 28 | };
 29 | 
 30 | export const defaultPaginationOptions = {
 31 |   maxPages: 5,
 32 |   timeoutMs: 10000,
 33 |   findFirst: false,
 34 | } as const;
 35 | 
 36 | /**
 37 |  * Creates standardized headers for CircleCI API clients
 38 |  * @param params Configuration parameters
 39 |  * @param params.token CircleCI API token
 40 |  * @param params.additionalHeaders Optional headers to merge with defaults (will not override critical headers)
 41 |  * @returns Headers object for fetch API
 42 |  */
 43 | export function createCircleCIHeaders({
 44 |   token,
 45 |   additionalHeaders = {},
 46 | }: {
 47 |   token: string;
 48 |   additionalHeaders?: HeadersInit;
 49 | }): HeadersInit {
 50 |   const headers = additionalHeaders;
 51 |   Object.assign(headers, {
 52 |     'Circle-Token': token,
 53 |     'Content-Type': 'application/json',
 54 |     'User-Agent': 'CircleCI-MCP-Server/0.1',
 55 |   });
 56 | 
 57 |   return headers;
 58 | }
 59 | 
 60 | /**
 61 |  * Creates a default HTTP client for the CircleCI API v2
 62 |  * @param options Configuration parameters
 63 |  * @param options.token CircleCI API token
 64 |  * @returns HTTP client for CircleCI API v2
 65 |  */
 66 | const defaultV2HTTPClient = (options: {
 67 |   token: string;
 68 |   useAPISubdomain?: boolean;
 69 | }) => {
 70 |   if (!options.token) {
 71 |     throw new Error('Token is required');
 72 |   }
 73 | 
 74 |   const baseURL = getBaseURL(options.useAPISubdomain);
 75 |   const headers = createCircleCIHeaders({ token: options.token });
 76 |   return new HTTPClient(baseURL, '/api/v2', {
 77 |     headers,
 78 |   });
 79 | };
 80 | 
 81 | /**
 82 |  * Creates a default HTTP client for the CircleCI API v1
 83 |  * @param options Configuration parameters
 84 |  * @param options.token CircleCI API token
 85 |  * @returns HTTP client for CircleCI API v1
 86 |  */
 87 | const defaultV1HTTPClient = (options: {
 88 |   token: string;
 89 |   useAPISubdomain?: boolean;
 90 | }) => {
 91 |   if (!options.token) {
 92 |     throw new Error('Token is required');
 93 |   }
 94 | 
 95 |   const baseURL = getBaseURL(options.useAPISubdomain);
 96 |   const headers = createCircleCIHeaders({ token: options.token });
 97 |   return new HTTPClient(baseURL, '/api/v1.1', {
 98 |     headers,
 99 |   });
100 | };
101 | 
102 | /**
103 |  * Creates a default HTTP client for the CircleCI API v2
104 |  * @param options Configuration parameters
105 |  * @param options.token CircleCI API token
106 |  */
107 | export class CircleCIClients {
108 |   protected apiPathV2 = '/api/v2';
109 |   protected apiPathV1 = '/api/v1.1';
110 | 
111 |   public jobs: JobsAPI;
112 |   public pipelines: PipelinesAPI;
113 |   public workflows: WorkflowsAPI;
114 |   public jobsV1: JobsV1API;
115 |   public insights: InsightsAPI;
116 |   public tests: TestsAPI;
117 |   public configValidate: ConfigValidateAPI;
118 |   public projects: ProjectsAPI;
119 |   public usage: UsageAPI;
120 |   public deploys: DeploysAPI;
121 | 
122 |   constructor({
123 |     token,
124 |     v2httpClient = defaultV2HTTPClient({
125 |       token,
126 |     }),
127 |     v1httpClient = defaultV1HTTPClient({
128 |       token,
129 |     }),
130 |     apiSubdomainV2httpClient = defaultV2HTTPClient({
131 |       token,
132 |       useAPISubdomain: true,
133 |     }),
134 |   }: {
135 |     token: string;
136 |     v2httpClient?: HTTPClient;
137 |     v1httpClient?: HTTPClient;
138 |     apiSubdomainV2httpClient?: HTTPClient;
139 |   }) {
140 |     this.jobs = new JobsAPI(v2httpClient);
141 |     this.pipelines = new PipelinesAPI(v2httpClient);
142 |     this.workflows = new WorkflowsAPI(v2httpClient);
143 |     this.jobsV1 = new JobsV1API(v1httpClient);
144 |     this.insights = new InsightsAPI(v2httpClient);
145 |     this.tests = new TestsAPI(v2httpClient);
146 |     this.configValidate = new ConfigValidateAPI(apiSubdomainV2httpClient);
147 |     this.projects = new ProjectsAPI(v2httpClient);
148 |     this.usage = new UsageAPI(v2httpClient);
149 |     this.deploys = new DeploysAPI(v2httpClient);
150 |   }
151 | }
152 | 
```

--------------------------------------------------------------------------------
/src/clients/circleci/pipelines.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import {
  2 |   PaginatedPipelineResponseSchema,
  3 |   Pipeline,
  4 |   PipelineDefinitionsResponse,
  5 |   RunPipelineResponse,
  6 |   PipelineDefinition,
  7 | } from '../schemas.js';
  8 | import { HTTPClient } from './httpClient.js';
  9 | import { defaultPaginationOptions } from './index.js';
 10 | 
 11 | export class PipelinesAPI {
 12 |   protected client: HTTPClient;
 13 | 
 14 |   constructor(httpClient: HTTPClient) {
 15 |     this.client = httpClient;
 16 |   }
 17 | 
 18 |   /**
 19 |    * Get recent pipelines until a condition is met
 20 |    * @param params Configuration parameters
 21 |    * @param params.projectSlug The project slug (e.g., "gh/CircleCI-Public/api-preview-docs")
 22 |    * @param params.filterFn Function to filter pipelines and determine when to stop fetching
 23 |    * @param params.branch Optional branch name to filter pipelines
 24 |    * @param params.options Optional configuration for pagination limits
 25 |    * @param params.options.maxPages Maximum number of pages to fetch (default: 5)
 26 |    * @param params.options.timeoutMs Timeout in milliseconds (default: 10000)
 27 |    * @param params.options.findFirst Whether to find the first pipeline that matches the filterFn (default: false)
 28 |    * @returns Filtered pipelines until the stop condition is met
 29 |    * @throws Error if timeout or max pages reached
 30 |    */
 31 |   async getPipelines({
 32 |     projectSlug,
 33 |     branch,
 34 |     options = {},
 35 |   }: {
 36 |     projectSlug: string;
 37 |     branch?: string;
 38 |     options?: {
 39 |       maxPages?: number;
 40 |       timeoutMs?: number;
 41 |       findFirst?: boolean;
 42 |     };
 43 |   }): Promise<Pipeline[]> {
 44 |     const {
 45 |       maxPages = defaultPaginationOptions.maxPages,
 46 |       timeoutMs = defaultPaginationOptions.timeoutMs,
 47 |       findFirst = defaultPaginationOptions.findFirst,
 48 |     } = options;
 49 | 
 50 |     const startTime = Date.now();
 51 |     const filteredPipelines: Pipeline[] = [];
 52 |     let nextPageToken: string | null = null;
 53 |     let pageCount = 0;
 54 | 
 55 |     do {
 56 |       // Check timeout
 57 |       if (Date.now() - startTime > timeoutMs) {
 58 |         nextPageToken = null;
 59 |         break;
 60 |       }
 61 | 
 62 |       // Check page limit
 63 |       if (pageCount >= maxPages) {
 64 |         nextPageToken = null;
 65 |         break;
 66 |       }
 67 | 
 68 |       const params = {
 69 |         ...(branch ? { branch } : {}),
 70 |         ...(nextPageToken ? { 'page-token': nextPageToken } : {}),
 71 |       };
 72 | 
 73 |       const rawResult = await this.client.get<unknown>(
 74 |         `/project/${projectSlug}/pipeline`,
 75 |         params,
 76 |       );
 77 | 
 78 |       const result = PaginatedPipelineResponseSchema.safeParse(rawResult);
 79 | 
 80 |       if (!result.success) {
 81 |         throw new Error('Failed to parse pipeline response');
 82 |       }
 83 | 
 84 |       pageCount++;
 85 | 
 86 |       // Using for...of instead of forEach to allow breaking the loop
 87 |       for (const pipeline of result.data.items) {
 88 |         filteredPipelines.push(pipeline);
 89 |         if (findFirst) {
 90 |           nextPageToken = null;
 91 |           break;
 92 |         }
 93 |       }
 94 | 
 95 |       nextPageToken = result.data.next_page_token;
 96 |     } while (nextPageToken);
 97 | 
 98 |     return filteredPipelines;
 99 |   }
100 | 
101 |   async getPipelineByNumber({
102 |     projectSlug,
103 |     pipelineNumber,
104 |   }: {
105 |     projectSlug: string;
106 |     pipelineNumber: number;
107 |   }): Promise<Pipeline | undefined> {
108 |     const rawResult = await this.client.get<unknown>(
109 |       `/project/${projectSlug}/pipeline/${pipelineNumber}`,
110 |     );
111 | 
112 |     const parsedResult = Pipeline.safeParse(rawResult);
113 |     if (!parsedResult.success) {
114 |       throw new Error('Failed to parse pipeline response');
115 |     }
116 | 
117 |     return parsedResult.data;
118 |   }
119 | 
120 |   async getPipelineDefinitions({
121 |     projectId,
122 |   }: {
123 |     projectId: string;
124 |   }): Promise<PipelineDefinition[]> {
125 |     const rawResult = await this.client.get<unknown>(
126 |       `/projects/${projectId}/pipeline-definitions`,
127 |     );
128 | 
129 |     const parsedResult = PipelineDefinitionsResponse.safeParse(rawResult);
130 |     if (!parsedResult.success) {
131 |       throw new Error('Failed to parse pipeline definition response');
132 |     }
133 | 
134 |     return parsedResult.data.items;
135 |   }
136 | 
137 |   async runPipeline({
138 |     projectSlug,
139 |     branch,
140 |     definitionId,
141 |     configContent = '',
142 |   }: {
143 |     projectSlug: string;
144 |     branch: string;
145 |     definitionId: string;
146 |     configContent?: string;
147 |   }): Promise<RunPipelineResponse> {
148 |     const rawResult = await this.client.post<unknown>(
149 |       `/project/${projectSlug}/pipeline/run`,
150 |       {
151 |         definition_id: definitionId,
152 |         config: {
153 |           branch,
154 |           content: configContent,
155 |         },
156 |         checkout: {
157 |           branch,
158 |         },
159 |       },
160 |     );
161 | 
162 |     const parsedResult = RunPipelineResponse.safeParse(rawResult);
163 |     if (!parsedResult.success) {
164 |       throw new Error('Failed to parse pipeline response');
165 |     }
166 | 
167 |     return parsedResult.data;
168 |   }
169 | }
170 | 
```

--------------------------------------------------------------------------------
/src/tools/listComponentVersions/tool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { listComponentVersionsInputSchema } from './inputSchema.js';
 2 | 
 3 | export const listComponentVersionsTool = {
 4 |    name: 'list_component_versions' as const,
 5 |    description: `
 6 |      This tool lists all versions for a CircleCI component. It guides you through a multi-step process to gather the required information and provides lists of available options when parameters are missing.
 7 | 
 8 |      **Initial Requirements:**
 9 |      - You need either a \`projectSlug\` (from \`listFollowedProjects\`) or a \`projectID\`. The tool will automatically resolve the \`orgID\` from either of these.
10 | 
11 |      **Typical Flow:**
12 |      1. **Start:** User requests component versions or deployment information.
13 |      2. **Project Information:** Provide either \`projectSlug\` or \`projectID\`. The tool will automatically resolve the \`orgID\` and \`projectID\` as needed.
14 |      3. **Environment Selection:** If \`environmentID\` is not provided, the tool will list all available environments for the organization and prompt the user to select one. Always return all available values without categorizing them.
15 |      4. **Component Selection:** If \`componentID\` is not provided, the tool will list all available components for the project and prompt the user to select one. Always return all available values without categorizing them.
16 |      5. **Version Listing:** Once both \`environmentID\` and \`componentID\` are provided, the tool will list all versions for that component in the specified environment.
17 |      6. **Selection:** User selects a version from the list for subsequent operations.
18 | 
19 |      **Parameters:**
20 |      - \`projectSlug\` (optional): The project slug from \`listFollowedProjects\` (e.g., "gh/organization/project"). Either this or \`projectID\` must be provided.
21 |      - \`projectID\` (optional): The CircleCI project ID (UUID). Either this or \`projectSlug\` must be provided.
22 |      - \`orgID\` (optional): The organization ID. If not provided, it will be automatically resolved from \`projectSlug\` or \`projectID\`.
23 |      - \`environmentID\` (optional): The environment ID. If not provided, available environments will be listed.
24 |      - \`componentID\` (optional): The component ID. If not provided, available components will be listed.
25 | 
26 |      **Behavior:**
27 |      - The tool will guide you through the selection process step by step.
28 |      - Automatically resolves \`orgID\` from \`projectSlug\` or \`projectID\` when needed.
29 |      - When \`environmentID\` is missing, it lists environments and waits for user selection.
30 |      - When \`componentID\` is missing (but \`environmentID\` is provided), it lists components and waits for user selection.
31 |      - Only when both \`environmentID\` and \`componentID\` are provided will it list the actual component versions.
32 |      - Make multiple calls to this tool as you gather the required parameters.
33 | 
34 |      **Common Use Cases:**
35 |      - Identify which versions were deployed for a component
36 |      - Identify which versions are live for a component
37 |      - Identify which versions were deployed to an environment for a component
38 |      - Identify which versions are not live for a component in an environment
39 |      - Select a version for rollback or deployment operations
40 |      - Obtain version name, namespace, and environment details for other CircleCI tools
41 | 
42 |      **Returns:**
43 |      - When missing \`environmentID\`: A list of available environments with their IDs
44 |      - When missing \`componentID\`: A list of available components with their IDs  
45 |      - When both \`environmentID\` and \`componentID\` provided: A list of component versions with version name, namespace, environment ID, and is_live status
46 | 
47 |      **Important Notes:**
48 |      - This tool requires multiple calls to gather all necessary information.
49 |      - Either \`projectSlug\` or \`projectID\` must be provided; the tool will resolve the missing project information automatically.
50 |      - The tool will prompt for missing \`environmentID\` and \`componentID\` by providing selection lists.
51 |      - Always use the exact IDs returned by the tool in subsequent calls.
52 |      - If pagination limits are reached, the tool will indicate that not all items could be displayed.
53 | 
54 |      **IMPORTANT:** Do not automatically run additional tools after this tool is called. Wait for explicit user instruction before executing further tool calls. The LLM MUST NOT invoke other CircleCI tools until receiving clear instruction from the user about what to do next, even if the user selects an option. It is acceptable to list out tool call options for the user to choose from, but do not execute them until instructed.
55 |      `,
56 |    inputSchema: listComponentVersionsInputSchema,
57 |  };
58 |  
```

--------------------------------------------------------------------------------
/src/transports/unified.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import express from 'express';
  2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
  3 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
  4 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
  5 | import type { Response } from 'express';
  6 | 
  7 | // Debug subclass that logs every payload sent over SSE
  8 | class DebugSSETransport extends SSEServerTransport {
  9 |   constructor(path: string, res: Response) {
 10 |     super(path, res);
 11 |   }
 12 |   override async send(payload: any) {
 13 |     if (process.env.debug === 'true') {
 14 |       console.error('[DEBUG] SSE out ->', JSON.stringify(payload));
 15 |     }
 16 |     return super.send(payload);
 17 |   }
 18 | }
 19 | 
 20 | /**
 21 |  * Unified MCP transport: Streamable HTTP + SSE on same app/port/session.
 22 |  * - POST /mcp: JSON-RPC (single or chunked JSON)
 23 |  * - GET /mcp: SSE stream (server-initiated notifications)
 24 |  * - DELETE /mcp: Session termination
 25 |  */
 26 | export const createUnifiedTransport = (server: McpServer) => {
 27 |   const app = express();
 28 |   app.use(express.json());
 29 | 
 30 |   // Stateless: No in-memory session or transport store
 31 | 
 32 |   // Health check
 33 |   app.get('/ping', (_req, res) => {
 34 |     res.json({ result: 'pong' });
 35 |   });
 36 | 
 37 |   // GET /mcp → open SSE stream, assign session if needed (stateless)
 38 |   app.get('/mcp', (req, res) => {
 39 |     (async () => {
 40 |       if (process.env.debug === 'true') {
 41 |         const sessionId =
 42 |           req.header('Mcp-Session-Id') ||
 43 |           req.header('mcp-session-id') ||
 44 |           (req.query.sessionId as string);
 45 |         console.error(`[DEBUG] [GET /mcp] Incoming session:`, sessionId);
 46 |       }
 47 |       // Create SSE transport (stateless)
 48 |       const transport = new DebugSSETransport('/mcp', res);
 49 |       if (process.env.debug === 'true') {
 50 |         console.error(`[DEBUG] [GET /mcp] Created SSE transport.`);
 51 |       }
 52 |       await server.connect(transport);
 53 |       // Notify newly connected client of current tool catalogue
 54 |       await server.sendToolListChanged();
 55 |       // SSE connection will be closed by client or on disconnect
 56 |     })().catch((err) => {
 57 |       console.error('GET /mcp error:', err);
 58 |       if (!res.headersSent) res.status(500).end();
 59 |     });
 60 |   });
 61 | 
 62 |   // POST /mcp → Streamable HTTP, session-aware
 63 |   app.post('/mcp', (req, res) => {
 64 |     (async () => {
 65 |       try {
 66 |         if (process.env.debug === 'true') {
 67 |           const names = Object.keys((server as any)._registeredTools ?? {});
 68 |           console.error(`[DEBUG] visible tools:`, names);
 69 |           console.error(
 70 |             `[DEBUG] incoming request body:`,
 71 |             JSON.stringify(req.body),
 72 |           );
 73 |         }
 74 | 
 75 |         // For each POST, create a temporary, stateless transport to handle the request/response cycle.
 76 |         const httpTransport = new StreamableHTTPServerTransport({
 77 |           sessionIdGenerator: undefined, // Ensures stateless operation
 78 |         });
 79 | 
 80 |         // Connect the server to the transport. This wires the server's internal `_handleRequest`
 81 |         // method to the transport's `onmessage` event.
 82 |         await server.connect(httpTransport);
 83 | 
 84 |         // Handle the request. The transport will receive the request, pass it to the server via
 85 |         // `onmessage`, receive the response from the server via its `send` method, and then
 86 |         // write the response back to the client over the HTTP connection.
 87 |         await httpTransport.handleRequest(req, res, req.body);
 88 | 
 89 |         // After responding to initialize, send tool catalogue again so the freshly initialised
 90 |         // client is guaranteed to see it (the first notification may have been sent before it
 91 |         // started listening on the SSE stream).
 92 |         if (req.body?.method === 'initialize') {
 93 |           if (process.env.debug === 'true') {
 94 |             console.error(
 95 |               '[DEBUG] initialize handled -> sending tools/list_changed again',
 96 |             );
 97 |           }
 98 |           await server.sendToolListChanged();
 99 |         }
100 |       } catch (error: any) {
101 |         console.error('Error handling MCP request:', error);
102 |         if (!res.headersSent) {
103 |           res.status(500).json({
104 |             jsonrpc: '2.0',
105 |             error: {
106 |               code: -32603,
107 |               message: 'Internal server error',
108 |               data: error.message,
109 |             },
110 |             id: req.body?.id || null,
111 |           });
112 |         }
113 |       }
114 |     })().catch((err) => {
115 |       console.error('POST /mcp error:', err);
116 |       if (!res.headersSent) res.status(500).end();
117 |     });
118 |   });
119 | 
120 |   // DELETE /mcp → stateless: acknowledge only
121 |   app.delete('/mcp', (req, res) => {
122 |     const sessionId =
123 |       req.header('Mcp-Session-Id') ||
124 |       req.header('mcp-session-id') ||
125 |       (req.query.sessionId as string);
126 |     if (process.env.debug === 'true') {
127 |       console.error(`[DEBUG] [DELETE /mcp] Incoming sessionId:`, sessionId);
128 |     }
129 |     res.status(204).end();
130 |   });
131 | 
132 |   const port = process.env.port || 8000;
133 |   app.listen(port, () => {
134 |     console.error(
135 |       `CircleCI MCP unified HTTP+SSE server listening on http://0.0.0.0:${port}`,
136 |     );
137 |   });
138 | };
139 | 
```

--------------------------------------------------------------------------------
/src/tools/createPromptTemplate/tool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { createPromptTemplateInputSchema } from './inputSchema.js';
 2 | import {
 3 |   PromptOrigin,
 4 |   promptsOutputDirectory,
 5 |   PromptWorkbenchToolName,
 6 |   fileNameTemplate,
 7 |   fileNameExample1,
 8 |   fileNameExample2,
 9 |   fileNameExample3,
10 |   defaultModel,
11 |   defaultTemperature,
12 | } from '../shared/constants.js';
13 | 
14 | const paramsKey = 'params';
15 | const promptKey = 'prompt';
16 | const promptOriginKey = 'promptOrigin';
17 | const templateKey = 'template';
18 | const contextSchemaKey = '`contextSchema`';
19 | const modelKey = 'model';
20 | const temperatureKey = 'temperature';
21 | 
22 | export const createPromptTemplateTool = {
23 |   name: PromptWorkbenchToolName.create_prompt_template,
24 |   description: `
25 |   ABOUT THIS TOOL:
26 |   - This tool is part of a toolchain that generates and provides test cases for a prompt template.
27 |   - This tool helps an AI assistant to generate a prompt template based on one of the following:
28 |     1. feature requirements defined by a user - in which case the tool will generate a new prompt template based on the feature requirements.
29 |     2. a pre-existing prompt or prompt template that a user wants to test, evaluate, or modify - in which case the tool will convert it into a more structured and testable prompt template while leaving the original prompt language relatively unchanged.
30 |   - This tool will return a structured prompt template (e.g. \`${templateKey}\`) along with a context schema (e.g. \`${contextSchemaKey}\`) that defines the expected input parameters for the prompt template.
31 |   - In some cases, a user will want to add test coverage for ALL of the prompts in a given application. In these cases, the AI agent should use this tool to generate a prompt template for each prompt in the application, and should check the entire application for AI prompts that are not already covered by a prompt template in the \`${promptsOutputDirectory}\` directory.
32 | 
33 |   WHEN SHOULD THIS TOOL BE TRIGGERED?
34 |   - This tool should be triggered whenever the user provides requirements for a new AI-enabled application or a new AI-enabled feature of an existing  application (i.e. one that requires a prompt request to an LLM or any AI model).
35 |   - This tool should also be triggered if the user provides a pre-existing prompt or prompt template from their codebase that they want to test, evaluate, or modify.
36 |   - This tool should be triggered even if there are pre-existing files in the \`${promptsOutputDirectory}\` directory with the \`${fileNameTemplate}\` convention (e.g. \`${fileNameExample1}\`, \`${fileNameExample2}\`, \`${fileNameExample3}\`, etc.). Similar files should NEVER be generated directly by the AI agent. Instead, the AI agent should use this tool to first generate a new prompt template.
37 | 
38 |   PARAMETERS:
39 |   - ${paramsKey}: object
40 |     - ${promptKey}: string (the feature requirements or pre-existing prompt/prompt template that will be used to generate a prompt template. Can be a multi-line string.)
41 |     - ${promptOriginKey}: "${PromptOrigin.codebase}" | "${PromptOrigin.requirements}" (indicates whether the prompt comes from an existing codebase or from new requirements)
42 |     - ${modelKey}: string (the model that the prompt template will be tested against. Explicitly specify the model if it can be inferred from the codebase. Otherwise, defaults to \`${defaultModel}\`.)
43 |     - ${temperatureKey}: number (the temperature of the prompt template. Explicitly specify the temperature if it can be inferred from the codebase. Otherwise, defaults to ${defaultTemperature}.)
44 | 
45 |   EXAMPLE USAGE (from new requirements):
46 |   {
47 |     "${paramsKey}": {
48 |       "${promptKey}": "Create an app that takes any topic and an age (in years), then renders a 1-minute bedtime story for a person of that age.",
49 |       "${promptOriginKey}": "${PromptOrigin.requirements}"
50 |       "${modelKey}": "${defaultModel}"
51 |       "${temperatureKey}": 1.0
52 |     }
53 |   }
54 | 
55 |   EXAMPLE USAGE (from pre-existing prompt/prompt template in codebase):
56 |   {
57 |     "${paramsKey}": {
58 |       "${promptKey}": "The user wants a bedtime story about {{topic}} for a person of age {{age}} years old. Please craft a captivating tale that captivates their imagination and provides a delightful bedtime experience.",
59 |       "${promptOriginKey}": "${PromptOrigin.codebase}"
60 |       "${modelKey}": "claude-3-5-sonnet-latest"
61 |       "${temperatureKey}": 0.7
62 |     }
63 |   }
64 | 
65 |   TOOL OUTPUT INSTRUCTIONS:
66 |   - The tool will return...
67 |     - a \`${templateKey}\` that reformulates the user's prompt into a more structured format.
68 |     - a \`${contextSchemaKey}\` that defines the expected input parameters for the template.
69 |     - a \`${promptOriginKey}\` that indicates whether the prompt comes from an existing prompt or prompt template in the user's codebase or from new requirements.
70 |   - The tool output -- the \`${templateKey}\`, \`${contextSchemaKey}\`, and \`${promptOriginKey}\` -- will also be used as input to the \`${PromptWorkbenchToolName.recommend_prompt_template_tests}\` tool to generate a list of recommended tests that can be used to test the prompt template.
71 |   `,
72 |   inputSchema: createPromptTemplateInputSchema,
73 | };
74 | 
```

--------------------------------------------------------------------------------
/src/lib/usage-api/getUsageApiData.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { gunzipSync } from 'zlib';
  2 | import { getCircleCIClient } from '../../clients/client.js';
  3 | import mcpErrorOutput from '../../lib/mcpErrorOutput.js';
  4 | import fs from 'fs';
  5 | import path from 'path';
  6 | import os from 'os';
  7 | 
  8 | type CircleCIClient = ReturnType<typeof getCircleCIClient>;
  9 | 
 10 | function resolveOutputDir(outputDir: string): string {
 11 |   if (outputDir.startsWith('~')) {
 12 |     return path.join(os.homedir(), outputDir.slice(1));
 13 |   }
 14 |   if (outputDir.includes('%USERPROFILE%')) {
 15 |     const userProfile = process.env.USERPROFILE || os.homedir();
 16 |     return outputDir.replace('%USERPROFILE%', userProfile);
 17 |   }
 18 |   return outputDir;
 19 | }
 20 | 
 21 | export async function downloadAndSaveUsageData(
 22 |   downloadUrl: string,
 23 |   outputDir: string,
 24 |   opts: { startDate?: string; endDate?: string; jobId?: string }
 25 | ) {
 26 |   try {
 27 |     const gzippedCsvResponse = await fetch(downloadUrl);
 28 |     if (!gzippedCsvResponse.ok) {
 29 |       const csvText = await gzippedCsvResponse.text();
 30 |       return mcpErrorOutput(`ERROR: Failed to download CSV.\nStatus: ${gzippedCsvResponse.status} ${gzippedCsvResponse.statusText}\nResponse: ${csvText}`);
 31 |     }
 32 |     const gzBuffer = Buffer.from(await gzippedCsvResponse.arrayBuffer());
 33 |     const csv = gunzipSync(gzBuffer);
 34 | 
 35 |     const fileName = (() => {
 36 |       if (opts.startDate && opts.endDate) {
 37 |         return `usage-data-${opts.startDate.slice(0, 10)}_${opts.endDate.slice(0, 10)}.csv`;
 38 |       }
 39 |       if (opts.jobId) {
 40 |         return `usage-data-job-${opts.jobId}.csv`;
 41 |       }
 42 |       return `usage-data-${Date.now()}.csv`;
 43 |     })();
 44 |     const usageDataDir = path.resolve(resolveOutputDir(outputDir));
 45 |     const filePath = path.join(usageDataDir, fileName);
 46 | 
 47 |     if (!fs.existsSync(usageDataDir)) {
 48 |       fs.mkdirSync(usageDataDir, { recursive: true });
 49 |     }
 50 |     fs.writeFileSync(filePath, csv);
 51 |     
 52 |     return { content: [{ type: 'text' as const, text: `Usage data CSV downloaded and saved to: ${filePath}` }] };
 53 |   } catch (e: any) {
 54 |     return mcpErrorOutput(`ERROR: Failed to download or save usage data.\nError: ${e?.stack || e}`);
 55 |   }
 56 | }
 57 | 
 58 | export async function handleExistingJob({ client, orgId, jobId, outputDir, startDate, endDate }: { client: CircleCIClient, orgId: string, jobId: string, outputDir: string, startDate?: string, endDate?: string }) {
 59 |   let jobStatus: any;
 60 |   try {
 61 |     jobStatus = await client.usage.getUsageExportJobStatus(orgId, jobId);
 62 |   } catch (e: any) {
 63 |     return mcpErrorOutput(`ERROR: Could not fetch job status for jobId ${jobId}.\n${e?.stack || e}`);
 64 |   }
 65 | 
 66 |     const state = jobStatus?.state?.toLowerCase();
 67 | 
 68 |   switch (state) {
 69 |     case 'completed': {
 70 |       const downloadUrls = jobStatus?.download_urls;
 71 |       const downloadUrl = Array.isArray(downloadUrls) && downloadUrls.length > 0 ? downloadUrls[0] : null;
 72 | 
 73 |       if (!downloadUrl) {
 74 |         return mcpErrorOutput(`ERROR: No download_url found in job status.\nJob status: ${JSON.stringify(jobStatus, null, 2)}`);
 75 |       }
 76 |       return await downloadAndSaveUsageData(downloadUrl, outputDir, { startDate, endDate, jobId });
 77 |     }
 78 |     case 'created':
 79 |     case 'pending':
 80 |     case 'processing':
 81 |       return {
 82 |         content: [
 83 |           { type: 'text' as const, text: `Usage export job is still processing. Please try again in a minute. (Job ID: ${jobId})` }
 84 |         ],
 85 |       };
 86 |     default:
 87 |       return mcpErrorOutput(`ERROR: Unknown job state: ${state}.\nJob status: ${JSON.stringify(jobStatus, null, 2)}`);
 88 |   }
 89 | }
 90 | 
 91 | export async function startNewUsageExportJob({ client, orgId, startDate, endDate }: { client: CircleCIClient, orgId: string, startDate: string, endDate: string }) {
 92 |   let createJson: any;
 93 |   try {
 94 |     createJson = await client.usage.startUsageExportJob(orgId, startDate, endDate);
 95 |   } catch (e: any) {
 96 |     return mcpErrorOutput(`ERROR: Failed to start usage export job.\n${e?.stack || e}`);
 97 |   }
 98 | 
 99 |   const newJobId = createJson?.usage_export_job_id;
100 |   if (!newJobId) {
101 |     return mcpErrorOutput(`ERROR: No usage export id returned.\nResponse: ${JSON.stringify(createJson)}`);
102 |   }
103 | 
104 |   return {
105 |     content: [
106 |       { type: 'text' as const, text: `Started a new usage export job for your requested date range.\n\nTo check the status or download the file, say "check status".\n\nYou do NOT need to provide the job ID; the system will track it for you automatically.\n\nJob ID: ${newJobId}` }
107 |     ],
108 |     jobId: newJobId
109 |   };
110 | }
111 | 
112 | export async function getUsageApiData({ orgId, startDate, endDate, jobId, outputDir }: { orgId: string, startDate?: string, endDate?: string, jobId?: string, outputDir: string }) {
113 |   if (!outputDir) {
114 |     return mcpErrorOutput('ERROR: outputDir is required. Please specify a directory to save the usage data CSV.');
115 |   }
116 |   const client = getCircleCIClient();
117 | 
118 |   if (jobId) {
119 |     return await handleExistingJob({ client, orgId, jobId, outputDir, startDate, endDate });
120 |   } else {
121 |     if (!startDate || !endDate) {
122 |       return mcpErrorOutput('ERROR: startDate and endDate are required when starting a new usage export job.');
123 |     }
124 |     return await startNewUsageExportJob({ client, orgId, startDate, endDate });
125 |   }
126 | } 
```

--------------------------------------------------------------------------------
/src/tools/getFlakyTests/handler.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
  2 | import {
  3 |   getProjectSlugFromURL,
  4 |   identifyProjectSlug,
  5 | } from '../../lib/project-detection/index.js';
  6 | import { getFlakyTestLogsInputSchema } from './inputSchema.js';
  7 | import getFlakyTests, {
  8 |   formatFlakyTests,
  9 | } from '../../lib/flaky-tests/getFlakyTests.js';
 10 | import mcpErrorOutput from '../../lib/mcpErrorOutput.js';
 11 | import { writeFileSync, mkdirSync, rmSync } from 'fs';
 12 | import { join } from 'path';
 13 | import { Test } from '../../clients/schemas.js';
 14 | 
 15 | export const getFlakyTestsOutputDirectory = () =>
 16 |   `${process.env.FILE_OUTPUT_DIRECTORY}/flaky-tests-output`;
 17 | 
 18 | export const getFlakyTestLogs: ToolCallback<{
 19 |   params: typeof getFlakyTestLogsInputSchema;
 20 | }> = async (args) => {
 21 |   const {
 22 |     workspaceRoot,
 23 |     gitRemoteURL,
 24 |     projectURL,
 25 |     projectSlug: inputProjectSlug,
 26 |   } = args.params ?? {};
 27 | 
 28 |   let projectSlug: string | null | undefined;
 29 | 
 30 |   if (inputProjectSlug) {
 31 |     projectSlug = inputProjectSlug;
 32 |   } else if (projectURL) {
 33 |     projectSlug = getProjectSlugFromURL(projectURL);
 34 |   } else if (workspaceRoot && gitRemoteURL) {
 35 |     projectSlug = await identifyProjectSlug({
 36 |       gitRemoteURL,
 37 |     });
 38 |   } else {
 39 |     return mcpErrorOutput(
 40 |       'Missing required inputs. Please provide either: 1) projectSlug, 2) projectURL, or 3) workspaceRoot with gitRemoteURL.',
 41 |     );
 42 |   }
 43 | 
 44 |   if (!projectSlug) {
 45 |     return mcpErrorOutput(`
 46 |           Project not found. Ask the user to provide the inputs user can provide based on the tool description.
 47 | 
 48 |           Project slug: ${projectSlug}
 49 |           Git remote URL: ${gitRemoteURL}
 50 |           `);
 51 |   }
 52 | 
 53 |   const tests = await getFlakyTests({
 54 |     projectSlug,
 55 |   });
 56 | 
 57 |   if (process.env.FILE_OUTPUT_DIRECTORY) {
 58 |     try {
 59 |       return await writeTestsToFiles({ tests });
 60 |     } catch (error) {
 61 |       console.error(error);
 62 |       return formatFlakyTests(tests);
 63 |     }
 64 |   }
 65 | 
 66 |   return formatFlakyTests(tests);
 67 | };
 68 | 
 69 | const generateSafeFilename = ({
 70 |   test,
 71 |   index,
 72 | }: {
 73 |   test: Test;
 74 |   index: number;
 75 | }): string => {
 76 |   const safeTestName = (test.name || 'unnamed-test')
 77 |     .replace(/[^a-zA-Z0-9\-_]/g, '_')
 78 |     .substring(0, 50); // Limit length
 79 | 
 80 |   return `flaky-test-${index + 1}-${safeTestName}.txt`;
 81 | };
 82 | 
 83 | /**
 84 |  * Write test data to a file
 85 |  */
 86 | const writeTestToFile = ({
 87 |   test,
 88 |   filePath,
 89 |   index,
 90 | }: {
 91 |   test: Test;
 92 |   filePath: string;
 93 |   index: number;
 94 | }): void => {
 95 |   const testContent = [
 96 |     `Flaky Test #${index + 1}`,
 97 |     '='.repeat(50),
 98 |     test.file && `File Name: ${test.file}`,
 99 |     test.classname && `Classname: ${test.classname}`,
100 |     test.name && `Test name: ${test.name}`,
101 |     test.result && `Result: ${test.result}`,
102 |     test.run_time && `Run time: ${test.run_time}`,
103 |     test.message && `Message: ${test.message}`,
104 |     '',
105 |     'Raw Test Data:',
106 |     '-'.repeat(20),
107 |     JSON.stringify(test, null, 2),
108 |   ]
109 |     .filter(Boolean)
110 |     .join('\n');
111 | 
112 |   writeFileSync(filePath, testContent, 'utf8');
113 | };
114 | 
115 | /**
116 |  * Write flaky tests to individual files
117 |  * @param params Configuration parameters
118 |  * @param params.tests Array of test objects to write to files
119 |  * @returns Response object with success message or error
120 |  */
121 | const writeTestsToFiles = async ({
122 |   tests,
123 | }: {
124 |   tests: Test[];
125 | }): Promise<{
126 |   content: {
127 |     type: 'text';
128 |     text: string;
129 |   }[];
130 | }> => {
131 |   if (tests.length === 0) {
132 |     return {
133 |       content: [
134 |         {
135 |           type: 'text' as const,
136 |           text: 'No flaky tests found - no files created',
137 |         },
138 |       ],
139 |     };
140 |   }
141 | 
142 |   const flakyTestsOutputDirectory = getFlakyTestsOutputDirectory();
143 | 
144 |   try {
145 |     rmSync(flakyTestsOutputDirectory, { recursive: true, force: true });
146 |     mkdirSync(flakyTestsOutputDirectory, { recursive: true });
147 | 
148 |     // Create .gitignore to ignore all files in this directory
149 |     const gitignorePath = join(flakyTestsOutputDirectory, '.gitignore');
150 |     const gitignoreContent = '# Ignore all flaky test output files\n*\n';
151 |     writeFileSync(gitignorePath, gitignoreContent, 'utf8');
152 |   } catch (error) {
153 |     throw new Error(
154 |       `Failed to create output directory: ${error instanceof Error ? error.message : String(error)}`,
155 |     );
156 |   }
157 | 
158 |   const filePaths: string[] = [];
159 | 
160 |   try {
161 |     tests.forEach((test, index) => {
162 |       const filename = generateSafeFilename({ test, index });
163 |       const filePath = join(flakyTestsOutputDirectory, filename);
164 | 
165 |       writeTestToFile({ test, filePath, index });
166 |       filePaths.push(filePath);
167 |     });
168 | 
169 |     return {
170 |       content: [
171 |         {
172 |           type: 'text' as const,
173 |           text: `Found ${tests.length} flaky tests that need stabilization. Each file contains test failure data and metadata - analyze these reports to understand what's causing the flakiness, then locate and fix the actual test code.\n\nFlaky test reports:\n${filePaths.map((path) => `- ${path}`).join('\n')}\n\nFiles are located in: ${flakyTestsOutputDirectory}`,
174 |         },
175 |       ],
176 |     };
177 |   } catch (error) {
178 |     return mcpErrorOutput(
179 |       `Failed to write flaky test files: ${error instanceof Error ? error.message : String(error)}`,
180 |     );
181 |   }
182 | };
183 | 
```

--------------------------------------------------------------------------------
/src/tools/runRollbackPipeline/tool.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { runRollbackPipelineInputSchema } from './inputSchema.js';
 2 | 
 3 | export const runRollbackPipelineTool = {
 4 |   name: 'run_rollback_pipeline' as const,
 5 |   description: `
 6 |     Run a rollback pipeline for a CircleCI project. This tool guides you through the full rollback process, adapting to the information you provide and prompting for any missing details.
 7 | 
 8 |     **Initial Requirements:**
 9 |     - You need either a \`projectSlug\` (from \`listFollowedProjects\`) or a \`projectID\`. The tool will automatically resolve the project information from either of these.
10 | 
11 |     **Typical Flow:**
12 |     1. **Start:** User initiates a rollback request.
13 |     2. **Project Selection:** If project id or project slug are not provided, call \`listFollowedProjects\` to get the list of projects the user follows and present the full list of projects to the user so that they can select the project they want to rollback.
14 |     3. **Project Information:** Provide either \`projectSlug\` or \`projectID\`. The tool will automatically resolve the project information as needed.
15 |     4. **Version Selection:** If component environment and version are not provided, call \`listComponentVersions\` to get the list of versions for the selected component and environment. If there is only one version, proceed automatically and do not ask the user to select a version. Otherwise, present the user with the full list of versions and ask them to select one. Always return all available values without categorizing them.
16 |     5. **Rollback Reason** ask the user for an optional reason for the rollback (e.g., "Critical bug fix"). Skip this step is the user explicitly requests a rollback by workflow rerun.
17 |     6. **Rollback pipeline check** if the tool reports that no rollback pipeline is defined, ask the user if they want to trigger a rollback by workflow rerun or suggest to setup a rollback pipeline following the documentation at https://circleci.com/docs/deploy/rollback-a-project-using-the-rollback-pipeline/.
18 |     7. **Confirmation:** Summarize the rollback request and confirm with the user before submitting.
19 |     8. **Pipeline Rollback:**  if the user requested a rollback by pipeline, call \`runRollbackPipeline\` passing all parameters including the namespace associated with the version to the tool.
20 |     9. **Workflow Rerun** If the user requested a rollback by workflow rerun, call \`rerunWorkflow\` passing the workflow ID of the selected version to the tool.
21 |     10.**Completion:** Report the outcome of the operation.
22 | 
23 |     **Parameters:**
24 |     - \`projectSlug\` (optional): The project slug from \`listFollowedProjects\` (e.g., "gh/organization/project"). Either this or \`projectID\` must be provided.
25 |     - \`projectID\` (optional): The CircleCI project ID (UUID). Either this or \`projectSlug\` must be provided.
26 |     - \`environmentName\` (required): The target environment (e.g., "production", "staging").
27 |     - \`componentName\` (required): The component to rollback (e.g., "frontend", "backend").
28 |     - \`currentVersion\` (required): The currently deployed version.
29 |     - \`targetVersion\` (required): The version to rollback to.
30 |     - \`namespace\` (required): The namespace of the component.
31 |     - \`reason\` (optional): Reason for the rollback.
32 |     - \`parameters\` (optional): Additional rollback parameters as key-value pairs.
33 | 
34 |     **Behavior:**
35 |     - If there are more than 20 environments or components, ask the user to refine their selection.
36 |     - Never attempt to guess or construct project slugs or URLs; always use values provided by the user or from \`listFollowedProjects\`.
37 |     - Do not prompt for missing parameters until versions have been listed.
38 |     - Do not call this tool with incomplete parameters.
39 |     - If the selected project lacks rollback pipeline configuration, provide a definitive error message without suggesting alternative projects.
40 | 
41 |     **Returns:**
42 |     - On success: The rollback ID or a confirmation in case of workflow rerun.
43 |     - On error: A clear message describing what is missing or what went wrong.
44 |     - If the selected project does not have a rollback pipeline configured: The tool will provide a clear error message specific to that project and will NOT suggest trying another project.
45 | 
46 |     **Important Note:**
47 |     - This tool is designed to work only with the specific project provided by the user.
48 |     - If a project does not have rollback capability configured, the tool will NOT recommend trying other projects.
49 |     - The assistant should NOT suggest trying different projects when a project lacks rollback configuration.
50 |     - Each project must have its own rollback pipeline configuration to be eligible for rollback operations.
51 |     - When a project cannot be rolled back, provide only the configuration guidance for THAT specific project.
52 |     - The tool automatically resolves project information from either \`projectSlug\` or \`projectID\`.
53 |     If no version is found, the tool will suggest the user to set up deploy markers following the documentation at:
54 |     https://circleci.com/docs/deploy/configure-deploy-markers/
55 |   `,
56 |   inputSchema: runRollbackPipelineInputSchema,
57 | };
58 | 
```

--------------------------------------------------------------------------------
/src/tools/getBuildFailureLogs/handler.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 | import { getBuildFailureLogs } from './handler.js';
  3 | import * as projectDetection from '../../lib/project-detection/index.js';
  4 | import * as getPipelineJobLogsModule from '../../lib/pipeline-job-logs/getPipelineJobLogs.js';
  5 | import * as formatJobLogs from '../../lib/pipeline-job-logs/getJobLogs.js';
  6 | 
  7 | // Mock dependencies
  8 | vi.mock('../../lib/project-detection/index.js');
  9 | vi.mock('../../lib/pipeline-job-logs/getPipelineJobLogs.js');
 10 | vi.mock('../../lib/pipeline-job-logs/getJobLogs.js');
 11 | 
 12 | describe('getBuildFailureLogs handler', () => {
 13 |   beforeEach(() => {
 14 |     vi.resetAllMocks();
 15 |   });
 16 | 
 17 |   it('should return a valid MCP error response when no inputs are provided', async () => {
 18 |     const args = {
 19 |       params: {},
 20 |     } as any;
 21 | 
 22 |     const controller = new AbortController();
 23 |     const response = await getBuildFailureLogs(args, {
 24 |       signal: controller.signal,
 25 |     });
 26 | 
 27 |     expect(response).toHaveProperty('content');
 28 |     expect(response).toHaveProperty('isError', true);
 29 |     expect(Array.isArray(response.content)).toBe(true);
 30 |     expect(response.content[0]).toHaveProperty('type', 'text');
 31 |     expect(typeof response.content[0].text).toBe('string');
 32 |   });
 33 | 
 34 |   it('should return a valid MCP error response when project is not found', async () => {
 35 |     vi.spyOn(projectDetection, 'identifyProjectSlug').mockResolvedValue(
 36 |       undefined,
 37 |     );
 38 | 
 39 |     const args = {
 40 |       params: {
 41 |         workspaceRoot: '/workspace',
 42 |         gitRemoteURL: 'https://github.com/org/repo.git',
 43 |         branch: 'main',
 44 |       },
 45 |     } as any;
 46 | 
 47 |     const controller = new AbortController();
 48 |     const response = await getBuildFailureLogs(args, {
 49 |       signal: controller.signal,
 50 |     });
 51 | 
 52 |     expect(response).toHaveProperty('content');
 53 |     expect(response).toHaveProperty('isError', true);
 54 |     expect(Array.isArray(response.content)).toBe(true);
 55 |     expect(response.content[0]).toHaveProperty('type', 'text');
 56 |     expect(typeof response.content[0].text).toBe('string');
 57 |   });
 58 | 
 59 |   it('should return a valid MCP error response when projectSlug is provided without branch', async () => {
 60 |     const args = {
 61 |       params: {
 62 |         projectSlug: 'gh/org/repo',
 63 |       },
 64 |     } as any;
 65 | 
 66 |     const controller = new AbortController();
 67 |     const response = await getBuildFailureLogs(args, {
 68 |       signal: controller.signal,
 69 |     });
 70 | 
 71 |     expect(response).toHaveProperty('content');
 72 |     expect(response).toHaveProperty('isError', true);
 73 |     expect(Array.isArray(response.content)).toBe(true);
 74 |     expect(response.content[0]).toHaveProperty('type', 'text');
 75 |     expect(typeof response.content[0].text).toBe('string');
 76 |     expect(response.content[0].text).toContain('Branch not provided');
 77 |   });
 78 | 
 79 |   it('should return a valid MCP success response with logs', async () => {
 80 |     vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
 81 |       'gh/org/repo',
 82 |     );
 83 | 
 84 |     vi.spyOn(getPipelineJobLogsModule, 'default').mockResolvedValue([
 85 |       {
 86 |         jobName: 'test',
 87 |         steps: [
 88 |           {
 89 |             stepName: 'Run tests',
 90 |             logs: { output: 'Test failed', error: '' },
 91 |           },
 92 |         ],
 93 |       },
 94 |     ]);
 95 | 
 96 |     vi.spyOn(formatJobLogs, 'formatJobLogs').mockReturnValue({
 97 |       content: [
 98 |         {
 99 |           type: 'text',
100 |           text: 'Job logs output',
101 |         },
102 |       ],
103 |     });
104 | 
105 |     const args = {
106 |       params: {
107 |         projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
108 |       },
109 |     } as any;
110 | 
111 |     const controller = new AbortController();
112 |     const response = await getBuildFailureLogs(args, {
113 |       signal: controller.signal,
114 |     });
115 | 
116 |     expect(response).toHaveProperty('content');
117 |     expect(Array.isArray(response.content)).toBe(true);
118 |     expect(response.content[0]).toHaveProperty('type', 'text');
119 |     expect(typeof response.content[0].text).toBe('string');
120 |   });
121 | 
122 |   it('should handle projectSlug and branch inputs correctly', async () => {
123 |     const mockLogs = [
124 |       {
125 |         jobName: 'build',
126 |         steps: [
127 |           {
128 |             stepName: 'Build app',
129 |             logs: { output: 'Build failed', error: 'Error: build failed' },
130 |           },
131 |         ],
132 |       },
133 |     ];
134 | 
135 |     vi.spyOn(getPipelineJobLogsModule, 'default').mockResolvedValue(mockLogs);
136 | 
137 |     vi.spyOn(formatJobLogs, 'formatJobLogs').mockReturnValue({
138 |       content: [
139 |         {
140 |           type: 'text',
141 |           text: 'Formatted job logs',
142 |         },
143 |       ],
144 |     });
145 | 
146 |     const args = {
147 |       params: {
148 |         projectSlug: 'gh/org/repo',
149 |         branch: 'feature/new-feature',
150 |       },
151 |     } as any;
152 | 
153 |     const controller = new AbortController();
154 |     const response = await getBuildFailureLogs(args, {
155 |       signal: controller.signal,
156 |     });
157 | 
158 |     expect(getPipelineJobLogsModule.default).toHaveBeenCalledWith({
159 |       projectSlug: 'gh/org/repo',
160 |       branch: 'feature/new-feature',
161 |       pipelineNumber: undefined,
162 |       jobNumber: undefined,
163 |     });
164 | 
165 |     expect(formatJobLogs.formatJobLogs).toHaveBeenCalledWith(mockLogs);
166 |     expect(response).toHaveProperty('content');
167 |     expect(Array.isArray(response.content)).toBe(true);
168 |     expect(response.content[0]).toHaveProperty('type', 'text');
169 |     expect(typeof response.content[0].text).toBe('string');
170 |   });
171 | });
172 | 
```

--------------------------------------------------------------------------------
/src/tools/listComponentVersions/handler.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
  2 | import { listComponentVersionsInputSchema } from './inputSchema.js';
  3 | import { getCircleCIClient } from '../../clients/client.js';
  4 | import mcpErrorOutput from '../../lib/mcpErrorOutput.js';
  5 | 
  6 | type ProjectInfo = {
  7 |   projectID: string;
  8 |   orgID: string;
  9 | };
 10 | 
 11 | 
 12 | export const listComponentVersions: ToolCallback<{
 13 |   params: typeof listComponentVersionsInputSchema;
 14 | }> = async (args) => {
 15 |   const {
 16 |     projectSlug,
 17 |     projectID: providedProjectID,
 18 |     orgID: providedOrgID,
 19 |     componentID,
 20 |     environmentID,
 21 |   } = args.params ?? {};
 22 | 
 23 |   try {
 24 |     // Resolve project and organization information
 25 |     const projectInfoResult = await resolveProjectInfo(projectSlug, providedProjectID, providedOrgID);
 26 |     
 27 |     if (!projectInfoResult.success) {
 28 |       return projectInfoResult.error;
 29 |     }
 30 | 
 31 |     const { projectID, orgID } = projectInfoResult.data;
 32 | 
 33 |     // If environmentID is not provided, list environments
 34 |     if (!environmentID) {
 35 |       return await listEnvironments(orgID);
 36 |     }
 37 | 
 38 |     // If componentID is not provided, list components
 39 |     if (!componentID) {
 40 |       return await listComponents(projectID, orgID);
 41 |     }
 42 | 
 43 |     // If both componentID and environmentID are provided, list component versions
 44 |     return await fetchComponentVersions(componentID, environmentID);
 45 | 
 46 |   } catch (error) {
 47 |     return mcpErrorOutput(
 48 |       `Failed to list component versions: ${error instanceof Error ? error.message : 'Unknown error'}`,
 49 |     );
 50 |   }
 51 | };
 52 | 
 53 | 
 54 | /**
 55 |  * Resolves project and organization information from the provided parameters
 56 |  */
 57 | async function resolveProjectInfo(
 58 |   projectSlug?: string,
 59 |   providedProjectID?: string,
 60 |   providedOrgID?: string,
 61 | ): Promise<{ success: true; data: ProjectInfo } | { success: false; error: any }> {
 62 |   const circleci = getCircleCIClient();
 63 | 
 64 |   try {
 65 |     if (providedProjectID && providedOrgID) {
 66 |       // Both IDs provided, use them directly
 67 |       return {
 68 |         success: true,
 69 |         data: {
 70 |           projectID: providedProjectID,
 71 |           orgID: providedOrgID,
 72 |         },
 73 |       };
 74 |     }
 75 | 
 76 |     if (projectSlug) {
 77 |       // Use projectSlug to get projectID and orgID
 78 |       const { id: resolvedProjectId, organization_id: resolvedOrgId } = await circleci.projects.getProject({
 79 |         projectSlug,
 80 |       });
 81 |       return {
 82 |         success: true,
 83 |         data: {
 84 |           projectID: resolvedProjectId,
 85 |           orgID: resolvedOrgId,
 86 |         },
 87 |       };
 88 |     }
 89 | 
 90 |     if (providedProjectID) {
 91 |       // Use projectID to get orgID
 92 |       const { id: resolvedProjectId, organization_id: resolvedOrgId } = await circleci.projects.getProjectByID({
 93 |         projectID: providedProjectID,
 94 |       });
 95 |       return {
 96 |         success: true,
 97 |         data: {
 98 |           projectID: resolvedProjectId,
 99 |           orgID: resolvedOrgId,
100 |         },
101 |       };
102 |     }
103 | 
104 |     return {
105 |       success: false,
106 |       error: mcpErrorOutput(`Invalid request. Please specify either a project slug or a project ID.`),
107 |     };
108 |   } catch (error) {
109 |     const errorMessage = projectSlug
110 |       ? `Failed to resolve project information for ${projectSlug}. Please verify the project slug is correct.`
111 |       : `Failed to resolve project information for project ID ${providedProjectID}. Please verify the project ID is correct.`;
112 | 
113 |     return {
114 |       success: false,
115 |       error: mcpErrorOutput(`${errorMessage} ${error instanceof Error ? error.message : 'Unknown error'}`),
116 |     };
117 |   }
118 | }
119 | 
120 | /**
121 |  * Lists available environments for the organization
122 |  */
123 | async function listEnvironments(orgID: string) {
124 |   const circleci = getCircleCIClient();
125 | 
126 |   const environments = await circleci.deploys.fetchEnvironments({
127 |     orgID,
128 |   });
129 | 
130 |   if (environments.items.length === 0) {
131 |     return {
132 |       content: [
133 |         {
134 |           type: 'text',
135 |           text: `No environments found`,
136 |         },
137 |       ],
138 |     };
139 |   }
140 | 
141 |   const environmentsList = environments.items
142 |     .map((env: any, index: number) => `${index + 1}. ${env.name} (ID: ${env.id})`)
143 |     .join('\n');
144 | 
145 |   return {
146 |     content: [
147 |       {
148 |         type: 'text',
149 |         text: `Please provide an environmentID. Available environments:\n\n${environmentsList}\n\n`,
150 |       },
151 |     ],
152 |   };
153 | }
154 | 
155 | /**
156 |  * Lists available components for the project
157 |  */
158 | async function listComponents(projectID: string, orgID: string) {
159 |   const circleci = getCircleCIClient();
160 | 
161 |   const components = await circleci.deploys.fetchProjectComponents({
162 |     projectID,
163 |     orgID,
164 |   });
165 | 
166 |   if (components.items.length === 0) {
167 |     return {
168 |       content: [
169 |         {
170 |           type: 'text',
171 |           text: `No components found`,
172 |         },
173 |       ],
174 |     };
175 |   }
176 | 
177 |   const componentsList = components.items
178 |     .map((component: any, index: number) => `${index + 1}. ${component.name} (ID: ${component.id})`)
179 |     .join('\n');
180 | 
181 |   return {
182 |     content: [
183 |       {
184 |         type: 'text',
185 |         text: `Please provide a componentID. Available components:\n\n${componentsList}\n\n`,
186 |       },
187 |     ],
188 |   };
189 | }
190 | 
191 | /**
192 |  * Lists component versions for the specified component and environment
193 |  */
194 | async function fetchComponentVersions(componentID: string, environmentID: string) {
195 |   const circleci = getCircleCIClient();
196 | 
197 |   const componentVersions = await circleci.deploys.fetchComponentVersions({
198 |     componentID,
199 |     environmentID,
200 |   });
201 | 
202 |   if (componentVersions.items.length === 0) {
203 |     return {
204 |       content: [
205 |         {
206 |           type: 'text',
207 |           text: `No component versions found`,
208 |         },
209 |       ],
210 |     };
211 |   }
212 | 
213 |   return {
214 |     content: [
215 |       {
216 |         type: 'text',
217 |         text: `Versions for the component: ${JSON.stringify(componentVersions)}`,
218 |       },
219 |     ],
220 |   };
221 | }
222 | 
```

--------------------------------------------------------------------------------
/src/tools/recommendPromptTemplateTests/handler.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
  2 | import { recommendPromptTemplateTests } from './handler.js';
  3 | import { CircletClient } from '../../clients/circlet/index.js';
  4 | import {
  5 |   defaultModel,
  6 |   PromptOrigin,
  7 |   promptsOutputDirectory,
  8 |   fileNameTemplate,
  9 |   fileNameExample1,
 10 |   fileNameExample2,
 11 |   fileNameExample3,
 12 | } from '../shared/constants.js';
 13 | 
 14 | // Mock dependencies
 15 | vi.mock('../../clients/circlet/index.js');
 16 | 
 17 | describe('recommendPromptTemplateTests handler', () => {
 18 |   beforeEach(() => {
 19 |     vi.resetAllMocks();
 20 |   });
 21 | 
 22 |   it('should return a valid MCP response with recommended tests for requirements-based prompt', async () => {
 23 |     const mockRecommendedTests = [
 24 |       'Test with variable = "value1"',
 25 |       'Test with variable = "value2"',
 26 |       'Test with empty variable',
 27 |     ];
 28 | 
 29 |     const mockRecommendPromptTemplateTests = vi
 30 |       .fn()
 31 |       .mockResolvedValue(mockRecommendedTests);
 32 | 
 33 |     const mockCircletInstance = {
 34 |       circlet: {
 35 |         recommendPromptTemplateTests: mockRecommendPromptTemplateTests,
 36 |       },
 37 |     };
 38 | 
 39 |     vi.mocked(CircletClient).mockImplementation(
 40 |       () => mockCircletInstance as any,
 41 |     );
 42 | 
 43 |     const template = 'This is a test template with {{variable}}';
 44 |     const contextSchema = {
 45 |       variable: 'Description of the variable',
 46 |     };
 47 | 
 48 |     const args = {
 49 |       params: {
 50 |         template,
 51 |         contextSchema,
 52 |         promptOrigin: PromptOrigin.requirements,
 53 |         model: defaultModel,
 54 |       },
 55 |     };
 56 | 
 57 |     const controller = new AbortController();
 58 |     const response = await recommendPromptTemplateTests(args, {
 59 |       signal: controller.signal,
 60 |     });
 61 | 
 62 |     expect(mockRecommendPromptTemplateTests).toHaveBeenCalledWith({
 63 |       template,
 64 |       contextSchema,
 65 |     });
 66 | 
 67 |     expect(response).toHaveProperty('content');
 68 |     expect(Array.isArray(response.content)).toBe(true);
 69 |     expect(response.content[0]).toHaveProperty('type', 'text');
 70 |     expect(typeof response.content[0].text).toBe('string');
 71 | 
 72 |     const responseText = response.content[0].text;
 73 | 
 74 |     // Verify recommended tests are included
 75 |     expect(responseText).toContain('recommendedTests:');
 76 |     expect(responseText).toContain(
 77 |       JSON.stringify(mockRecommendedTests, null, 2),
 78 |     );
 79 | 
 80 |     // Verify next steps and file saving instructions
 81 |     expect(responseText).toContain('NEXT STEP:');
 82 |     expect(responseText).toContain(
 83 |       'save the `promptTemplate`, `contextSchema`, and `recommendedTests`',
 84 |     );
 85 | 
 86 |     // Verify file saving rules
 87 |     expect(responseText).toContain('RULES FOR SAVING FILES:');
 88 |     expect(responseText).toContain(promptsOutputDirectory);
 89 |     expect(responseText).toContain(fileNameTemplate);
 90 |     expect(responseText).toContain(fileNameExample1);
 91 |     expect(responseText).toContain(fileNameExample2);
 92 |     expect(responseText).toContain(fileNameExample3);
 93 |     expect(responseText).toContain('`name`: string');
 94 |     expect(responseText).toContain('`description`: string');
 95 |     expect(responseText).toContain('`version`: string');
 96 |     expect(responseText).toContain('`promptOrigin`: string');
 97 |     expect(responseText).toContain('`model`: string');
 98 |     expect(responseText).toContain('`template`: multi-line string');
 99 |     expect(responseText).toContain('`contextSchema`: object');
100 |     expect(responseText).toContain('`tests`: array of objects');
101 |     expect(responseText).toContain('`sampleInputs`: object[]');
102 | 
103 |     // Should not contain integration instructions for requirements-based prompts
104 |     expect(responseText).not.toContain(
105 |       'FINALLY, ONCE ALL THE FILES ARE SAVED:',
106 |     );
107 |   });
108 | 
109 |   it('should include integration instructions for codebase-based prompts', async () => {
110 |     const mockRecommendedTests = ['Test case 1'];
111 |     const mockRecommendPromptTemplateTests = vi
112 |       .fn()
113 |       .mockResolvedValue(mockRecommendedTests);
114 | 
115 |     const mockCircletInstance = {
116 |       circlet: {
117 |         recommendPromptTemplateTests: mockRecommendPromptTemplateTests,
118 |       },
119 |     };
120 | 
121 |     vi.mocked(CircletClient).mockImplementation(
122 |       () => mockCircletInstance as any,
123 |     );
124 | 
125 |     const args = {
126 |       params: {
127 |         template: 'Test template',
128 |         contextSchema: { variable: 'description' },
129 |         promptOrigin: PromptOrigin.codebase,
130 |         model: defaultModel,
131 |       },
132 |     };
133 | 
134 |     const controller = new AbortController();
135 |     const response = await recommendPromptTemplateTests(args, {
136 |       signal: controller.signal,
137 |     });
138 | 
139 |     const responseText = response.content[0].text;
140 |     expect(responseText).toContain('FINALLY, ONCE ALL THE FILES ARE SAVED:');
141 |     expect(responseText).toContain('1. Ask user if they want to integrate');
142 |     expect(responseText).toContain('(Yes/No)');
143 |     expect(responseText).toContain(
144 |       `2. If yes, import the \`${promptsOutputDirectory}\` files into their app, following codebase conventions`,
145 |     );
146 |     expect(responseText).toContain('3. Only use existing dependencies');
147 |   });
148 | 
149 |   it('should handle errors from CircletClient', async () => {
150 |     const mockCircletInstance = {
151 |       circlet: {
152 |         recommendPromptTemplateTests: vi
153 |           .fn()
154 |           .mockRejectedValue(new Error('API error')),
155 |       },
156 |     };
157 | 
158 |     vi.mocked(CircletClient).mockImplementation(
159 |       () => mockCircletInstance as any,
160 |     );
161 | 
162 |     const args = {
163 |       params: {
164 |         template: 'Test template',
165 |         contextSchema: { variable: 'description' },
166 |         promptOrigin: PromptOrigin.requirements,
167 |         model: defaultModel,
168 |       },
169 |     };
170 | 
171 |     const controller = new AbortController();
172 | 
173 |     await expect(
174 |       recommendPromptTemplateTests(args, { signal: controller.signal }),
175 |     ).rejects.toThrow('API error');
176 |   });
177 | });
178 | 
```
Page 2/4FirstPrevNextLast