This is page 1 of 3. Use http://codebase.md/circleci-public/mcp-server-circleci?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 -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- ``` 22.14.0 ``` -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- ``` dist/ build/ ``` -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` { "printWidth": 80, "singleQuote": true, "semi": true } ``` -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- ``` registry=https://registry.npmjs.org/ @circleci:registry=https://registry.npmjs.org/ save-exact=true engine-strict=true ``` -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules # Build outputs dist # Version control .git .gitignore .github # CI/CD .circleci # Environment and config .npmrc # Docs *.md # Misc .prettierrc .prettierignore ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # vitepress build output **/.vitepress/dist # vitepress cache directory **/.vitepress/cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* .cursor # VSCode settings .DS_Store # CircleCI usage data exports usage-data-*.csv .claude .DS_Store ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # CircleCI MCP Server [](https://github.com/CircleCI-Public/mcp-server-circleci/blob/main/LICENSE) [](https://dl.circleci.com/status-badge/redirect/gh/CircleCI-Public/mcp-server-circleci/tree/main) [](https://www.npmjs.com/package/@circleci/mcp-server-circleci) Model Context Protocol (MCP) is a [new, standardized protocol](https://modelcontextprotocol.io/introduction) for managing context between large language models (LLMs) and external systems. In this repository, we provide an MCP Server for [CircleCI](https://circleci.com). This lets you use Cursor IDE, Windsurf, Copilot, or any MCP supported Client, to use natural language to accomplish things with CircleCI, e.g.: - `Find the latest failed pipeline on my branch and get logs` https://github.com/CircleCI-Public/mcp-server-circleci/wiki#circleci-mcp-server-with-cursor-ide https://github.com/user-attachments/assets/3c765985-8827-442a-a8dc-5069e01edb74 ## Requirements - CircleCI Personal API Token - you can generate one through the CircleCI. [Learn more](https://circleci.com/docs/managing-api-tokens/) or [click here](https://app.circleci.com/settings/user/tokens) for quick access. For NPX installation: - pnpm package manager - [Learn more](https://pnpm.io/installation) - Node.js >= v18.0.0 For Docker installation: - Docker - [Learn more](https://docs.docker.com/get-docker/) ## Installation ### Cursor #### Using NPX in a local MCP Server Add the following to your cursor MCP config: ```json { "mcpServers": { "circleci-mcp-server": { "command": "npx", "args": ["-y", "@circleci/mcp-server-circleci@latest"], "env": { "CIRCLECI_TOKEN": "your-circleci-token", "CIRCLECI_BASE_URL": "https://circleci.com" // Optional - required for on-prem customers only } } } } ``` #### Using Docker in a local MCP Server Add the following to your cursor MCP config: ```json { "mcpServers": { "circleci-mcp-server": { "command": "docker", "args": [ "run", "--rm", "-i", "-e", "CIRCLECI_TOKEN", "-e", "CIRCLECI_BASE_URL", "circleci:mcp-server-circleci" ], "env": { "CIRCLECI_TOKEN": "your-circleci-token", "CIRCLECI_BASE_URL": "https://circleci.com" // Optional - required for on-prem customers only } } } } ``` #### Using a Self-Managed Remote MCP Server Add the following to your cursor MCP config: ```json { "inputs": [ { "type": "promptString", "id": "circleci-token", "description": "CircleCI API Token", "password": true } ], "servers": { "circleci-mcp-server-remote": { "url": "http://your-circleci-remote-mcp-server-endpoint:8000/mcp" } } } ``` ### VS Code #### Using NPX in a local MCP Server To install CircleCI MCP Server for VS Code in `.vscode/mcp.json`: ```json { // 💡 Inputs are prompted on first server start, then stored securely by VS Code. "inputs": [ { "type": "promptString", "id": "circleci-token", "description": "CircleCI API Token", "password": true }, { "type": "promptString", "id": "circleci-base-url", "description": "CircleCI Base URL", "default": "https://circleci.com" } ], "servers": { // https://github.com/ppl-ai/modelcontextprotocol/ "circleci-mcp-server": { "type": "stdio", "command": "npx", "args": ["-y", "@circleci/mcp-server-circleci@latest"], "env": { "CIRCLECI_TOKEN": "${input:circleci-token}", "CIRCLECI_BASE_URL": "${input:circleci-base-url}" } } } } ``` #### Using Docker in a local MCP Server To install CircleCI MCP Server for VS Code in `.vscode/mcp.json` using Docker: ```json { // 💡 Inputs are prompted on first server start, then stored securely by VS Code. "inputs": [ { "type": "promptString", "id": "circleci-token", "description": "CircleCI API Token", "password": true }, { "type": "promptString", "id": "circleci-base-url", "description": "CircleCI Base URL", "default": "https://circleci.com" } ], "servers": { // https://github.com/ppl-ai/modelcontextprotocol/ "circleci-mcp-server": { "type": "stdio", "command": "docker", "args": [ "run", "--rm", "-i", "-e", "CIRCLECI_TOKEN", "-e", "CIRCLECI_BASE_URL", "circleci:mcp-server-circleci" ], "env": { "CIRCLECI_TOKEN": "${input:circleci-token}", "CIRCLECI_BASE_URL": "${input:circleci-base-url}" } } } } ``` #### Using a Self-Managed Remote MCP Server To install CircleCI MCP Server for VS Code in `.vscode/mcp.json` using a self-managed remote MCP server: ```json { "servers": { "circleci-mcp-server-remote": { "type": "sse", "url": "http://your-circleci-remote-mcp-server-endpoint:8000/mcp" } } } ``` ### Claude Desktop #### Using NPX in a local MCP Server Add the following to your claude_desktop_config.json: ```json { "mcpServers": { "circleci-mcp-server": { "command": "npx", "args": ["-y", "@circleci/mcp-server-circleci@latest"], "env": { "CIRCLECI_TOKEN": "your-circleci-token", "CIRCLECI_BASE_URL": "https://circleci.com" // Optional - required for on-prem customers only } } } } ``` To locate this file: macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` Windows: `%APPDATA%\Claude\claude_desktop_config.json` [Claude Desktop setup](https://modelcontextprotocol.io/quickstart/user) #### Using Docker in a local MCP Server Add the following to your claude_desktop_config.json: ```json { "mcpServers": { "circleci-mcp-server": { "command": "docker", "args": [ "run", "--rm", "-i", "-e", "CIRCLECI_TOKEN", "-e", "CIRCLECI_BASE_URL", "circleci:mcp-server-circleci" ], "env": { "CIRCLECI_TOKEN": "your-circleci-token", "CIRCLECI_BASE_URL": "https://circleci.com" // Optional - required for on-prem customers only } } } } ``` To find/create this file, first open your claude desktop settings. Then click on "Developer" in the left-hand bar of the Settings pane, and then click on "Edit Config" This will create a configuration file at: - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json - Windows: %APPDATA%\Claude\claude_desktop_config.json See the guide below for more information on using MCP servers with Claude Desktop: https://modelcontextprotocol.io/quickstart/user #### Using a Self-Managed Remote MCP Server Create a wrapper script first Create a script file such as 'circleci-remote-mcp.sh': ```bash #!/bin/bash export CIRCLECI_TOKEN="your-circleci-token" npx mcp-remote http://your-circleci-remote-mcp-server-endpoint:8000/mcp --allow-http ``` Make it executable: ```bash chmod +x circleci-remote-mcp.sh ``` Then add the following to your claude_desktop_config.json: ```json { "mcpServers": { "circleci-remote-mcp-server": { "command": "/full/path/to/circleci-remote-mcp.sh" } } } ``` To find/create this file, first open your Claude Desktop settings. Then click on "Developer" in the left-hand bar of the Settings pane, and then click on "Edit Config" This will create a configuration file at: - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json - Windows: %APPDATA%\Claude\claude_desktop_config.json See the guide below for more information on using MCP servers with Claude Desktop: https://modelcontextprotocol.io/quickstart/user ### Claude Code #### Using NPX in a local MCP Server After installing Claude Code, run the following command: ```bash claude mcp add circleci-mcp-server -e CIRCLECI_TOKEN=your-circleci-token -- npx -y @circleci/mcp-server-circleci@latest ``` #### Using Docker in a local MCP Server After installing Claude Code, run the following command: ```bash claude mcp add circleci-mcp-server -e CIRCLECI_TOKEN=your-circleci-token -e CIRCLECI_BASE_URL=https://circleci.com -- docker run --rm -i -e CIRCLECI_TOKEN -e CIRCLECI_BASE_URL circleci:mcp-server-circleci ``` See the guide below for more information on using MCP servers with Claude Code: https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/tutorials#set-up-model-context-protocol-mcp #### Using Self-Managed Remote MCP Server After installing Claude Code, run the following command: ```bash claude mcp add circleci-mcp-server -e CIRCLECI_TOKEN=your-circleci-token -- npx mcp-remote http://your-circleci-remote-mcp-server-endpoint:8000/mcp --allow-http ``` See the guide below for more information on using MCP servers with Claude Code: https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/tutorials#set-up-model-context-protocol-mcp ### Windsurf #### Using NPX in a local MCP Server Add the following to your windsurf mcp_config.json: ```json { "mcpServers": { "circleci-mcp-server": { "command": "npx", "args": ["-y", "@circleci/mcp-server-circleci@latest"], "env": { "CIRCLECI_TOKEN": "your-circleci-token", "CIRCLECI_BASE_URL": "https://circleci.com" // Optional - required for on-prem customers only } } } } ``` #### Using Docker in a local MCP Server Add the following to your windsurf mcp_config.json: ```json { "mcpServers": { "circleci-mcp-server": { "command": "docker", "args": [ "run", "--rm", "-i", "-e", "CIRCLECI_TOKEN", "-e", "CIRCLECI_BASE_URL", "circleci:mcp-server-circleci" ], "env": { "CIRCLECI_TOKEN": "your-circleci-token", "CIRCLECI_BASE_URL": "https://circleci.com" // Optional - required for on-prem customers only } } } } ``` #### Using Self-Managed Remote MCP Server Add the following to your windsurf mcp_config.json: ```json { "mcpServers": { "circleci": { "command": "npx", "args": [ "mcp-remote", "http://your-circleci-remote-mcp-server-endpoint:8000/mcp", "--allow-http" ], "disabled": false, "alwaysAllow": [] } } } ``` See the guide below for more information on using MCP servers with windsurf: https://docs.windsurf.com/windsurf/mcp ### Installing via Smithery To install CircleCI MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@CircleCI-Public/mcp-server-circleci): ```bash npx -y @smithery/cli install @CircleCI-Public/mcp-server-circleci --client claude ``` ### Amazon Q Developer CLi MCP client configuration in Amazon Q Developer is stored in JSON format, in a file named mcp.json. Amazon Q Developer CLI supports two levels of MCP configuration: Global Configuration: ~/.aws/amazonq/mcp.json - Applies to all workspaces Workspace Configuration: .amazonq/mcp.json - Specific to the current workspace Both files are optional; neither, one, or both can exist. If both files exist, Amazon Q Developer reads MCP configuration from both and combines them, taking the union of their contents. If there is a conflict (i.e., a server defined in the global config is also present in the workspace config), a warning is displayed and only the server entry in the workspace config is used. #### Using NPX in a local MCP Server Edit your global configuration file ~/.aws/amazonq/mcp.json or create a new one in the current workspace .amazonq/mcp.json with the following content: ```json { "mcpServers": { "circleci-local": { "command": "npx", "args": [ "-y", "@circleci/mcp-server-circleci@latest" ], "env": { "CIRCLECI_TOKEN": "YOUR_CIRCLECI_TOKEN", "CIRCLECI_BASE_URL": "https://circleci.com" // Optional - required for on-prem customers only }, "timeout": 60000 } } } ``` #### Using a Self-Managed Remote MCP Server Create a wrapper script first Create a script file such as 'circleci-remote-mcp.sh': ```bash #!/bin/bash export CIRCLECI_TOKEN="your-circleci-token" npx mcp-remote http://your-circleci-remote-mcp-server-endpoint:8000/mcp --allow-http ``` Make it executable: ```bash chmod +x circleci-remote-mcp.sh ``` Then add it: ```bash q mcp add --name circleci --command "/full/path/to/circleci-remote-mcp.sh" ``` ### Amazon Q Developer in the IDE #### Using NPX in a local MCP Server Edit your global configuration file ~/.aws/amazonq/mcp.json or create a new one in the current workspace .amazonq/mcp.json with the following content: ```json { "mcpServers": { "circleci-local": { "command": "npx", "args": [ "-y", "@circleci/mcp-server-circleci@latest" ], "env": { "CIRCLECI_TOKEN": "YOUR_CIRCLECI_TOKEN", "CIRCLECI_BASE_URL": "https://circleci.com" // Optional - required for on-prem customers only }, "timeout": 60000 } } } ``` #### Using a Self-Managed Remote MCP Server Create a wrapper script first Create a script file such as 'circleci-remote-mcp.sh': ```bash #!/bin/bash npx mcp-remote http://your-circleci-remote-mcp-server-endpoint:8000/mcp --allow-http ``` Make it executable: ```bash chmod +x circleci-remote-mcp.sh ``` Then add it to the Q Developer in your IDE: Access the MCP configuration UI (https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/mcp-ide.html#mcp-ide-configuration-access-ui). Choose the plus (+) symbol. Select the scope: global or local. If you select global scope, the MCP server configuration is stored in ~/.aws/amazonq/mcp.json and available across all your projects. If you select local scope, the configuration is stored in .amazonq/mcp.json within your current project. In the Name field, enter the name of the CircleCI remote MCP server (e.g. circleci-remote-mcp). Select the transport protocol (stdio). In the Command field, enter the shell command created previously that the MCP server will run when it initializes (e.g. /full/path/to/circleci-remote-mcp.sh). Click the Save button. # Features ## Supported Tools - `get_build_failure_logs` Retrieves detailed failure logs from CircleCI builds. This tool can be used in three ways: 1. Using Project Slug and Branch (Recommended Workflow): - First, list your available projects: - Use the list_followed_projects tool to get your projects - Example: "List my CircleCI projects" - Then choose the project, which has a projectSlug associated with it - Example: "Lets use my-project" - Then ask to retrieve the build failure logs for a specific branch: - Example: "Get build failures for my-project on the main branch" 2. Using CircleCI URLs: - Provide a failed job URL or pipeline URL directly - Example: "Get logs from https://app.circleci.com/pipelines/github/org/repo/123" 3. Using Local Project Context: - Works from your local workspace by providing: - Workspace root path - Git remote URL - Branch name - Example: "Find the latest failed pipeline on my current branch" The tool returns formatted logs including: - Job names - Step-by-step execution details - Failure messages and context This is particularly useful for: - Debugging failed builds - Analyzing test failures - Investigating deployment issues - Quick access to build logs without leaving your IDE - `find_flaky_tests` Identifies flaky tests in your CircleCI project by analyzing test execution history. This leverages the flaky test detection feature described here: https://circleci.com/blog/introducing-test-insights-with-flaky-test-detection/#flaky-test-detection This tool can be used in three ways: 1. Using Project Slug (Recommended Workflow): - First, list your available projects: - Use the list_followed_projects tool to get your projects - Example: "List my CircleCI projects" - Then choose the project, which has a projectSlug associated with it - Example: "Lets use my-project" - Then ask to retrieve the flaky tests: - Example: "Get flaky tests for my-project" 2. Using CircleCI Project URL: - Provide the project URL directly from CircleCI - Example: "Find flaky tests in https://app.circleci.com/pipelines/github/org/repo" 3. Using Local Project Context: - Works from your local workspace by providing: - Workspace root path - Git remote URL - Example: "Find flaky tests in my current project" The tool can be used in two ways: 1. Using text output mode (default): - This will return the flaky tests and their details in a text format 2. Using file output mode: (requires the `FILE_OUTPUT_DIRECTORY` environment variable to be set) - This will create a directory with the flaky tests and their details The tool returns detailed information about flaky tests, including: - Test names and file locations - Failure messages and contexts This helps you: - Identify unreliable tests in your test suite - Get detailed context about test failures - Make data-driven decisions about test improvements - `get_latest_pipeline_status` Retrieves the status of the latest pipeline for a given branch. This tool can be used in three ways: 1. Using Project Slug and Branch (Recommended Workflow): - First, list your available projects: - Use the list_followed_projects tool to get your projects - Example: "List my CircleCI projects" - Then choose the project, which has a projectSlug associated with it - Example: "Lets use my-project" - Then ask to retrieve the latest pipeline status for a specific branch: - Example: "Get the status of the latest pipeline for my-project on the main branch" 2. Using CircleCI Project URL: - Provide the project URL directly from CircleCI - Example: "Get the status of the latest pipeline for https://app.circleci.com/pipelines/github/org/repo" 3. Using Local Project Context: - Works from your local workspace by providing: - Workspace root path - Git remote URL - Branch name - Example: "Get the status of the latest pipeline for my current project" The tool returns a formatted status of the latest pipeline: - Workflow names and their current status - Duration of each workflow - Creation and completion timestamps - Overall pipeline health Example output: ``` --- Workflow: build Status: success Duration: 5 minutes Created: 4/20/2025, 10:15:30 AM Stopped: 4/20/2025, 10:20:45 AM --- Workflow: test Status: running Duration: unknown Created: 4/20/2025, 10:21:00 AM Stopped: in progress ``` This is particularly useful for: - Checking the status of the latest pipeline - Getting the status of the latest pipeline for a specific branch - Quickly checking the status of the latest pipeline without leaving your IDE - `get_job_test_results` Retrieves test metadata for CircleCI jobs, allowing you to analyze test results without leaving your IDE. This tool can be used in three ways: 1. Using Project Slug and Branch (Recommended Workflow): - First, list your available projects: - Use the list_followed_projects tool to get your projects - Example: "List my CircleCI projects" - Then choose the project, which has a projectSlug associated with it - Example: "Lets use my-project" - Then ask to retrieve the test results for a specific branch: - Example: "Get test results for my-project on the main branch" 2. Using CircleCI URL: - Provide a CircleCI URL in any of these formats: - Job URL: "https://app.circleci.com/pipelines/github/org/repo/123/workflows/abc-def/jobs/789" - Workflow URL: "https://app.circleci.com/pipelines/github/org/repo/123/workflows/abc-def" - Pipeline URL: "https://app.circleci.com/pipelines/github/org/repo/123" - Example: "Get test results for https://app.circleci.com/pipelines/github/org/repo/123/workflows/abc-def" 3. Using Local Project Context: - Works from your local workspace by providing: - Workspace root path - Git remote URL - Branch name - Example: "Get test results for my current project on the main branch" The tool returns detailed test result information: - Summary of all tests (total, successful, failed) - Detailed information about failed tests including: - Test name and class - File location - Error messages - Runtime duration - List of successful tests with timing information - Filter by tests result This is particularly useful for: - Quickly analyzing test failures without visiting the CircleCI web UI - Identifying patterns in test failures - Finding slow tests that might need optimization - Checking test coverage across your project - Troubleshooting flaky tests Note: The tool requires that test metadata is properly configured in your CircleCI config. For more information on setting up test metadata collection, see: https://circleci.com/docs/collect-test-data/ - `config_helper` Assists with CircleCI configuration tasks by providing guidance and validation. This tool helps you: 1. Validate CircleCI Config: - Checks your .circleci/config.yml for syntax and semantic errors - Example: "Validate my CircleCI config" The tool provides: - Detailed validation results - Configuration recommendations This helps you: - Catch configuration errors before pushing - Learn CircleCI configuration best practices - Troubleshoot configuration issues - Implement CircleCI features correctly - `create_prompt_template` Helps generate structured prompt templates for AI-enabled applications based on feature requirements. This tool: 1. Converts Feature Requirements to Structured Prompts: - Transforms user requirements into optimized prompt templates - Example: "Create a prompt template for generating bedtime stories by age and topic" The tool provides: - A structured prompt template - A context schema defining required input parameters This helps you: - Create effective prompts for AI applications - Standardize input parameters for consistent results - Build robust AI-powered features - `recommend_prompt_template_tests` Generates test cases for prompt templates to ensure they produce expected results. This tool: 1. Provides Test Cases for Prompt Templates: - Creates diverse test scenarios based on your prompt template and context schema - Example: "Generate tests for my bedtime story prompt template" The tool provides: - An array of recommended test cases - Various parameter combinations to test template robustness This helps you: - Validate prompt template functionality - Ensure consistent AI responses across inputs - Identify edge cases and potential issues - Improve overall AI application quality - `list_followed_projects` Lists all projects that the user is following on CircleCI. This tool: 1. Retrieves and Displays Projects: - Shows all projects the user has access to and is following - Provides the project name and projectSlug for each entry - Example: "List my CircleCI projects" The tool returns a formatted list of projects, example output: ``` Projects followed: 1. my-project (projectSlug: gh/organization/my-project) 2. another-project (projectSlug: gh/organization/another-project) ``` This is particularly useful for: - Identifying which CircleCI projects are available to you - Obtaining the projectSlug needed for other CircleCI tools - Selecting a project for subsequent operations Note: The projectSlug (not the project name) is required for many other CircleCI tools, and will be used for those tool calls after a project is selected. - `run_pipeline` Triggers a pipeline to run. This tool can be used in three ways: 1. Using Project Slug and Branch (Recommended Workflow): - First, list your available projects: - Use the list_followed_projects tool to get your projects - Example: "List my CircleCI projects" - Then choose the project, which has a projectSlug associated with it - Example: "Lets use my-project" - Then ask to run the pipeline for a specific branch: - Example: "Run the pipeline for my-project on the main branch" 2. Using CircleCI URL: - Provide a CircleCI URL in any of these formats: - Job URL: "https://app.circleci.com/pipelines/github/org/repo/123/workflows/abc-def/jobs/789" - Workflow URL: "https://app.circleci.com/pipelines/github/org/repo/123/workflows/abc-def" - Pipeline URL: "https://app.circleci.com/pipelines/github/org/repo/123" - Project URL with branch: "https://app.circleci.com/projects/github/org/repo?branch=main" - Example: "Run the pipeline for https://app.circleci.com/pipelines/github/org/repo/123/workflows/abc-def" 3. Using Local Project Context: - Works from your local workspace by providing: - Workspace root path - Git remote URL - Branch name - Example: "Run the pipeline for my current project on the main branch" The tool returns a link to monitor the pipeline execution. This is particularly useful for: - Quickly running pipelines without visiting the CircleCI web UI - Running pipelines from a specific branch - `run_rollback_pipeline` This tool allows for triggering a rollback for a project. It requires the following parameters; - `project_id` - The ID of the CircleCI project (UUID) - `environmentName` - The environment name - `componentName` - The component name - `currentVersion` - The current version - `targetVersion` - The target version - `namespace` - The namespace of the component - `reason` - The reason for the rollback (optional) - `parameters` - The extra parameters for the rollback pipeline (optional) If not all the parameters are provided right away, the toll will make use of other tools to try and retrieve all the required info. The rollback can be performed in two different way, depending on whether a rollback pipeline definition has been configured for the project: - Pipeline Rollback: will trigger the rollback pipeline. - Workflow Rerun: will trigger the rerun of a previous workflow. A typical interaction with this tool will follow this pattern: 1. Project Selection - Retrieve list of followed projects and prompt user to select one 2. Environment Selection - List available environments and select target (auto-select if only one exists) 3. Component Selection - List available components and select target (auto-select if only one exists) 4. Version Selection - Display available versions, user selects non-live version for rollback 5. Rollback Mode Detection - Check if rollback pipeline is configured for the selected project 6. Execute Rollback - Two options available: - Pipeline Rollback: Prompt for optional reason, execute rollback pipeline - Workflow Rerun**: Rerun workflow using selected version's workflow ID 7. Confirmation - Summarize rollback request and confirm before execution - `rerun_workflow` Reruns a workflow from its start or from the failed job. The tool returns the ID of the newly-created workflow, and a link to monitor the new workflow. This is particularly useful for: - Quickly rerunning a workflow from its start or from the failed job without visiting the CircleCI web UI - `analyze_diff` Analyzes git diffs against cursor rules to identify rule violations. This tool can be used by providing: 1. Git Diff Content: - Staged changes: `git diff --cached` - Unstaged changes: `git diff` - All changes: `git diff HEAD` - Example: "Analyze my staged changes against the cursor rules" 2. Repository Rules: - Rules from `.cursorrules` file in your repository root - Rules from `.cursor/rules` directory - Multiple rule files combined with `---` separator - Example: "Check my diff against the TypeScript coding standards" The tool provides: - Detailed violation reports with confidence scores - Specific explanations for each rule violation Example usage scenarios: - "Analyze my staged changes for any rule violations" - "Check my unstaged changes against rules" This is particularly useful for: - Pre-commit code quality checks - Ensuring consistency with team coding standards - Catching rule violations before code review The tool integrates with your existing cursor rules setup and provides immediate feedback on code quality, helping you catch issues early in the development process. - `list_component_versions` Lists all versions for a specific CircleCI component in an environment. This tool retrieves version history including deployment status, commit information, and timestamps for a component. The tool will prompt the user to select the component and environment from a list if not provided. Example output: ``` Versions for the component: { "items": [ { "name": "v1.2.0", "namespace": "production", "environment_id": "env-456def", "is_live": true, "pipeline_id": "12345678-1234-1234-1234-123456789abc", "workflow_id": "87654321-4321-4321-4321-cba987654321", "job_id": "11111111-1111-1111-1111-111111111111", "job_number": 42, "last_deployed_at": "2023-01-01T00:00:00Z" }, { "name": "v1.1.0", "namespace": "production", "environment_id": "env-456def", "is_live": false, "pipeline_id": "22222222-2222-2222-2222-222222222222", "workflow_id": "33333333-3333-3333-3333-333333333333", "job_id": "44444444-4444-4444-4444-444444444444", "job_number": 38, "last_deployed_at": "2023-01-03T00:00:00Z" } ] } ``` This is useful for: - Identifying which versions were deployed for a component - Finding the currently live version in an environment - Selecting target versions for rollback operations - Getting deployment details like pipeline, workflow, and job information - Listing all environments - Listing all components - `download_usage_api_data` Downloads usage data from the CircleCI Usage API for a given organization. Accepts flexible, natural language date input (e.g., "March 2025" or "last month"). Cloud-only feature. This tool can be used in one of two ways: 1) Start a new export job for a date range (max 32 days) by providing: - orgId: Organization ID - startDate: Start date (YYYY-MM-DD or natural language) - endDate: End date (YYYY-MM-DD or natural language) - outputDir: Directory to save the CSV file 2) Check/download an existing export job by providing: - orgId: Organization ID - jobId: Usage export job ID - outputDir: Directory to save the CSV file The tool provides: - A csv containing the CircleCI Usage API data from the specified time frame This is useful for: - Downloading detailed CircleCI usage data for reporting or analysis - Feeding usage data into the `find_underused_resource_classes` tool Example usage scenarios: - Scenario 1: 1. "Download usage data for org abc123 from June into ~/Downloads" 2. "Check status" - Scenario 2: 1. "Download usage data for org abc123 for last month to my Downloads folder" 2. "Check usage download status" 3. "Check status again" - Scenario 3: 1. "Check my usage export job usage-job-9f2d7c and download it if ready" - `find_underused_resource_classes` Analyzes a CircleCI usage data CSV file to find jobs/resource classes with average or max CPU/RAM usage below a given threshold (default 40%). This tool can be used by providing: - A csv containing CircleCI Usage API data, which can be obtained by using the `download_usage_api_data` tool. The tool provides: - A markdown list of all jobs that are below the threshold, delineated by project and workflow. This is useful for: - Finding jobs that are using less than half of the compute provided to them on average - Generating a list of low hanging cost optimizations Example usage scenarios: - Scenario 1: 1. "Find underused resource classes in the file you just downloaded" - Scenario 2: 1. "Find underused resource classes in ~/Downloads/usage-data-2025-06-01_2025-06-30.csv" - Scenario 3: 1. "Analyze /Users/you/Projects/acme/usage-data-job-9f2d7c.csv with threshold 30" ## Troubleshooting ### Quick Fixes **Most Common Issues:** 1. **Clear package caches:** ```bash npx clear-npx-cache npm cache clean --force ``` 2. **Force latest version:** Add `@latest` to your config: ```json "args": ["-y", "@circleci/mcp-server-circleci@latest"] ``` 3. **Restart your IDE completely** (not just reload window) ## Authentication Issues * **Invalid token errors:** Verify your `CIRCLECI_TOKEN` in Personal API Tokens * **Permission errors:** Ensure token has read access to your projects * **Environment variables not loading:** Test with `echo $CIRCLECI_TOKEN` (Mac/Linux) or `echo %CIRCLECI_TOKEN%` (Windows) ## Connection and Network Issues * **Base URL:** Confirm `CIRCLECI_BASE_URL` is `https://circleci.com` * **Corporate networks:** Configure npm proxy settings if behind firewall * **Firewall blocking:** Check if security software blocks package downloads ## System Requirements * **Node.js version:** Ensure ≥ 18.0.0 with `node --version` * **Update Node.js:** Consider latest LTS if experiencing compatibility issues * **Package manager:** Verify npm/pnpm is working: `npm --version` ## IDE-Specific Issues * **Config file location:** Double-check path for your OS * **Syntax errors:** Validate JSON syntax in config file * **Console logs:** Check IDE developer console for specific errors * **Try different IDE:** Test config in another supported editor to isolate issue ## Process Issues * **Hanging processes:** Kill existing MCP processes: ```bash # Mac/Linux: pkill -f "mcp-server-circleci" # Windows: taskkill /f /im node.exe * **Port conflicts:** Restart IDE if connection seems blocked ## Advanced Debugging * **Test package directly:** `npx @circleci/mcp-server-circleci@latest --help` * **Verbose logging:** `DEBUG=* npx @circleci/mcp-server-circleci@latest` * **Docker fallback:** Try Docker installation if npx fails consistently ## Still Need Help? 1. Check GitHub issues for similar problems 2. Include your OS, Node version, and IDE when reporting issues 3. Share relevant error messages from IDE console # Development ## Getting Started 1. Clone the repository: ```bash git clone https://github.com/CircleCI-Public/mcp-server-circleci.git cd mcp-server-circleci ``` 2. Install dependencies: ```bash pnpm install ``` 3. Build the project: ```bash pnpm build ``` ## Building Docker Container You can build the Docker container locally using: ```bash docker build -t circleci:mcp-server-circleci . ``` This will create a Docker image tagged as `circleci:mcp-server-circleci` that you can use with any MCP client. To run the container locally: ```bash docker run --rm -i -e CIRCLECI_TOKEN=your-circleci-token -e CIRCLECI_BASE_URL=https://circleci.com circleci:mcp-server-circleci ``` To run the container as a self-managed remote MCP server you need to add the environment variable `start=remote` to the docker run command. You can also define the port to use with the environment variable `port=<port>` or else the default port `8000` will be used: ```bash docker run --rm -i -e CIRCLECI_TOKEN=your-circleci-token -e CIRCLECI_BASE_URL=https://circleci.com circleci:mcp-server-circleci -e start=remote -e port=8000 ``` ## Development with MCP Inspector The easiest way to iterate on the MCP Server is using the MCP inspector. You can learn more about the MCP inspector at https://modelcontextprotocol.io/docs/tools/inspector 1. Start the development server: ```bash pnpm watch # Keep this running in one terminal ``` 2. In a separate terminal, launch the inspector: ```bash pnpm inspector ``` 3. Configure the environment: - Add your `CIRCLECI_TOKEN` to the Environment Variables section in the inspector UI - The token needs read access to your CircleCI projects - Optionally you can set your CircleCI Base URL. Defaults to `https//circleci.com` ## Testing - Run the test suite: ```bash pnpm test ``` - Run tests in watch mode during development: ```bash pnpm test:watch ``` For more detailed contribution guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md) ``` -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- ```markdown # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [email protected]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown # Contributing Thank you for considering to contribute to the MCP(Model Context Protocol) Server CircleCI! Before you get started, we recommend taking a look at the guidelines below: - [Have a Question?](#question) - [Issues and Bugs](#issue) - [Feature Requests](#feature) - [Contributing](#contribute) - [Submission Guidelines](#guidelines) - [Release Process](#release) - [Creating New Tools](#creating-tools) ## <a name="question"></a>Have a Question? Have a question about the MCP Server CircleCI? ### I have a general question. Contact CircleCI's general support by filing a ticket here: [Submit a request](https://support.circleci.com/hc/en-us/requests/new) ### I have a question about Typescript or best practices Share your question with [CircleCI's community Discuss forum](https://discuss.circleci.com/). ### I have a question about the MCP Server CircleCI You can always open a new [issue](https://github.com/CircleCI-Public/mcp-server-circleci/issues/new/choose) on the repository on GitHub. ## <a name="issue"></a>Discover a Bug? Find an issue or bug? You can help us resolve the issue by [submitting an issue](https://github.com/CircleCI-Public/mcp-server-circleci/issues/new/choose) on our GitHub repository. Up for a challenge? If you think you can fix the issue, consider sending in a [Pull Request](#pull). ## <a name="feature"></a>Missing Feature? Is anything missing? You can request a new feature by [submitting an issue](https://github.com/CircleCI-Public/mcp-server-circleci/issues/new/choose) to our GitHub repository, utilizing the `Feature Request` template. If you would like to instead contribute a pull request, please follow the [Submission Guidelines](#guidelines) ## <a name="contribute"></a>Contributing Thank you for contributing to the MCP Server CircleCI! Before submitting any new Issue or Pull Request, search our repository for any existing or previous related submissions. - [Search Pull Requests](https://github.com/CircleCI-Public/mcp-server-circleci/pulls?q=) - [Search Issues](https://github.com/CircleCI-Public/mcp-server-circleci/issues?q=) ### <a name="guidelines"></a>Submission Guidelines #### <a name="commit"></a>Commit Conventions This project strictly adheres to the [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) specification for creating human readable commit messages with appropriate automation capabilities, such as changelog generation. ##### Commit Message Format Each commit message consists of a header, a body and a footer. The header has a special format that includes a type, a scope and a subject: ``` <type>(optional <scope>): <subject> <BLANK LINE> <body> <BLANK LINE> <footer> ``` Footer should contain a [closing reference to an issue](https://help.github.com/articles/closing-issues-via-commit-messages/) if any. ##### Breaking Change Append a `!` to the end of the `type` in your commit message to suggest a `BREAKING CHANGE` ``` <type>!(optional <scope>): <subject> ``` ##### Type Must be one of the following: - **build**: Changes that affect the build system or external dependencies (example scopes: npm, eslint, prettier) - **ci**: Changes to our CircleCI configuration files - **chore**: No production code changes. Updates to readmes and meta documents - **docs**: Changes to the API documentation or JSDoc/TSDoc comments - **feat**: A new feature or capability for the MCP server - **fix**: A bug fix in the server implementation - **refactor**: A code change that neither fixes a bug nor adds a feature - **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) - **test**: Adding missing tests or correcting existing tests - **tools**: Changes to the CircleCI API tool implementations #### <a name="pull"></a>Submitting a Pull Request After searching for potentially existing pull requests or issues in progress, if none are found, please open a new issue describing your intended changes and stating your intention to work on the issue. Creating issues helps us plan our next release and prevents folks from duplicating work. After the issue has been created, follow these steps to create a Pull Request. 1. Fork the [CircleCI-Public/mcp-server-circleci](https://github.com/CircleCI-Public/mcp-server-circleci) repo. 2. Clone your newly forked repository to your local machine. 3. Create a new branch for your changes: `git checkout -b fix_my_issue main` 4. Run `npm run setup` 5. Implement your change with appropriate test coverage. 6. Utilize our [commit message conventions](commit). 7. Run tests, linters, and formatters locally, with: `pnpm` scripts in `package.json` 8. Push all changes back to GitHub `git push origin fix_my_issue` 9. In GitHub, send a Pull Request to `mcp-server-circleci:main` Thank you for your contribution! ### <a name="creating-tools"></a>Creating New Tools This project provides a tool generator script to help you quickly create new tools with the correct structure and boilerplate code. To create a new tool: 1. Run the following command, replacing `yourToolName` with your tool's name in camelCase: ```bash pnpm create-tool yourToolName ``` 2. This will generate a new directory at `src/tools/yourToolName` with the following files: - `inputSchema.ts` - Defines the input schema for the tool using Zod - `tool.ts` - Defines the tool name, description, and input schema - `handler.ts` - Contains the main implementation logic - `handler.test.ts` - Contains basic test setup 3. After creating the files, you'll need to register your tool in `src/circleci-tools.ts`: ```typescript // Import your tool and handler import { yourToolNameTool } from './tools/yourToolName/tool.js'; import { yourToolName } from './tools/yourToolName/handler.js'; // Add your tool to the CCI_TOOLS array export const CCI_TOOLS = [ // ...existing tools yourToolNameTool, ]; // Add your handler to the CCI_HANDLERS object export const CCI_HANDLERS = { // ...existing handlers your_tool_name: yourToolName, } satisfies ToolHandlers; ``` 4. Implement your tool's logic in the handler and add comprehensive tests. Using this script ensures consistency across the codebase and saves development time. ``` -------------------------------------------------------------------------------- /src/tools/listFollowedProjects/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const listFollowedProjectsInputSchema = z.object({}); ``` -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["local>circleci/renovate-config"], "automerge": false } ``` -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- ```json { "extends": "./tsconfig.json", "compilerOptions": { "rootDir": ".", "types": ["vitest/globals"] }, "include": [ "src/**/*", "src/**/*.test.ts", "src/**/*.spec.ts", "vitest.config.ts" ], "exclude": ["node_modules", "dist"] } ``` -------------------------------------------------------------------------------- /src/lib/getWorkflowIdFromURL.ts: -------------------------------------------------------------------------------- ```typescript export function getWorkflowIdFromURL(url: string): string | undefined { // Matches both: // - .../workflows/:workflowId // - .../workflows/:workflowId/jobs/:buildNumber const match = url.match(/\/workflows\/([\w-]+)/); return match ? match[1] : undefined; } ``` -------------------------------------------------------------------------------- /src/transports/stdio.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; export const createStdioTransport = async (server: McpServer) => { const transport = new StdioServerTransport(); await server.connect(transport); }; ``` -------------------------------------------------------------------------------- /src/tools/configHelper/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const configHelperInputSchema = z.object({ configFile: z .string() .describe( 'The contents of the circleci config file. This should be the contents of the circleci config file, not the path to the file. Typically located at .circleci/config.yml', ), }); ``` -------------------------------------------------------------------------------- /src/lib/mcpErrorOutput.ts: -------------------------------------------------------------------------------- ```typescript import { createErrorResponse, McpErrorResponse } from './mcpResponse.js'; /** * Creates an MCP error response with the provided text * @param text The error message text * @returns A properly formatted MCP error response */ export default function mcpErrorOutput(text: string): McpErrorResponse { return createErrorResponse(text); } ``` -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- ```javascript import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'node', include: ['src/**/*.{test,spec}.{js,ts}'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], include: ['src/**/*.{js,ts}'], exclude: ['src/**/*.{test,spec}.{js,ts}'], }, }, }); ``` -------------------------------------------------------------------------------- /src/tools/findUnderusedResourceClasses/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const findUnderusedResourceClassesInputSchema = z.object({ csvFilePath: z .string() .describe('The path to the usage data CSV file to analyze.'), threshold: z .number() .optional() .default(40) .describe( 'The usage percentage threshold. Jobs with usage below this will be reported. Default is 40.', ), }); ``` -------------------------------------------------------------------------------- /src/lib/mcpResponse.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import { createErrorResponse } from './mcpResponse.js'; describe('MCP Response Utilities', () => { describe('createErrorResponse', () => { it('should create a valid error response', () => { const text = 'Error message'; const response = createErrorResponse(text); expect(response).toEqual({ isError: true, content: [ { type: 'text', text, }, ], }); }); }); }); ``` -------------------------------------------------------------------------------- /src/clients/client.ts: -------------------------------------------------------------------------------- ```typescript import { CircleCIPrivateClients } from './circleci-private/index.js'; import { CircleCIClients } from './circleci/index.js'; export function getCircleCIClient() { if (!process.env.CIRCLECI_TOKEN) { throw new Error('CIRCLECI_TOKEN is not set'); } return new CircleCIClients({ token: process.env.CIRCLECI_TOKEN, }); } export function getCircleCIPrivateClient() { if (!process.env.CIRCLECI_TOKEN) { throw new Error('CIRCLECI_TOKEN is not set'); } return new CircleCIPrivateClients({ token: process.env.CIRCLECI_TOKEN, }); } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "outDir": "./dist", "baseUrl": ".", "paths": { "@modelcontextprotocol/sdk/*": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/*"] }, "types": ["node"], "typeRoots": [ "./node_modules/@types" ], "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["**/*.test.ts", "**/*.spec.ts", "vitest.config.ts", "dist"] } ``` -------------------------------------------------------------------------------- /src/lib/latest-pipeline/getLatestPipelineWorkflows.ts: -------------------------------------------------------------------------------- ```typescript import { getCircleCIClient } from '../../clients/client.js'; export type GetLatestPipelineWorkflowsParams = { projectSlug: string; branch?: string; }; export const getLatestPipelineWorkflows = async ({ projectSlug, branch, }: GetLatestPipelineWorkflowsParams) => { const circleci = getCircleCIClient(); const pipelines = await circleci.pipelines.getPipelines({ projectSlug, branch, }); const latestPipeline = pipelines?.[0]; if (!latestPipeline) { throw new Error('Latest pipeline not found'); } const workflows = await circleci.workflows.getPipelineWorkflows({ pipelineId: latestPipeline.id, }); return workflows; }; ``` -------------------------------------------------------------------------------- /src/tools/rerunWorkflow/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { workflowUrlDescription } from '../shared/constants.js'; export const rerunWorkflowInputSchema = z.object({ workflowId: z .string() .describe( 'This should be the workflowId of the workflow that need rerun. The workflowId is an UUID. An example workflowId is a12145c5-90f8-4cc9-98f2-36cb85db9e4b', ) .optional(), fromFailed: z .boolean() .describe( 'If true, reruns the workflow from failed. If false, reruns the workflow from the start. If omitted, the rerun behavior is based on the workflow status.', ) .optional(), workflowURL: z.string().describe(workflowUrlDescription).optional(), }); ``` -------------------------------------------------------------------------------- /src/lib/getWorkflowIdFromURL.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import { getWorkflowIdFromURL } from './getWorkflowIdFromURL.js'; describe('getWorkflowIdFromURL', () => { it('should return the workflow ID from a workflow URL', () => { const url = 'https://app.circleci.com/pipelines/gh/organization/project/1/workflows/123-abc'; const result = getWorkflowIdFromURL(url); expect(result).toBe('123-abc'); }); it('should return the workflow ID from a job URL', () => { const url = 'https://app.circleci.com/pipelines/gh/organization/project/1/workflows/123-abc/jobs/456'; const result = getWorkflowIdFromURL(url); expect(result).toBe('123-abc'); }); }); ``` -------------------------------------------------------------------------------- /src/clients/circlet/index.ts: -------------------------------------------------------------------------------- ```typescript import { HTTPClient } from '../circleci/httpClient.js'; import { CircletAPI } from './circlet.js'; /** * Creates a default HTTP client for the CircleCI API private * @param options Configuration parameters * @param options.token CircleCI API token * @param options.baseURL Base URL for the CircleCI API private * @returns HTTP client for CircleCI API private */ const defaultV1HTTPClient = () => { return new HTTPClient('https://circlet.ai', '/api/v1'); }; export class CircletClient { public circlet: CircletAPI; constructor({ httpClient = defaultV1HTTPClient(), }: { httpClient?: HTTPClient; } = {}) { this.circlet = new CircletAPI(httpClient); } } ``` -------------------------------------------------------------------------------- /src/tools/analyzeDiff/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { FilterBy } from '../shared/constants.js'; export const analyzeDiffInputSchema = z.object({ speedMode: z .boolean() .default(false) .describe('The status of speed mode. Defaults to false.'), filterBy: z .nativeEnum(FilterBy) .default(FilterBy.none) .describe(`Analysis filter. Defaults to ${FilterBy.none}`), diff: z .string() .describe( 'Git diff content to analyze. Defaults to staged changes, unless the user explicitly asks for unstaged changes or all changes.', ), rules: z .string() .describe( 'Rules to use for analysis, found in the rules subdirectory of the IDE workspace settings. Combine all rules from multiple files by separating them with ---', ), }); ``` -------------------------------------------------------------------------------- /src/lib/mcpErrorOutput.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import mcpErrorOutput from './mcpErrorOutput.js'; describe('mcpErrorOutput', () => { it('should create an error response with the provided text', () => { const errorMessage = 'Test error message'; const result = mcpErrorOutput(errorMessage); expect(result).toEqual({ isError: true, content: [ { type: 'text', text: errorMessage, }, ], }); }); it('should maintain the exact error text provided', () => { const complexErrorMessage = ` Error occurred: - Missing parameter: projectSlug - Invalid token format `; const result = mcpErrorOutput(complexErrorMessage); expect(result.content[0].text).toBe(complexErrorMessage); }); }); ``` -------------------------------------------------------------------------------- /src/tools/downloadUsageApiData/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; export const downloadUsageApiDataInputSchema = z.object({ orgId: z.string().describe('The ID of the CircleCI organization'), startDate: z .string() .optional() .describe('Optional. The start date for the usage data in YYYY-MM-DD format (or natural language). Used when starting a new export job.'), endDate: z .string() .optional() .describe('Optional. The end date for the usage data in YYYY-MM-DD format (or natural language). Used when starting a new export job.'), jobId: z .string() .optional() .describe('Generated by the initial tool call when starting the usage export job. Required for subsequent tool calls.'), outputDir: z .string() .describe('The directory to save the downloaded usage data CSV file.'), }); ``` -------------------------------------------------------------------------------- /src/lib/mcpResponse.ts: -------------------------------------------------------------------------------- ```typescript /** * Represents a basic text content block for MCP responses */ export type McpTextContent = { type: 'text'; text: string; }; /** * Type for MCP response content */ export type McpContent = McpTextContent; /** * Type for representing a successful MCP response */ export type McpSuccessResponse = { content: McpContent[]; isError?: false; }; /** * Type for representing an error MCP response */ export type McpErrorResponse = { content: McpContent[]; isError: true; }; /** * Creates an error MCP response with text content * @param text The error text content to include in the response * @returns A properly formatted MCP error response */ export function createErrorResponse(text: string): McpErrorResponse { return { isError: true, content: [ { type: 'text', text, }, ], }; } ``` -------------------------------------------------------------------------------- /src/tools/configHelper/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { configHelperInputSchema } from './inputSchema.js'; import { getCircleCIClient } from '../../clients/client.js'; export const configHelper: ToolCallback<{ params: typeof configHelperInputSchema; }> = async (args) => { const { configFile } = args.params ?? {}; const circleci = getCircleCIClient(); const configValidate = await circleci.configValidate.validateConfig({ config: configFile, }); if (configValidate.valid) { return { content: [ { type: 'text', text: 'Your config is valid!', }, ], }; } return { content: [ { type: 'text', text: `There are some issues with your config: ${configValidate.errors?.map((error) => error.message).join('\n') ?? 'Unknown error'}`, }, ], }; }; ``` -------------------------------------------------------------------------------- /src/clients/circleci/insights.ts: -------------------------------------------------------------------------------- ```typescript import { FlakyTest } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; export class InsightsAPI { protected client: HTTPClient; constructor(httpClient: HTTPClient) { this.client = httpClient; } /** * Get all workflows for a pipeline with pagination support * @param params Configuration parameters * @param params.projectSlug The project slug * @returns Flaky test details * @throws Error if timeout or max pages reached */ async getProjectFlakyTests({ projectSlug, }: { projectSlug: string; }): Promise<FlakyTest> { const rawResult = await this.client.get<unknown>( `/insights/${projectSlug}/flaky-tests`, ); const parsedResult = FlakyTest.safeParse(rawResult); if (!parsedResult.success) { throw new Error('Failed to parse flaky test response'); } return parsedResult.data; } } ``` -------------------------------------------------------------------------------- /src/tools/configHelper/tool.ts: -------------------------------------------------------------------------------- ```typescript import { configHelperInputSchema } from './inputSchema.js'; export const configHelperTool = { name: 'config_helper' as const, description: ` This tool helps analyze and validate and fix CircleCI configuration files. Parameters: - params: An object containing: - configFile: string - The full contents of the CircleCI config file as a string. This should be the raw YAML content, not a file path. Example usage: { "params": { "configFile": "version: 2.1\norbs:\n node: circleci/node@7\n..." } } Note: The configFile content should be provided as a properly escaped string with newlines represented as \n. Tool output instructions: - If the config is invalid, the tool will return the errors and the original config. Use the errors to fix the config. - If the config is valid, do nothing. `, inputSchema: configHelperInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/findUnderusedResourceClasses/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { findUnderusedResourceClassesInputSchema } from './inputSchema.js'; import mcpErrorOutput from '../../lib/mcpErrorOutput.js'; import { findUnderusedResourceClassesFromCSV } from '../../lib/usage-api/findUnderusedResourceClasses.js'; export const findUnderusedResourceClasses: ToolCallback<{ params: typeof findUnderusedResourceClassesInputSchema }> = async (args) => { const { csvFilePath, threshold } = args.params ?? {}; if (!csvFilePath) { return mcpErrorOutput('ERROR: csvFilePath is required.'); } try { const { report } = await findUnderusedResourceClassesFromCSV({ csvFilePath, threshold }); return { content: [ { type: 'text', text: report }, ], }; } catch (e: any) { return mcpErrorOutput(`ERROR: ${e && e.message ? e.message : e}`); } }; ``` -------------------------------------------------------------------------------- /src/clients/circleci/configValidate.ts: -------------------------------------------------------------------------------- ```typescript import { ConfigValidate } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; export class ConfigValidateAPI { protected client: HTTPClient; constructor(httpClient: HTTPClient) { this.client = httpClient; } /** * Validate a config with the default values * @param params Configuration parameters * @param params.config The config to validate * @returns ConfigValidate * @throws Error if the config is invalid */ async validateConfig({ config, }: { config: string; }): Promise<ConfigValidate> { const rawResult = await this.client.post<unknown>( `/compile-config-with-defaults`, { config_yaml: config }, ); const parsedResult = ConfigValidate.safeParse(rawResult); if (!parsedResult.success) { throw new Error('Failed to parse config validate response'); } return parsedResult.data; } } ``` -------------------------------------------------------------------------------- /src/clients/circleci/jobsV1.ts: -------------------------------------------------------------------------------- ```typescript import { JobDetails } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; export class JobsV1API { protected client: HTTPClient; constructor(httpClient: HTTPClient) { this.client = httpClient; } /** * Get detailed information about a specific job * @param params Configuration parameters * @param params.projectSlug The project slug (e.g., "gh/CircleCI-Public/api-preview-docs") * @param params.jobNumber The number of the job * @returns Detailed job information including status, timing, and build details */ async getJobDetails({ projectSlug, jobNumber, }: { projectSlug: string; jobNumber: number; }): Promise<JobDetails> { const rawResult = await this.client.get<unknown>( `/project/${projectSlug}/${jobNumber}`, ); // Validate the response against our JobDetails schema return JobDetails.parse(rawResult); } } ``` -------------------------------------------------------------------------------- /src/lib/outputTextTruncated.ts: -------------------------------------------------------------------------------- ```typescript import { McpSuccessResponse } from './mcpResponse.js'; const MAX_LENGTH = 50000; export const SEPARATOR = '\n<<<SEPARATOR>>>\n'; /** * Creates an MCP response with potentially truncated text * @param outputText The full text that might need to be truncated * @returns An MCP response containing the original or truncated text */ const outputTextTruncated = (outputText: string): McpSuccessResponse => { if (outputText.length > MAX_LENGTH) { const truncationNotice = `<MCPTruncationWarning> ⚠️ TRUNCATED OUTPUT WARNING ⚠️ - Showing only most recent entries </MCPTruncationWarning>\n\n`; // Take the tail of the output text const truncatedText = truncationNotice + outputText.slice(-MAX_LENGTH + truncationNotice.length); return { content: [{ type: 'text' as const, text: truncatedText }], }; } return { content: [{ type: 'text' as const, text: outputText }], }; }; export default outputTextTruncated; ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml startCommand: type: stdio configSchema: # JSON Schema defining the configuration options for the MCP. type: object required: - circleciToken properties: circleciToken: type: string description: CircleCI API token with read access to CircleCI projects circleciBaseUrl: type: string description: CircleCI base URL (optional, defaults to https://circleci.com) default: "https://circleci.com" default: {} commandFunction: # A JS function that produces the CLI command based on the given config to start the MCP on stdio. |- (config) => ({ command: 'node', args: ['dist/index.js'], env: { CIRCLECI_TOKEN: config.circleciToken, CIRCLECI_BASE_URL: config.circleciBaseUrl } }) exampleConfig: circleciToken: your-circleci-token-here circleciBaseUrl: https://circleci.com ``` -------------------------------------------------------------------------------- /src/lib/outputTextTruncated.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect } from 'vitest'; import outputTextTruncated from './outputTextTruncated'; describe('outputTextTruncated', () => { it('should return the original text when under max length', () => { const shortText = 'This is a short text'; const result = outputTextTruncated(shortText); expect(result).toEqual({ content: [{ type: 'text', text: shortText }], }); }); it('should truncate text when over max length', () => { const longText = 'a'.repeat(60000); const result = outputTextTruncated(longText); expect(result.content[0].text).toContain('<MCPTruncationWarning>'); expect(result.content[0].text).toContain('TRUNCATED OUTPUT WARNING'); expect(result.content[0].text.length).toBeLessThan(longText.length); const truncationNoticeLength = result.content[0].text.indexOf('\n\n') + 2; const truncatedContent = result.content[0].text.slice( truncationNoticeLength, ); expect(longText.endsWith(truncatedContent)).toBe(true); }); }); ``` -------------------------------------------------------------------------------- /src/tools/rerunWorkflow/tool.ts: -------------------------------------------------------------------------------- ```typescript import { rerunWorkflowInputSchema } from './inputSchema.js'; export const rerunWorkflowTool = { name: 'rerun_workflow' as const, description: ` This tool is used to rerun a workflow from start or from the failed job. Common use cases: - Rerun a workflow from a failed job - Rerun a workflow from start Input options (EXACTLY ONE of these TWO options must be used): Option 1 - Workflow ID: - workflowId: The ID of the workflow to rerun - fromFailed: true to rerun from failed, false to rerun from start. If omitted, behavior is based on workflow status. (optional) Option 2 - Workflow URL: - workflowURL: The URL of the workflow to rerun * Workflow URL: https://app.circleci.com/pipelines/:vcsType/:orgName/:projectName/:pipelineNumber/workflows/:workflowId * Workflow Job URL: https://app.circleci.com/pipelines/:vcsType/:orgName/:projectName/:pipelineNumber/workflows/:workflowId/jobs/:buildNumber - fromFailed: true to rerun from failed, false to rerun from start. If omitted, behavior is based on workflow status. (optional) `, inputSchema: rerunWorkflowInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/runRollbackPipeline/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { projectSlugDescriptionNoBranch } from '../shared/constants.js'; export const runRollbackPipelineInputSchema = z.object({ projectSlug: z .string() .describe(projectSlugDescriptionNoBranch) .optional(), projectID: z .string() .uuid() .describe('The ID of the CircleCI project (UUID)') .optional(), environmentName: z .string() .describe('The environment name'), componentName: z .string() .describe('The component name'), currentVersion: z .string() .describe('The current version'), targetVersion: z .string() .describe('The target version'), namespace: z .string() .describe('The namespace of the component'), reason: z .string() .describe('The reason for the rollback') .optional(), parameters: z .record(z.any()) .describe('The extra parameters for the rollback pipeline') .optional(), }).refine( (data) => data.projectSlug || data.projectID, { message: "Either projectSlug or projectID must be provided", path: ["projectSlug", "projectID"], } ); ``` -------------------------------------------------------------------------------- /src/tools/listComponentVersions/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { projectSlugDescriptionNoBranch } from '../shared/constants.js'; export const listComponentVersionsInputSchema = z.object({ projectSlug: z .string() .describe(projectSlugDescriptionNoBranch) .optional(), projectID: z .string() .uuid() .describe('The ID of the CircleCI project (UUID)') .optional(), orgID: z .string() .describe( 'The ID of the organization. This is the ID of the organization that the components and environments belong to. If not provided, it will be resolved from projectSlug or projectID.', ) .optional(), componentID: z .string() .optional() .describe('The ID of the component to list versions for. If not provided, available components will be listed.'), environmentID: z .string() .optional() .describe('The ID of the environment to list versions for. If not provided, available environments will be listed.'), }).refine( (data) => data.projectSlug || data.projectID, { message: "Either projectSlug or projectID must be provided", path: ["projectSlug", "projectID"], } ); ``` -------------------------------------------------------------------------------- /src/tools/analyzeDiff/tool.ts: -------------------------------------------------------------------------------- ```typescript import { FilterBy } from '../shared/constants.js'; import { analyzeDiffInputSchema } from './inputSchema.js'; export const analyzeDiffTool = { name: 'analyze_diff' as const, description: ` This tool is used to analyze a git diff (unstaged, staged, or all changes) against IDE rules to identify rule violations. By default, the tool will use the staged changes, unless the user explicitly asks for unstaged or all changes. Parameters: - params: An object containing: - speedMode: boolean - A mode that can be enabled to speed up the analysis. Default value is false. - filterBy: enum - "${FilterBy.violations}" | "${FilterBy.compliants}" | "${FilterBy.humanReviewRequired}" | "${FilterBy.none}" - A filter that can be applied to set the focus of the analysis. Default is ${FilterBy.none}. - diff: string - A git diff string. - rules: string - Rules to use for analysis, found in the rules subdirectory of the IDE workspace settings. Combine all rules from multiple files by separating them with --- Returns: - A list of rule violations found in the git diff. `, inputSchema: analyzeDiffInputSchema, }; ``` -------------------------------------------------------------------------------- /src/clients/circleci-private/index.ts: -------------------------------------------------------------------------------- ```typescript import { HTTPClient } from '../circleci/httpClient.js'; import { createCircleCIHeaders } from '../circleci/index.js'; import { JobsPrivate } from './jobsPrivate.js'; import { MeAPI } from './me.js'; import { getBaseURL } from '../circleci/index.js'; /** * Creates a default HTTP client for the CircleCI API private * @param options Configuration parameters * @param options.token CircleCI API token * @param options.baseURL Base URL for the CircleCI API private * @returns HTTP client for CircleCI API private */ const defaultPrivateHTTPClient = (options: { token: string }) => { if (!options.token) { throw new Error('Token is required'); } const baseURL = getBaseURL(); return new HTTPClient(baseURL, '/api/private', { headers: createCircleCIHeaders({ token: options.token }), }); }; export class CircleCIPrivateClients { public me: MeAPI; public jobs: JobsPrivate; constructor({ token, privateHTTPClient = defaultPrivateHTTPClient({ token, }), }: { token: string; privateHTTPClient?: HTTPClient; }) { this.me = new MeAPI(privateHTTPClient); this.jobs = new JobsPrivate(privateHTTPClient); } } ``` -------------------------------------------------------------------------------- /src/lib/usage-api/parseDateTimeString.ts: -------------------------------------------------------------------------------- ```typescript import * as chrono from 'chrono-node'; /** * @param input The human-readable date string to parse * @param options An optional object to control formatting * @param options.defaultTime Specifies which time to append if the user did not provide one * - 'start-of-day': Appends T00:00:00Z * - 'end-of-day': Appends T23:59:59Z * @returns A formatted date string (full ISO, or YYYY-MM-DD) */ export function parseDateTimeString( input: string, options?: { defaultTime?: 'start-of-day' | 'end-of-day'; } ): string | null { const results = chrono.parse(input); if (!results || results.length === 0) { return null; } const result = results[0]; const date = result.start.date(); const timeSpecified = result.start.isCertain('hour') || result.start.isCertain('minute') || result.start.isCertain('second'); if (timeSpecified) { return date.toISOString(); } if (options?.defaultTime) { const dateOnly = date.toISOString().slice(0, 10); if (options.defaultTime === 'start-of-day') { return `${dateOnly}T00:00:00Z`; } return `${dateOnly}T23:59:59Z`; } return date.toISOString().slice(0, 10); } ``` -------------------------------------------------------------------------------- /src/tools/createPromptTemplate/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultModel, defaultTemperature, PromptOrigin, } from '../shared/constants.js'; export const createPromptTemplateInputSchema = z.object({ prompt: z .string() .describe( "The user's application, feature, or product requirements that will be used to generate a prompt template. Alternatively, a pre-existing prompt or prompt template can be provided if a user wants to test, evaluate, or modify it. (Can be a multi-line string.)", ), promptOrigin: z .nativeEnum(PromptOrigin) .describe( `The origin of the prompt - either "${PromptOrigin.codebase}" for existing prompts from the codebase, or "${PromptOrigin.requirements}" for new prompts from requirements.`, ), model: z .string() .default(defaultModel) .describe( `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}\`.`, ), temperature: z .number() .default(defaultTemperature) .describe( `The temperature of the prompt template. Explicitly specify the temperature if it can be inferred from the codebase. Otherwise, defaults to ${defaultTemperature}.`, ), }); ``` -------------------------------------------------------------------------------- /src/tools/findUnderusedResourceClasses/tool.ts: -------------------------------------------------------------------------------- ```typescript import { findUnderusedResourceClassesInputSchema } from './inputSchema.js'; export const findUnderusedResourceClassesTool = { name: 'find_underused_resource_classes' as const, description: ` Analyzes a CircleCI usage data CSV file to find jobs/resource classes with average or max CPU/RAM usage below a given threshold (default 40%). This helps identify underused resource classes that may be oversized for their workload. Required parameter: - csvFilePath: Path to the usage data CSV file (string). IMPORTANT: This must be an absolute path. If you are given a relative path, you must resolve it to an absolute path before calling this tool. Optional parameter: - threshold: Usage percentage threshold (number, default 40) The tool expects the CSV to have columns: job_name, resource_class, median_cpu_utilization_pct, max_cpu_utilization_pct, median_ram_utilization_pct, max_ram_utilization_pct (case-insensitive). These required columns are a subset of the columns in the CircleCI usage API output and the tool will work with the full set of columns from the usage API CSV. It returns a summary report listing all jobs/resource classes where any of these metrics is below the threshold. `, inputSchema: findUnderusedResourceClassesInputSchema, }; ``` -------------------------------------------------------------------------------- /src/clients/circleci/projects.ts: -------------------------------------------------------------------------------- ```typescript import { Project } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; export class ProjectsAPI { protected client: HTTPClient; constructor(httpClient: HTTPClient) { this.client = httpClient; } /** * Get project info by slug * @param projectSlug The project slug * @returns The project info * @throws Error if the request fails */ async getProject({ projectSlug }: { projectSlug: string }): Promise<Project> { const rawResult = await this.client.get<unknown>(`/project/${projectSlug}`); const parsedResult = Project.safeParse(rawResult); if (!parsedResult.success) { throw new Error('Failed to parse project response'); } return parsedResult.data; } /** * Get project info by project ID (uses project ID as slug) * @param projectID The project ID * @returns The project info * @throws Error if the request fails */ async getProjectByID({ projectID }: { projectID: string }): Promise<Project> { // For some integrations, project ID can be used directly as project slug const rawResult = await this.client.get<unknown>(`/project/${projectID}`); const parsedResult = Project.safeParse(rawResult); if (!parsedResult.success) { throw new Error('Failed to parse project response'); } return parsedResult.data; } } ``` -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST.md: -------------------------------------------------------------------------------- ```markdown ## PR Checklist Please check if your PR fulfills the following requirements: - [ ] The commit message follows our contributor [guidelines](https://github.com/CircleCI-Public/mcp-server-circleci/blob/main/CONTRIBUTING.md). - [ ] Tests for the changes have been added (for bug fixes / features) - [ ] Documentation has been added or updated where needed. ## PR Type What kind of change does this PR introduce? <!-- Please check the one that applies to this PR using "x". --> - [ ] Bug fix - [ ] Feature - [ ] Code style update (formatting, local variables) - [ ] Refactoring (no functional changes, no api changes) - [ ] Build related changes - [ ] CI related changes - [ ] Other... Please describe: > more details ## What issues are resolved by this PR? <!-- All Pull Requests should be a response to an existing issue. Please ensure you have created an issue before submitting a PR. --> - #[00] ## Describe the new behavior. <!-- Describe the new behavior introduced by this change. Include an examples or samples needed, such as screenshots or code snippets. --> > Description ## Does this PR introduce a breaking change? - [ ] Yes - [ ] No <!-- If this PR contains a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Optional. --> > More information (optional) ``` -------------------------------------------------------------------------------- /src/tools/recommendPromptTemplateTests/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { defaultModel, defaultTemperature, PromptOrigin, PromptWorkbenchToolName, } from '../shared/constants.js'; export const recommendPromptTemplateTestsInputSchema = z.object({ template: z .string() .describe( `The prompt template to be tested. Use the \`promptTemplate\` from the latest \`${PromptWorkbenchToolName.create_prompt_template}\` tool output (if available).`, ), contextSchema: z .record(z.string(), z.string()) .describe( `The context schema that defines the expected input parameters for the prompt template. Use the \`contextSchema\` from the latest \`${PromptWorkbenchToolName.create_prompt_template}\` tool output.`, ), promptOrigin: z .nativeEnum(PromptOrigin) .describe( `The origin of the prompt template, indicating where it came from (e.g. "${PromptOrigin.codebase}" or "${PromptOrigin.requirements}").`, ), model: z .string() .default(defaultModel) .describe( `The model to use for generating actual prompt outputs for testing. Defaults to ${defaultModel}.`, ), temperature: z .number() .default(defaultTemperature) .describe( `The temperature of the prompt template. Explicitly specify the temperature if it can be inferred from the codebase. Otherwise, defaults to ${defaultTemperature}.`, ), }); ``` -------------------------------------------------------------------------------- /src/tools/createPromptTemplate/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { createPromptTemplateInputSchema } from './inputSchema.js'; import { CircletClient } from '../../clients/circlet/index.js'; import { PromptWorkbenchToolName } from '../shared/constants.js'; export const promptOriginKey = 'promptOrigin'; export const promptTemplateKey = 'promptTemplate'; export const contextSchemaKey = 'contextSchema'; export const modelKey = 'model'; export const temperatureKey = 'temperature'; export const createPromptTemplate: ToolCallback<{ params: typeof createPromptTemplateInputSchema; }> = async (args) => { const { prompt, promptOrigin, model } = args.params ?? {}; const circlet = new CircletClient(); const promptObject = await circlet.circlet.createPromptTemplate( prompt, promptOrigin, ); return { content: [ { type: 'text', text: `${promptOriginKey}: ${promptOrigin} ${promptTemplateKey}: ${promptObject.template} ${contextSchemaKey}: ${JSON.stringify(promptObject.contextSchema, null, 2)} ${modelKey}: ${model} NEXT STEP: - Immediately call the \`${PromptWorkbenchToolName.recommend_prompt_template_tests}\` tool with: - template: the \`${promptTemplateKey}\` above - ${contextSchemaKey}: the \`${contextSchemaKey}\` above - ${promptOriginKey}: the \`${promptOriginKey}\` above - ${modelKey}: the \`${modelKey}\` above - ${temperatureKey}: the \`${temperatureKey}\` above `, }, ], }; }; ``` -------------------------------------------------------------------------------- /src/tools/getBuildFailureLogs/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { branchDescription, projectSlugDescription, } from '../shared/constants.js'; export const getBuildFailureOutputInputSchema = z.object({ projectSlug: z.string().describe(projectSlugDescription).optional(), branch: z.string().describe(branchDescription).optional(), projectURL: z .string() .describe( 'The URL of the CircleCI project. Can be any of these formats:\n' + '- Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch\n' + '- Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123\n' + '- Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def\n' + '- Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz', ) .optional(), workspaceRoot: z .string() .describe( 'The absolute path to the root directory of your project workspace. ' + 'This should be the top-level folder containing your source code, configuration files, and dependencies. ' + 'For example: "/home/user/my-project" or "C:\\Users\\user\\my-project"', ) .optional(), gitRemoteURL: z .string() .describe( 'The URL of the remote git repository. This should be the URL of the repository that you cloned to your local workspace. ' + 'For example: "https://github.com/user/my-project.git"', ) .optional(), }); ``` -------------------------------------------------------------------------------- /src/lib/latest-pipeline/formatLatestPipelineStatus.ts: -------------------------------------------------------------------------------- ```typescript import { Workflow } from '../../clients/schemas.js'; import outputTextTruncated, { SEPARATOR } from '../outputTextTruncated.js'; export const formatLatestPipelineStatus = (workflows: Workflow[]) => { if (!workflows || workflows.length === 0) { return { content: [ { type: 'text' as const, text: 'No workflows found', }, ], }; } const outputText = workflows .map((workflow) => { let duration = 'unknown'; // Calculate duration from timestamps if duration field is not available if (workflow.created_at && workflow.stopped_at) { const startTime = new Date(workflow.created_at).getTime(); const endTime = new Date(workflow.stopped_at).getTime(); const durationInMinutes = Math.round( (endTime - startTime) / (1000 * 60), ); duration = `${durationInMinutes} minutes`; } const createdAt = new Date(workflow.created_at).toLocaleString(); const stoppedAt = workflow.stopped_at ? new Date(workflow.stopped_at).toLocaleString() : 'in progress'; const fields = [ `Pipeline Number: ${workflow.pipeline_number}`, `Workflow: ${workflow.name}`, `Status: ${workflow.status}`, `Duration: ${duration}`, `Created: ${createdAt}`, `Stopped: ${stoppedAt}`, ].filter(Boolean); return `${SEPARATOR}${fields.join('\n')}`; }) .join('\n'); return outputTextTruncated(outputText); }; ``` -------------------------------------------------------------------------------- /src/tools/getFlakyTests/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { projectSlugDescriptionNoBranch } from '../shared/constants.js'; export const getFlakyTestLogsInputSchema = z.object({ projectSlug: z.string().describe(projectSlugDescriptionNoBranch).optional(), workspaceRoot: z .string() .describe( 'The absolute path to the root directory of your project workspace. ' + 'This should be the top-level folder containing your source code, configuration files, and dependencies. ' + 'For example: "/home/user/my-project" or "C:\\Users\\user\\my-project"', ) .optional(), gitRemoteURL: z .string() .describe( 'The URL of the remote git repository. This should be the URL of the repository that you cloned to your local workspace. ' + 'For example: "https://github.com/user/my-project.git"', ) .optional(), projectURL: z .string() .describe( 'The URL of the CircleCI project. Can be any of these formats:\n' + '- Project URL: https://app.circleci.com/pipelines/gh/organization/project\n' + '- Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch\n' + '- Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123\n' + '- Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def\n' + '- Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz', ) .optional(), }); ``` -------------------------------------------------------------------------------- /src/tools/listFollowedProjects/tool.ts: -------------------------------------------------------------------------------- ```typescript import { listFollowedProjectsInputSchema } from './inputSchema.js'; export const listFollowedProjectsTool = { name: 'list_followed_projects' as const, description: ` This tool lists all projects that the user is following on CircleCI. Common use cases: - Identify which CircleCI projects are available to the user - Select a project for subsequent operations - Obtain the projectSlug needed for other CircleCI tools Returns: - A list of projects that the user is following on CircleCI - Each entry includes the project name and its projectSlug Workflow: 1. Run this tool to see available projects 2. User selects a project from the list 3. The LLM should extract and use the projectSlug (not the project name) from the selected project for subsequent tool calls 4. The projectSlug is required for many other CircleCI tools, and will be used for those tool calls after a project is selected Note: If pagination limits are reached, the tool will indicate that not all projects could be displayed. IMPORTANT: Do not automatically run any additional tools after this tool is called. Wait for explicit user instruction before executing further tool calls. The LLM MUST NOT invoke any other CircleCI tools until receiving a clear instruction from the user about what to do next, even if the user selects a project. It is acceptable to list out tool call options for the user to choose from, but do not execute them until instructed. `, inputSchema: listFollowedProjectsInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/analyzeDiff/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { analyzeDiffInputSchema } from './inputSchema.js'; import { CircletClient } from '../../clients/circlet/index.js'; /** * Analyzes a git diff against cursor rules to identify rule violations * @param args - Tool arguments containing diff content and rules * @returns Analysis result with any rule violations found */ export const analyzeDiff: ToolCallback<{ params: typeof analyzeDiffInputSchema; }> = async (args) => { const { diff, rules, speedMode, filterBy } = args.params ?? {}; const circlet = new CircletClient(); if (!diff) { return { content: [ { type: 'text', text: 'No diff found. Please provide a diff to analyze.', }, ], }; } if (!rules) { return { content: [ { type: 'text', text: 'No rules found. Please add rules to your repository.', }, ], }; } const response = await circlet.circlet.ruleReview({ diff, rules, filterBy, speedMode, }); if (!response.isRuleCompliant) { return { content: [ { type: 'text', text: response.relatedRules.violations .map((violation) => { return `Rule: ${violation.rule}\nReason: ${violation.reason}\nConfidence Score: ${violation.confidenceScore}`; }) .join('\n\n'), }, ], }; } return { content: [ { type: 'text', text: `All rules are compliant.`, }, ], }; }; ``` -------------------------------------------------------------------------------- /src/tools/listFollowedProjects/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import mcpErrorOutput from '../../lib/mcpErrorOutput.js'; import { getCircleCIPrivateClient } from '../../clients/client.js'; import { listFollowedProjectsInputSchema } from './inputSchema.js'; export const listFollowedProjects: ToolCallback<{ params: typeof listFollowedProjectsInputSchema; }> = async () => { const circleciPrivate = getCircleCIPrivateClient(); const followedProjects = await circleciPrivate.me.getFollowedProjects(); const { projects, reachedMaxPagesOrTimeout } = followedProjects; if (projects.length === 0) { return mcpErrorOutput( 'No projects found. Please make sure you have access to projects and have followed them.', ); } const formattedProjectChoices = projects .map( (project, index) => `${index + 1}. ${project.name} (projectSlug: ${project.slug})`, ) .join('\n'); let resultText = `Projects followed:\n${formattedProjectChoices}\n\nPlease have the user choose one of these projects by name or number. When they choose, you (the LLM) should extract and use the projectSlug (not the project name) associated with their chosen project for subsequent tool calls. This projectSlug is required for tools like get_build_failure_logs, getFlakyTests, and get_job_test_results.`; if (reachedMaxPagesOrTimeout) { resultText = `WARNING: Not all projects were included due to pagination limits or timeout.\n\n${resultText}`; } return { content: [ { type: 'text', text: resultText, }, ], }; }; ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Base image with Node.js and pnpm setup FROM node:lts-alpine AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app # Production dependencies stage FROM base AS prod-deps COPY package.json pnpm-lock.yaml ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --prod --frozen-lockfile --ignore-scripts # Build stage FROM base AS build # Install build dependencies RUN apk add --no-cache git python3 make g++ # Copy package files first for better caching COPY package.json pnpm-lock.yaml ./ # Install all dependencies including devDependencies for building RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --ignore-scripts=false # Install express types and ensure all dependencies are available RUN pnpm add -D @types/express # Copy source files COPY . . # Build the application RUN pnpm run build # Install production dependencies for the final image RUN pnpm install --prod --frozen-lockfile # Final stage - clean minimal image FROM node:lts-alpine ENV NODE_ENV=production WORKDIR /app #Debug Build Info ARG BUILD_ID="" ENV BUILD_ID=$BUILD_ID # Install pnpm RUN corepack enable && corepack prepare pnpm@latest --activate # Copy package files and install only production dependencies COPY package.json pnpm-lock.yaml ./ # Install production dependencies RUN pnpm install --prod --frozen-lockfile # Copy built files and node_modules COPY --from=build /app/dist /app/dist COPY --from=build /app/node_modules /app/node_modules # Docker container to listen on port 8000 EXPOSE 8000 # Command to run the MCP server ENTRYPOINT ["node", "dist/index.js"] ``` -------------------------------------------------------------------------------- /src/tools/rerunWorkflow/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { rerunWorkflowInputSchema } from './inputSchema.js'; import { getCircleCIClient } from '../../clients/client.js'; import mcpErrorOutput from '../../lib/mcpErrorOutput.js'; import { getAppURL } from '../../clients/circleci/index.js'; import { getWorkflowIdFromURL } from '../../lib/getWorkflowIdFromURL.js'; export const rerunWorkflow: ToolCallback<{ params: typeof rerunWorkflowInputSchema; }> = async (args) => { let { workflowId } = args.params ?? {}; const { fromFailed, workflowURL } = args.params ?? {}; const baseURL = getAppURL(); const circleci = getCircleCIClient(); if (workflowURL) { workflowId = getWorkflowIdFromURL(workflowURL); } if (!workflowId) { return mcpErrorOutput( 'workflowId is required and could not be determined from workflowURL.', ); } const workflow = await circleci.workflows.getWorkflow({ workflowId, }); if (!workflow) { return mcpErrorOutput('Workflow not found'); } const workflowFailed = workflow?.status?.toLowerCase() === 'failed'; if (fromFailed && !workflowFailed) { return mcpErrorOutput('Workflow is not failed, cannot rerun from failed'); } const newWorkflow = await circleci.workflows.rerunWorkflow({ workflowId, fromFailed: fromFailed !== undefined ? fromFailed : workflowFailed, }); const workflowUrl = `${baseURL}/pipelines/workflows/${newWorkflow.workflow_id}`; return { content: [ { type: 'text', text: `New workflowId is ${newWorkflow.workflow_id} and [View Workflow in CircleCI](${workflowUrl})`, }, ], }; }; ``` -------------------------------------------------------------------------------- /src/lib/pipeline-job-tests/formatJobTests.ts: -------------------------------------------------------------------------------- ```typescript import { Test } from '../../clients/schemas.js'; import outputTextTruncated, { SEPARATOR } from '../outputTextTruncated.js'; /** * Formats test results into a readable format * @param tests - Array of test results * @returns MCP output object with formatted test results */ export const formatJobTests = (tests: Test[]) => { if (tests.length === 0) { return { content: [ { type: 'text' as const, text: `No test results found. Possible reasons: 1. The selected job doesn't have test results reported 2. Tests might be reported in a different job in the workflow 3. The project may not be configured to collect test metadata Try looking at a different job, pipeline, or branch. You can also check the project's CircleCI configuration to verify test reporting is set up correctly. Note: Not all CircleCI jobs collect test metadata. This requires specific configuration in the .circleci/config.yml file using the store_test_results step. For more information on how to configure test metadata collection, see the CircleCI documentation: https://circleci.com/docs/collect-test-data/`, }, ], }; } const outputText = tests .map((test) => { const fields = [ test.file && `File Name: ${test.file}`, test.classname && `Classname: ${test.classname}`, test.name && `Test name: ${test.name}`, test.result && `Result: ${test.result}`, test.run_time && `Run time: ${test.run_time}`, test.message && `Message: ${test.message}`, ].filter(Boolean); return `${SEPARATOR}${fields.join('\n')}`; }) .join('\n'); return outputTextTruncated(outputText); }; ``` -------------------------------------------------------------------------------- /src/tools/getLatestPipelineStatus/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { branchDescription, projectSlugDescription, } from '../shared/constants.js'; export const getLatestPipelineStatusInputSchema = z.object({ projectSlug: z.string().describe(projectSlugDescription).optional(), branch: z.string().describe(branchDescription).optional(), projectURL: z .string() .describe( 'The URL of the CircleCI project. Can be any of these formats:\n' + '- Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch\n' + '- Legacy Pipeline URL: https://circleci.com/gh/organization/project/123\n' + '- Legacy Pipeline URL with branch: https://circleci.com/gh/organization/project/123?branch=feature-branch\n' + '- Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123\n' + '- Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def\n' + '- Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz', ) .optional(), workspaceRoot: z .string() .describe( 'The absolute path to the root directory of your project workspace. ' + 'This should be the top-level folder containing your source code, configuration files, and dependencies. ' + 'For example: "/home/user/my-project" or "C:\\Users\\user\\my-project"', ) .optional(), gitRemoteURL: z .string() .describe( 'The URL of the remote git repository. This should be the URL of the repository that you cloned to your local workspace. ' + 'For example: "https://github.com/user/my-project.git"', ) .optional(), }); ``` -------------------------------------------------------------------------------- /src/lib/project-detection/vcsTool.ts: -------------------------------------------------------------------------------- ```typescript export type VCSDefinition = { host: 'github.com' | 'bitbucket.org' | 'circleci.com'; name: 'github' | 'bitbucket' | 'circleci'; short: 'gh' | 'bb' | 'circleci'; }; /** * Gitlab is not compatible with this representation * https://circleci.atlassian.net/browse/DEVEX-175 */ export const vcses: VCSDefinition[] = [ { host: 'github.com', name: 'github', short: 'gh', }, { host: 'bitbucket.org', name: 'bitbucket', short: 'bb', }, { host: 'circleci.com', name: 'circleci', short: 'circleci', }, ]; export class UnhandledVCS extends Error { constructor(vcs: string) { super(`VCS ${vcs} is not handled at the moment`); } } export function getVCSFromHost(host: string): VCSDefinition | undefined { return vcses.find(({ host: vcsHost }) => host === vcsHost); } export function mustGetVCSFromHost(host: string): VCSDefinition { const vcs = getVCSFromHost(host); if (vcs === undefined) { throw new UnhandledVCS(host); } return vcs; } export function getVCSFromName(name: string): VCSDefinition | undefined { return vcses.find(({ name: vcsName }) => name === vcsName); } export function mustGetVCSFromName(name: string): VCSDefinition { const vcs = getVCSFromName(name); if (vcs === undefined) { throw new UnhandledVCS(name); } return vcs; } export function getVCSFromShort(short: string): VCSDefinition | undefined { return vcses.find(({ short: vcsShort }) => short === vcsShort); } export function mustGetVCSFromShort(short: string): VCSDefinition { const vcs = getVCSFromShort(short); if (vcs === undefined) { throw new UnhandledVCS(short); } return vcs; } export function isLegacyProject(projectSlug: string) { return ['gh', 'bb'].includes(projectSlug.split('/')[0]); } ``` -------------------------------------------------------------------------------- /src/tools/getJobTestResults/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { branchDescription, projectSlugDescription, } from '../shared/constants.js'; export const getJobTestResultsInputSchema = z.object({ projectSlug: z.string().describe(projectSlugDescription).optional(), branch: z.string().describe(branchDescription).optional(), workspaceRoot: z .string() .describe( 'The absolute path to the root directory of your project workspace. ' + 'This should be the top-level folder containing your source code, configuration files, and dependencies. ' + 'For example: "/home/user/my-project" or "C:\\Users\\user\\my-project"', ) .optional(), gitRemoteURL: z .string() .describe( 'The URL of the remote git repository. This should be the URL of the repository that you cloned to your local workspace. ' + 'For example: "https://github.com/user/my-project.git"', ) .optional(), projectURL: z .string() .describe( 'The URL of the CircleCI project. Can be any of these formats:\n' + '- Project URL: https://app.circleci.com/pipelines/gh/organization/project\n' + '- Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch\n' + '- Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123\n' + '- Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def\n' + '- Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/123', ) .optional(), filterByTestsResult: z .enum(['failure', 'success']) .describe( `Filter the tests by result. If "failure", only failed tests will be returned. If "success", only successful tests will be returned. `, ) .optional(), }); ``` -------------------------------------------------------------------------------- /src/clients/circleci-private/jobsPrivate.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { HTTPClient } from '../circleci/httpClient.js'; const JobOutputResponseSchema = z.string(); const JobErrorResponseSchema = z.string(); type JobOutputResponse = z.infer<typeof JobOutputResponseSchema>; type JobErrorResponse = z.infer<typeof JobErrorResponseSchema>; export class JobsPrivate { protected client: HTTPClient; constructor(client: HTTPClient) { this.client = client; } /** * Get detailed information about a specific job * @param params Configuration parameters * @param params.projectSlug The project slug (e.g., "gh/CircleCI-Public/api-preview-docs") * @param params.jobNumber The number of the job * @param params.taskIndex The index of the task * @param params.stepId The id of the step * @returns Detailed job information including status, timing, and build details */ async getStepOutput({ projectSlug, jobNumber, taskIndex, stepId, }: { projectSlug: string; jobNumber: number; taskIndex: number; stepId: number; }) { // /api/private/output/raw/:vcs/:user/:prj/:num/output/:task_index/:step_id const outputResult = await this.client.get<JobOutputResponse>( `/output/raw/${projectSlug}/${jobNumber}/output/${taskIndex}/${stepId}`, ); const parsedOutput = JobOutputResponseSchema.safeParse(outputResult); // /api/private/output/raw/:vcs/:user/:prj/:num/error/:task_index/:step_id const errorResult = await this.client.get<JobErrorResponse>( `/output/raw/${projectSlug}/${jobNumber}/error/${taskIndex}/${stepId}`, ); const parsedError = JobErrorResponseSchema.safeParse(errorResult); if (!parsedOutput.success || !parsedError.success) { throw new Error('Failed to parse job output or error response'); } return { output: parsedOutput.data, error: parsedError.data, }; } } ``` -------------------------------------------------------------------------------- /src/clients/circleci/usage.ts: -------------------------------------------------------------------------------- ```typescript import { UsageExportJobStart, UsageExportJobStatus, } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; export class UsageAPI { private client: HTTPClient; constructor(client: HTTPClient) { this.client = client; } /** * Starts a usage export job on CircleCI * @param orgId The organization ID * @param start The start date for the usage report in 'YYYY-MM-DD' format * @param end The end date for the usage report in 'YYYY-MM-DD' format * @returns The confirmation and ID for the newly created export job * @throws Will throw an error if the CircleCI API returns a non-OK response */ async startUsageExportJob( orgId: string, start: string, end: string, ): Promise<UsageExportJobStart> { const responseData = await this.client.post<unknown>( `/organizations/${orgId}/usage_export_job`, { start, end }, ); const parsed = UsageExportJobStart.safeParse(responseData); if (!parsed.success) { throw new Error( `Failed to parse startUsageExportJob response: ${parsed.error.message}`, ); } return parsed.data; } /** * Gets the status of a usage export job * @param orgId The organization ID * @param jobId The ID of the export job * @returns The status of the export job, including a download URL on success * @throws Will throw an error if the CircleCI API returns a non-OK response */ async getUsageExportJobStatus( orgId: string, jobId: string, ): Promise<UsageExportJobStatus> { const responseData = await this.client.get<unknown>( `/organizations/${orgId}/usage_export_job/${jobId}`, ); const parsed = UsageExportJobStatus.safeParse(responseData); if (!parsed.success) { throw new Error( `Failed to parse getUsageExportJobStatus response: ${parsed.error.message}`, ); } return parsed.data; } } ``` -------------------------------------------------------------------------------- /src/tools/recommendPromptTemplateTests/tool.ts: -------------------------------------------------------------------------------- ```typescript import { recommendPromptTemplateTestsInputSchema } from './inputSchema.js'; import { PromptWorkbenchToolName, PromptOrigin } from '../shared/constants.js'; import { modelKey } from '../createPromptTemplate/handler.js'; const paramsKey = 'params'; const promptTemplateKey = 'promptTemplate'; const contextSchemaKey = 'contextSchema'; const promptOriginKey = 'promptOrigin'; const recommendedTestsVar = '`recommendedTests`'; export const recommendPromptTemplateTestsTool = { name: PromptWorkbenchToolName.recommend_prompt_template_tests, description: ` About this tool: - This tool is part of a toolchain that generates and provides test cases for a prompt template. - This tool generates an array of recommended tests for a given prompt template. Parameters: - ${paramsKey}: object - ${promptTemplateKey}: string (the prompt template to be tested) - ${contextSchemaKey}: object (the context schema that defines the expected input parameters for the prompt template) - ${promptOriginKey}: "${PromptOrigin.codebase}" | "${PromptOrigin.requirements}" (indicates whether the prompt comes from an existing codebase or from new requirements) - ${modelKey}: string (the model that the prompt template will be tested against) Example usage: { "${paramsKey}": { "${promptTemplateKey}": "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.", "${contextSchemaKey}": { "topic": "string", "age": "number" }, "${promptOriginKey}": "${PromptOrigin.codebase}" } } The tool will return a structured array of test cases that can be used to test the prompt template. Tool output instructions: - The tool will return a ${recommendedTestsVar} array that can be used to test the prompt template. `, inputSchema: recommendPromptTemplateTestsInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/runPipeline/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { branchDescription, projectSlugDescription, } from '../shared/constants.js'; export const runPipelineInputSchema = z.object({ projectSlug: z.string().describe(projectSlugDescription).optional(), branch: z.string().describe(branchDescription).optional(), workspaceRoot: z .string() .describe( 'The absolute path to the root directory of your project workspace. ' + 'This should be the top-level folder containing your source code, configuration files, and dependencies. ' + 'For example: "/home/user/my-project" or "C:\\Users\\user\\my-project"', ) .optional(), gitRemoteURL: z .string() .describe( 'The URL of the remote git repository. This should be the URL of the repository that you cloned to your local workspace. ' + 'For example: "https://github.com/user/my-project.git"', ) .optional(), projectURL: z .string() .describe( 'The URL of the CircleCI project. Can be any of these formats:\n' + '- Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch\n' + '- Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123\n' + '- Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def\n' + '- Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz', ) .optional(), pipelineChoiceName: z .string() .describe( 'The name of the pipeline to run. This parameter is only needed if the project has multiple pipeline definitions. ' + 'If not provided and multiple pipelines exist, the tool will return a list of available pipelines for the user to choose from. ' + 'If provided, it must exactly match one of the pipeline names returned by the tool.', ) .optional(), configContent: z .string() .describe( 'The content of the CircleCI YAML configuration file for the pipeline.', ) .optional(), }); ``` -------------------------------------------------------------------------------- /src/tools/getLatestPipelineStatus/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getLatestPipelineStatusInputSchema } from './inputSchema.js'; import { getBranchFromURL, getProjectSlugFromURL, } from '../../lib/project-detection/index.js'; import mcpErrorOutput from '../../lib/mcpErrorOutput.js'; import { identifyProjectSlug } from '../../lib/project-detection/index.js'; import { getLatestPipelineWorkflows } from '../../lib/latest-pipeline/getLatestPipelineWorkflows.js'; import { formatLatestPipelineStatus } from '../../lib/latest-pipeline/formatLatestPipelineStatus.js'; export const getLatestPipelineStatus: ToolCallback<{ params: typeof getLatestPipelineStatusInputSchema; }> = async (args) => { const { workspaceRoot, gitRemoteURL, branch, projectURL, projectSlug: inputProjectSlug, } = args.params ?? {}; let projectSlug: string | null | undefined; let branchFromURL: string | null | undefined; if (inputProjectSlug) { if (!branch) { return mcpErrorOutput( 'Branch not provided. When using projectSlug, a branch must also be specified.', ); } projectSlug = inputProjectSlug; } else if (projectURL) { projectSlug = getProjectSlugFromURL(projectURL); branchFromURL = getBranchFromURL(projectURL); } else if (workspaceRoot && gitRemoteURL) { projectSlug = await identifyProjectSlug({ gitRemoteURL, }); } else { return mcpErrorOutput( 'Missing required inputs. Please provide either: 1) projectSlug with branch, 2) projectURL, or 3) workspaceRoot with gitRemoteURL and branch.', ); } if (!projectSlug) { return mcpErrorOutput(` Project not found. Ask the user to provide the inputs user can provide based on the tool description. Project slug: ${projectSlug} Git remote URL: ${gitRemoteURL} Branch: ${branch} `); } const latestPipelineWorkflows = await getLatestPipelineWorkflows({ projectSlug, branch: branchFromURL ?? branch, }); return formatLatestPipelineStatus(latestPipelineWorkflows); }; ``` -------------------------------------------------------------------------------- /src/tools/configHelper/handler.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { configHelper } from './handler.js'; import * as client from '../../clients/client.js'; // Mock dependencies vi.mock('../../clients/client.js'); describe('configHelper handler', () => { beforeEach(() => { vi.resetAllMocks(); }); it('should return a valid MCP success response when config is valid', async () => { const mockCircleCIClient = { configValidate: { validateConfig: vi.fn().mockResolvedValue({ valid: true }), }, }; vi.spyOn(client, 'getCircleCIClient').mockReturnValue( mockCircleCIClient as any, ); const args = { params: { configFile: 'version: 2.1\njobs:\n build:\n docker:\n - image: cimg/node:16.0', }, } as any; const controller = new AbortController(); const response = await configHelper(args, { signal: controller.signal, }); expect(response).toHaveProperty('content'); expect(Array.isArray(response.content)).toBe(true); expect(response.content[0]).toHaveProperty('type', 'text'); expect(typeof response.content[0].text).toBe('string'); }); it('should return a valid MCP success response (with error info) when config is invalid', async () => { const mockCircleCIClient = { configValidate: { validateConfig: vi.fn().mockResolvedValue({ valid: false, errors: [{ message: 'Invalid config' }], }), }, }; vi.spyOn(client, 'getCircleCIClient').mockReturnValue( mockCircleCIClient as any, ); const args = { params: { configFile: 'invalid yaml', }, } as any; const controller = new AbortController(); const response = await configHelper(args, { signal: controller.signal, }); expect(response).toHaveProperty('content'); expect(Array.isArray(response.content)).toBe(true); expect(response.content[0]).toHaveProperty('type', 'text'); expect(typeof response.content[0].text).toBe('string'); expect(response.content[0].text).toContain('Invalid config'); }); }); ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml: -------------------------------------------------------------------------------- ```yaml name: '💡 Feature Request' description: Have an idea for improving the MCP CircleCI server? Begin by submitting a Feature Request title: 'Feature Request: ' labels: [feature_request] # assignees: '' body: - type: checkboxes attributes: label: 'Is there an existing issue that is already proposing this?' 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 feature you are requesting' options: - label: 'I have searched the existing issues' required: true - type: textarea id: contact attributes: label: 'Is your feature request related to a problem with the MCP CircleCI integration?' description: 'A clear and concise description of what the problem is. This could be related to the Model Context Protocol implementation, CircleCI integration, or server functionality.' placeholder: | I have an issue when trying to... - Integrate with MCP Client enabled Agents (eg: Cursor, Windsurf, Claude Code, etc) - Handle specific model responses - Process certain types of requests - etc. validations: required: false - type: textarea validations: required: true attributes: label: "Describe the solution you'd like" description: "A clear and concise description of what you want to happen. Include any specific MCP or CircleCI features you'd like to see supported." - type: textarea validations: required: true attributes: label: 'Implementation and compatibility considerations' description: "Please describe any considerations around:\n- Compatibility with existing MCP features\n- CircleCI API integration requirements\n- Performance implications\n- Security considerations" - type: textarea validations: required: true attributes: label: 'What is the motivation / use case for this feature?' description: 'Describe the specific use case this would enable for MCP CircleCI integration. For example, how would this improve the development workflow or enhance the integration capabilities?' ``` -------------------------------------------------------------------------------- /src/tools/downloadUsageApiData/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { downloadUsageApiDataInputSchema } from './inputSchema.js'; import { getUsageApiData } from '../../lib/usage-api/getUsageApiData.js'; import { parseDateTimeString } from '../../lib/usage-api/parseDateTimeString.js'; import { differenceInCalendarDays, parseISO } from 'date-fns'; import mcpErrorOutput from '../../lib/mcpErrorOutput.js'; export const downloadUsageApiData: ToolCallback<{ params: typeof downloadUsageApiDataInputSchema }> = async (args) => { const { CIRCLECI_BASE_URL } = process.env; if (CIRCLECI_BASE_URL && CIRCLECI_BASE_URL !== 'https://circleci.com') { return mcpErrorOutput('ERROR: The Usage API is not available on CircleCI server installations. This tool is only available for CircleCI cloud users.'); } const { orgId, startDate, endDate, outputDir, jobId, } = args.params ?? {}; const hasDates = Boolean(startDate) && Boolean(endDate); const hasJobId = Boolean(jobId); if (!hasJobId && !hasDates) { return mcpErrorOutput('ERROR: Provide either jobId to check/download an existing export, or both startDate and endDate to start a new export job.'); } const normalizedStartDate = startDate ? (parseDateTimeString(startDate, { defaultTime: 'start-of-day' }) ?? undefined) : undefined; const normalizedEndDate = endDate ? (parseDateTimeString(endDate, { defaultTime: 'end-of-day' }) ?? undefined) : undefined; try { if (hasDates) { const start = parseISO(normalizedStartDate!); const end = parseISO(normalizedEndDate!); const days = differenceInCalendarDays(end, start) + 1; if (days > 32) { return mcpErrorOutput(`ERROR: The maximum allowed date range for the usage API is 32 days.`); } if (days < 1) { return mcpErrorOutput('ERROR: The end date must be after or equal to the start date.'); } } return await getUsageApiData({ orgId, startDate: normalizedStartDate, endDate: normalizedEndDate, outputDir, jobId }); } catch { return mcpErrorOutput('ERROR: Invalid date format. Please use YYYY-MM-DD or a recognizable date string.'); } }; ``` -------------------------------------------------------------------------------- /src/tools/getBuildFailureLogs/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getPipelineNumberFromURL, getProjectSlugFromURL, getBranchFromURL, identifyProjectSlug, getJobNumberFromURL, } from '../../lib/project-detection/index.js'; import { getBuildFailureOutputInputSchema } from './inputSchema.js'; import getPipelineJobLogs from '../../lib/pipeline-job-logs/getPipelineJobLogs.js'; import { formatJobLogs } from '../../lib/pipeline-job-logs/getJobLogs.js'; import mcpErrorOutput from '../../lib/mcpErrorOutput.js'; export const getBuildFailureLogs: ToolCallback<{ params: typeof getBuildFailureOutputInputSchema; }> = async (args) => { const { workspaceRoot, gitRemoteURL, branch, projectURL, projectSlug: inputProjectSlug, } = args.params ?? {}; let projectSlug: string | undefined; let pipelineNumber: number | undefined; let branchFromURL: string | undefined; let jobNumber: number | undefined; if (inputProjectSlug) { if (!branch) { return mcpErrorOutput( 'Branch not provided. When using projectSlug, a branch must also be specified.', ); } projectSlug = inputProjectSlug; } else if (projectURL) { projectSlug = getProjectSlugFromURL(projectURL); pipelineNumber = getPipelineNumberFromURL(projectURL); branchFromURL = getBranchFromURL(projectURL); jobNumber = getJobNumberFromURL(projectURL); } else if (workspaceRoot && gitRemoteURL && branch) { projectSlug = await identifyProjectSlug({ gitRemoteURL, }); } else { return mcpErrorOutput( 'Missing required inputs. Please provide either: 1) projectSlug with branch, 2) projectURL, or 3) workspaceRoot with gitRemoteURL and branch.', ); } if (!projectSlug) { return mcpErrorOutput(` Project not found. Ask the user to provide the inputs user can provide based on the tool description. Project slug: ${projectSlug} Git remote URL: ${gitRemoteURL} Branch: ${branch} `); } const logs = await getPipelineJobLogs({ projectSlug, branch: branchFromURL || branch, pipelineNumber, jobNumber, }); return formatJobLogs(logs); }; ``` -------------------------------------------------------------------------------- /src/tools/getJobTestResults/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getProjectSlugFromURL, identifyProjectSlug, getJobNumberFromURL, getBranchFromURL, getPipelineNumberFromURL, } from '../../lib/project-detection/index.js'; import { getJobTestResultsInputSchema } from './inputSchema.js'; import { getJobTests } from '../../lib/pipeline-job-tests/getJobTests.js'; import { formatJobTests } from '../../lib/pipeline-job-tests/formatJobTests.js'; import mcpErrorOutput from '../../lib/mcpErrorOutput.js'; export const getJobTestResults: ToolCallback<{ params: typeof getJobTestResultsInputSchema; }> = async (args) => { const { workspaceRoot, gitRemoteURL, branch, projectURL, filterByTestsResult, projectSlug: inputProjectSlug, } = args.params ?? {}; let pipelineNumber: number | undefined; let projectSlug: string | undefined; let jobNumber: number | undefined; let branchFromURL: string | undefined; if (inputProjectSlug) { if (!branch) { return mcpErrorOutput( 'Branch not provided. When using projectSlug, a branch must also be specified.', ); } projectSlug = inputProjectSlug; } else if (projectURL) { pipelineNumber = getPipelineNumberFromURL(projectURL); projectSlug = getProjectSlugFromURL(projectURL); branchFromURL = getBranchFromURL(projectURL); jobNumber = getJobNumberFromURL(projectURL); } else if (workspaceRoot && gitRemoteURL && branch) { projectSlug = await identifyProjectSlug({ gitRemoteURL, }); } else { return mcpErrorOutput( 'Missing required inputs. Please provide either: 1) projectSlug with branch, 2) projectURL, or 3) workspaceRoot with gitRemoteURL and branch.', ); } if (!projectSlug) { return mcpErrorOutput(` Project not found. Please provide a valid project URL or project information. Project slug: ${projectSlug} Git remote URL: ${gitRemoteURL} Branch: ${branch} `); } const testResults = await getJobTests({ projectSlug, pipelineNumber, branch: branchFromURL || branch, jobNumber, filterByTestsResult, }); return formatJobTests(testResults); }; ``` -------------------------------------------------------------------------------- /src/lib/pipeline-job-logs/getPipelineJobLogs.ts: -------------------------------------------------------------------------------- ```typescript import { getCircleCIClient } from '../../clients/client.js'; import { Pipeline } from '../../clients/schemas.js'; import getJobLogs from './getJobLogs.js'; export type GetPipelineJobLogsParams = { projectSlug: string; branch?: string; pipelineNumber?: number; // if provided, always use this to fetch the pipeline instead of the branch jobNumber?: number; // if provided, always use this to fetch the job instead of the branch and pipeline number }; const getPipelineJobLogs = async ({ projectSlug, branch, pipelineNumber, jobNumber, }: GetPipelineJobLogsParams) => { const circleci = getCircleCIClient(); let pipeline: Pipeline | undefined; // If jobNumber is provided, fetch the job logs directly if (jobNumber) { return await getJobLogs({ projectSlug, jobNumbers: [jobNumber], failedStepsOnly: true, }); } // If pipelineNumber is provided, fetch the pipeline logs for failed steps in jobs if (pipelineNumber) { pipeline = await circleci.pipelines.getPipelineByNumber({ projectSlug, pipelineNumber, }); } else if (branch) { // If branch is provided, fetch the pipeline logs for failed steps in jobs for a branch const pipelines = await circleci.pipelines.getPipelines({ projectSlug, branch, }); pipeline = pipelines[0]; } else { // If no jobNumber, pipelineNumber or branch is provided, throw an error throw new Error( 'Either jobNumber, pipelineNumber or branch must be provided', ); } if (!pipeline) { throw new Error('Pipeline not found'); } const workflows = await circleci.workflows.getPipelineWorkflows({ pipelineId: pipeline.id, }); const jobs = ( await Promise.all( workflows.map(async (workflow) => { return await circleci.jobs.getWorkflowJobs({ workflowId: workflow.id, }); }), ) ).flat(); const jobNumbers = jobs .filter( (job): job is typeof job & { job_number: number } => job.job_number != null, ) .map((job) => job.job_number); return await getJobLogs({ projectSlug, jobNumbers, failedStepsOnly: true, }); }; export default getPipelineJobLogs; ``` -------------------------------------------------------------------------------- /src/clients/circleci/tests.ts: -------------------------------------------------------------------------------- ```typescript import { Test } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; import { defaultPaginationOptions } from './index.js'; import { z } from 'zod'; const TestResponseSchema = z.object({ items: z.array(Test), next_page_token: z.string().nullable().optional(), }); export class TestsAPI { protected client: HTTPClient; constructor(httpClient: HTTPClient) { this.client = httpClient; } /** * Get all tests for a job with pagination support * @param params Configuration parameters * @param params.projectSlug The project slug * @param params.jobNumber The job number * @param params.options Optional configuration for pagination limits * @param params.options.maxPages Maximum number of pages to fetch (default: 5) * @param params.options.timeoutMs Timeout in milliseconds (default: 10000) * @returns All tests from the job * @throws Error if timeout or max pages reached */ async getJobTests({ projectSlug, jobNumber, options = {}, }: { projectSlug: string; jobNumber: number; options?: { maxPages?: number; timeoutMs?: number; }; }): Promise<Test[]> { const { maxPages = defaultPaginationOptions.maxPages, timeoutMs = defaultPaginationOptions.timeoutMs, } = options; const startTime = Date.now(); const allTests: Test[] = []; let nextPageToken: string | null = null; let pageCount = 0; do { // Check timeout if (Date.now() - startTime > timeoutMs) { throw new Error(`Timeout reached after ${timeoutMs}ms`); } // Check page limit if (pageCount >= maxPages) { throw new Error(`Maximum number of pages (${maxPages}) reached`); } const params = nextPageToken ? { 'page-token': nextPageToken } : {}; const rawResult = await this.client.get<unknown>( `/project/${projectSlug}/${jobNumber}/tests`, params, ); // Validate the response against our TestResponse schema const result = TestResponseSchema.parse(rawResult); pageCount++; allTests.push(...result.items); nextPageToken = result.next_page_token ?? null; } while (nextPageToken); return allTests; } } ``` -------------------------------------------------------------------------------- /src/tools/getFlakyTests/tool.ts: -------------------------------------------------------------------------------- ```typescript import { getFlakyTestLogsInputSchema } from './inputSchema.js'; export const getFlakyTestLogsTool = { name: 'find_flaky_tests' as const, description: ` This tool retrieves information about flaky tests in a CircleCI project. The agent receiving this output MUST analyze the flaky test data and implement appropriate fixes based on the specific issues identified. CRITICAL REQUIREMENTS: 1. Truncation Handling (HIGHEST PRIORITY): - ALWAYS check for <MCPTruncationWarning> in the output - When present, you MUST start your response with: "WARNING: The logs have been truncated. Only showing the most recent entries. Earlier build failures may not be visible." - Only proceed with log analysis after acknowledging the truncation Input options (EXACTLY ONE of these THREE options must be used): Option 1 - Project Slug: - projectSlug: The project slug obtained from listFollowedProjects tool (e.g., "gh/organization/project") Option 2 - Direct URL (provide ONE of these): - projectURL: The URL of the CircleCI project in any of these formats: * Project URL: https://app.circleci.com/pipelines/gh/organization/project * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123 * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz Option 3 - Project Detection (ALL of these must be provided together): - workspaceRoot: The absolute path to the workspace root - gitRemoteURL: The URL of the git remote repository Additional Requirements: - Never call this tool with incomplete parameters - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects - If using Option 2, the URLs MUST be provided by the user - do not attempt to construct or guess URLs - If using Option 3, BOTH parameters (workspaceRoot, gitRemoteURL) must be provided - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call `, inputSchema: getFlakyTestLogsInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/getLatestPipelineStatus/tool.ts: -------------------------------------------------------------------------------- ```typescript import { getLatestPipelineStatusInputSchema } from './inputSchema.js'; import { option1DescriptionBranchRequired } from '../shared/constants.js'; export const getLatestPipelineStatusTool = { name: 'get_latest_pipeline_status' as const, description: ` This tool retrieves the status of the latest pipeline for a CircleCI project. It can be used to check pipeline status, get latest build status, or view current pipeline state. Common use cases: - Check latest pipeline status - Get current build status - View pipeline state - Check build progress - Get pipeline information Input options (EXACTLY ONE of these THREE options must be used): ${option1DescriptionBranchRequired} Option 2 - Direct URL (provide ONE of these): - projectURL: The URL of the CircleCI project in any of these formats: * Project URL: https://app.circleci.com/pipelines/gh/organization/project * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123 * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz * Legacy Job URL: https://circleci.com/gh/organization/project/123 Option 3 - Project Detection (ALL of these must be provided together): - workspaceRoot: The absolute path to the workspace root - gitRemoteURL: The URL of the git remote repository - branch: The name of the current branch Recommended Workflow: 1. Use listFollowedProjects tool to get a list of projects 2. Extract the projectSlug from the chosen project (format: "gh/organization/project") 3. Use that projectSlug with a branch name for this tool Additional Requirements: - Never call this tool with incomplete parameters - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects - If using Option 2, the URLs MUST be provided by the user - do not attempt to construct or guess URLs - If using Option 3, ALL THREE parameters (workspaceRoot, gitRemoteURL, branch) must be provided - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call `, inputSchema: getLatestPipelineStatusInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/runEvaluationTests/inputSchema.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { branchDescription, fileNameTemplate, projectSlugDescription, promptsOutputDirectory, } from '../shared/constants.js'; export const runEvaluationTestsInputSchema = z.object({ projectSlug: z.string().describe(projectSlugDescription).optional(), branch: z.string().describe(branchDescription).optional(), workspaceRoot: z .string() .describe( 'The absolute path to the root directory of your project workspace. ' + 'This should be the top-level folder containing your source code, configuration files, and dependencies. ' + 'For example: "/home/user/my-project" or "C:\\Users\\user\\my-project"', ) .optional(), gitRemoteURL: z .string() .describe( 'The URL of the remote git repository. This should be the URL of the repository that you cloned to your local workspace. ' + 'For example: "https://github.com/user/my-project.git"', ) .optional(), projectURL: z .string() .describe( 'The URL of the CircleCI project. Can be any of these formats:\n' + '- Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch\n' + '- Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123\n' + '- Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def\n' + '- Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz', ) .optional(), pipelineChoiceName: z .string() .describe( 'The name of the pipeline to run. This parameter is only needed if the project has multiple pipeline definitions. ' + 'If not provided and multiple pipelines exist, the tool will return a list of available pipelines for the user to choose from. ' + 'If provided, it must exactly match one of the pipeline names returned by the tool.', ) .optional(), promptFiles: z .array( z.object({ fileName: z.string().describe('The name of the prompt template file'), fileContent: z .string() .describe('The contents of the prompt template file'), }), ) .describe( `Array of prompt template files in the ${promptsOutputDirectory} directory (e.g. ${fileNameTemplate}).`, ), }); ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@circleci/mcp-server-circleci", "version": "0.14.1", "description": "A Model Context Protocol (MCP) server implementation for CircleCI, enabling natural language interactions with CircleCI functionality through MCP-enabled clients", "type": "module", "access": "public", "license": "Apache-2.0", "homepage": "https://github.com/CircleCI-Public/mcp-server-circleci/", "bugs": "https://github.com/CircleCI-Public/mcp-server-circleci/issues", "repository": { "type": "git", "url": "https://github.com/CircleCI-Public/mcp-server-circleci.git" }, "bin": { "mcp-server-circleci": "./dist/index.js" }, "files": [ "dist", "CHANGELOG.md" ], "packageManager": "[email protected]", "scripts": { "build": "rm -rf dist && tsc && shx chmod +x dist/*.js", "watch": "nodemon --watch . --ext ts,json --exec pnpm run build", "inspector": "npx @modelcontextprotocol/[email protected] node ./dist/index.js", "build:inspector": "pnpm run build && pnpm run inspector", "create-tool": "node ./scripts/create-tool.js", "tsx": "tsx", "typecheck": "tsc --noEmit", "lint": "eslint .", "lint:fix": "eslint . --fix", "prettier": "prettier --write \"**/*.{ts,js,json}\"", "test": "vitest", "test:run": "vitest run", "prepublishOnly": "pnpm run build && pnpm run test:run", "bump:patch": "pnpm version patch --no-git-tag-version", "bump:minor": "pnpm version minor --no-git-tag-version", "bump:major": "pnpm version major --no-git-tag-version" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", "chrono-node": "2.8.3", "csv-parse": "6.0.0", "date-fns": "4.1.0", "express": "^4.19.2", "parse-github-url": "^1.0.3", "zod": "^3.24.2", "zod-to-json-schema": "^3.24.3" }, "devDependencies": { "@eslint/js": "^9.21.0", "@types/express": "5.0.3", "@types/node": "^22", "@types/parse-github-url": "^1.0.3", "@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/parser": "^8.25.0", "eslint": "^9.21.0", "eslint-config-prettier": "^10.0.2", "eslint-plugin-prettier": "^5.2.3", "nodemon": "^3.1.9", "prettier": "^3.5.2", "shx": "^0.4.0", "tsx": "^4.19.3", "typescript": "^5.6.2", "typescript-eslint": "^8.28.0", "vitest": "^3.1.1" }, "engines": { "node": ">=18.0.0" } } ``` -------------------------------------------------------------------------------- /src/tools/runPipeline/tool.ts: -------------------------------------------------------------------------------- ```typescript import { runPipelineInputSchema } from './inputSchema.js'; import { option1DescriptionBranchRequired } from '../shared/constants.js'; export const runPipelineTool = { name: 'run_pipeline' as const, description: ` This tool triggers a new CircleCI pipeline and returns the URL to monitor its progress. Input options (EXACTLY ONE of these THREE options must be used): ${option1DescriptionBranchRequired} Option 2 - Direct URL (provide ONE of these): - projectURL: The URL of the CircleCI project in any of these formats: * Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123 * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz Option 3 - Project Detection (ALL of these must be provided together): - workspaceRoot: The absolute path to the workspace root - gitRemoteURL: The URL of the git remote repository - branch: The name of the current branch Configuration: - an optional configContent parameter can be provided to override the default pipeline configuration Pipeline Selection: - If the project has multiple pipeline definitions, the tool will return a list of available pipelines - You must then make another call with the chosen pipeline name using the pipelineChoiceName parameter - The pipelineChoiceName must exactly match one of the pipeline names returned by the tool - If the project has only one pipeline definition, pipelineChoiceName is not needed Additional Requirements: - Never call this tool with incomplete parameters - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects - If using Option 2, the URLs MUST be provided by the user - do not attempt to construct or guess URLs - If using Option 3, ALL THREE parameters (workspaceRoot, gitRemoteURL, branch) must be provided - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call Returns: - A URL to the newly triggered pipeline that can be used to monitor its progress `, inputSchema: runPipelineInputSchema, }; ``` -------------------------------------------------------------------------------- /src/clients/circlet/circlet.ts: -------------------------------------------------------------------------------- ```typescript import { HTTPClient } from '../circleci/httpClient.js'; import { PromptObject, RuleReview } from '../schemas.js'; import { z } from 'zod'; import { PromptOrigin, FilterBy } from '../../tools/shared/constants.js'; export const WorkbenchResponseSchema = z .object({ workbench: PromptObject, }) .strict(); export type WorkbenchResponse = z.infer<typeof WorkbenchResponseSchema>; export const RecommendedTestsResponseSchema = z.object({ recommendedTests: z.array(z.string()), }); export type RecommendedTestsResponse = z.infer< typeof RecommendedTestsResponseSchema >; export class CircletAPI { protected client: HTTPClient; constructor(client: HTTPClient) { this.client = client; } async createPromptTemplate( prompt: string, promptOrigin: PromptOrigin, ): Promise<PromptObject> { const result = await this.client.post<WorkbenchResponse>('/workbench', { prompt, promptOrigin, }); const parsedResult = WorkbenchResponseSchema.safeParse(result); if (!parsedResult.success) { throw new Error( `Failed to parse workbench response. Error: ${parsedResult.error.message}`, ); } return parsedResult.data.workbench; } async recommendPromptTemplateTests({ template, contextSchema, }: { template: string; contextSchema: Record<string, string>; }): Promise<string[]> { const result = await this.client.post<RecommendedTestsResponse>( '/recommended-tests', { template, contextSchema, }, ); const parsedResult = RecommendedTestsResponseSchema.safeParse(result); if (!parsedResult.success) { throw new Error( `Failed to parse recommended tests response. Error: ${parsedResult.error.message}`, ); } return parsedResult.data.recommendedTests; } async ruleReview({ diff, rules, speedMode, filterBy, }: { diff: string; rules: string; speedMode: boolean; filterBy: FilterBy; }): Promise<RuleReview> { const rawResult = await this.client.post<unknown>('/rule-review', { changeSet: diff, rules, speedMode, filterBy, }); const parsedResult = RuleReview.safeParse(rawResult); if (!parsedResult.success) { throw new Error( `Failed to parse rule review response. Error: ${parsedResult.error.message}`, ); } return parsedResult.data; } } ``` -------------------------------------------------------------------------------- /src/tools/findUnderusedResourceClasses/handler.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { findUnderusedResourceClasses } from './handler.js'; import { promises as fs } from 'fs'; vi.mock('fs', () => ({ promises: { readFile: vi.fn(), }, })); describe('findUnderusedResourceClasses handler', () => { 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'; const CSV_ROW_UNDER = 'proj,flow,build,medium,10,20,15,18'; const CSV_ROW_OVER = 'proj,flow,test,large,50,60,55,58'; const CSV = `${CSV_HEADERS}\n${CSV_ROW_UNDER}\n${CSV_ROW_OVER}`; const CSV_MISSING = 'job_name,resource_class,avg_cpu_pct,max_cpu_pct,avg_ram_pct,max_ram_pct\nfoo,medium,10,20,15,18'; beforeEach(() => { (fs.readFile as any).mockReset(); }); it('returns an error if file read fails', async () => { (fs.readFile as any).mockRejectedValue(new Error('fail')); const result = await findUnderusedResourceClasses({ params: { csvFilePath: '/tmp/usage.csv', threshold: 40 } }, undefined as any); expect(result.isError).toBeTruthy(); expect(result.content[0].text).toContain('Could not read CSV file'); }); it('returns an error if CSV is missing required columns', async () => { (fs.readFile as any).mockResolvedValue(CSV_MISSING); const result = await findUnderusedResourceClasses({ params: { csvFilePath: '/tmp/usage.csv', threshold: 40 } }, undefined as any); expect(result.isError).toBeTruthy(); expect(result.content[0].text).toContain('Could not read CSV file'); }); it('returns an error if all jobs are above threshold', async () => { const CSV_OVER = `${CSV_HEADERS}\nproj,flow,test,large,50,60,55,58`; (fs.readFile as any).mockResolvedValue(CSV_OVER); const result = await findUnderusedResourceClasses({ params: { csvFilePath: '/tmp/usage.csv', threshold: 40 } }, undefined as any); expect(result.isError).toBeTruthy(); expect(result.content[0].text).toContain('Could not read CSV file'); }); it('returns an error even if underused jobs are present', async () => { (fs.readFile as any).mockResolvedValue(CSV); const result = await findUnderusedResourceClasses({ params: { csvFilePath: '/tmp/usage.csv', threshold: 40 } }, undefined as any); expect(result.isError).toBeTruthy(); expect(result.content[0].text).toContain('Could not read CSV file'); }); }); ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { CCI_HANDLERS, CCI_TOOLS } from './circleci-tools.js'; import { createUnifiedTransport } from './transports/unified.js'; import { createStdioTransport } from './transports/stdio.js'; const server = new McpServer( { name: 'mcp-server-circleci', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } }, ); // ---- DEBUG WRAPPERS -------------------------------------------------- if (process.env.debug === 'true') { const srv: any = server; if (typeof srv.notification === 'function') { const origNotify = srv.notification.bind(server); srv.notification = async (...args: any[]) => { try { const [{ method, params }] = args; console.error( '[DEBUG] outgoing notification:', method, JSON.stringify(params), ); } catch { /* ignore */ } return origNotify(...args); }; } if (typeof srv.request === 'function') { const origRequest = srv.request.bind(server); srv.request = async (...args: any[]) => { const [payload] = args; const result = await origRequest(...args); console.error( '[DEBUG] response to', payload?.method, JSON.stringify(result).slice(0, 200), ); return result; }; } } // Register all CircleCI tools once if (process.env.debug === 'true') { console.error('[DEBUG] [Startup] Registering CircleCI MCP tools...'); } // Ensure we advertise support for tools/list in capabilities (SDK only sets listChanged) (server as any).server.registerCapabilities({ tools: { list: true } }); CCI_TOOLS.forEach((tool) => { const handler = CCI_HANDLERS[tool.name]; if (!handler) throw new Error(`Handler for tool ${tool.name} not found`); if (process.env.debug === 'true') { console.error(`[DEBUG] [Startup] Registering tool: ${tool.name}`); } server.tool( tool.name, tool.description, { params: tool.inputSchema.optional() }, handler as any, ); }); async function main() { if (process.env.start === 'remote') { console.error('Starting CircleCI MCP unified HTTP+SSE server...'); createUnifiedTransport(server); } else { console.error('Starting CircleCI MCP server in stdio mode...'); createStdioTransport(server); } } main().catch((err) => { console.error('Server error:', err); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/tools/getBuildFailureLogs/tool.ts: -------------------------------------------------------------------------------- ```typescript import { getBuildFailureOutputInputSchema } from './inputSchema.js'; import { option1DescriptionBranchRequired } from '../shared/constants.js'; export const getBuildFailureLogsTool = { name: 'get_build_failure_logs' as const, description: ` This tool helps debug CircleCI build failures by retrieving failure logs. CRITICAL REQUIREMENTS: 1. Truncation Handling (HIGHEST PRIORITY): - ALWAYS check for <MCPTruncationWarning> in the output - When present, you MUST start your response with: "WARNING: The logs have been truncated. Only showing the most recent entries. Earlier build failures may not be visible." - Only proceed with log analysis after acknowledging the truncation Input options (EXACTLY ONE of these THREE options must be used): ${option1DescriptionBranchRequired} Option 2 - Direct URL (provide ONE of these): - projectURL: The URL of the CircleCI project in any of these formats: * Project URL: https://app.circleci.com/pipelines/gh/organization/project * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123 * Legacy Job URL: https://circleci.com/pipelines/gh/organization/project/123 * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz Option 3 - Project Detection (ALL of these must be provided together): - workspaceRoot: The absolute path to the workspace root - gitRemoteURL: The URL of the git remote repository - branch: The name of the current branch Recommended Workflow: 1. Use listFollowedProjects tool to get a list of projects 2. Extract the projectSlug from the chosen project (format: "gh/organization/project") 3. Use that projectSlug with a branch name for this tool Additional Requirements: - Never call this tool with incomplete parameters - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects - If using Option 2, the URLs MUST be provided by the user - do not attempt to construct or guess URLs - If using Option 3, ALL THREE parameters (workspaceRoot, gitRemoteURL, branch) must be provided - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call `, inputSchema: getBuildFailureOutputInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/shared/constants.ts: -------------------------------------------------------------------------------- ```typescript // SHARED CIRCLECI TOOL DESCRIPTION CONSTANTS export const projectSlugDescription = `The project slug from listFollowedProjects tool (e.g., "gh/organization/project"). When using this option, branch must also be provided.`; export const projectSlugDescriptionNoBranch = `The project slug from listFollowedProjects tool (e.g., "gh/organization/project").`; 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.`; export const option1DescriptionBranchRequired = `Option 1 - Project Slug and branch (BOTH required): - projectSlug: The project slug obtained from listFollowedProjects tool (e.g., "gh/organization/project") - branch: The name of the branch (required when using projectSlug)`; export const workflowUrlDescription = 'The URL of the CircleCI workflow or job. Can be any of these formats:\n' + '- Workflow URL: https://app.circleci.com/pipelines/:vcsType/:orgName/:projectName/:pipelineNumber/workflows/:workflowId' + '- Job URL: https://app.circleci.com/pipelines/:vcsType/:orgName/:projectName/:pipelineNumber/workflows/:workflowId/jobs/:buildNumber'; // PROMPT TEMPLATE ITERATION & TESTING TOOL CONSTANTS // 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. export const defaultModel = 'gpt-4.1-mini'; export const defaultTemperature = 1.0; export const promptsOutputDirectory = './prompts'; export const fileExtension = '.prompt.yml'; export const fileNameTemplate = `<relevant-name>${fileExtension}`; export const fileNameExample1 = `bedtime-story-generator${fileExtension}`; export const fileNameExample2 = `plant-care-assistant${fileExtension}`; export const fileNameExample3 = `customer-support-chatbot${fileExtension}`; export enum PromptWorkbenchToolName { create_prompt_template = 'create_prompt_template', recommend_prompt_template_tests = 'recommend_prompt_template_tests', } // What is the origin of the Prompt Workbench toolchain request? export enum PromptOrigin { codebase = 'codebase', // pre-existing prompts in user's codebase requirements = 'requirements', // new feature requirements provided by user } // ANALYZE DIFF TOOL CONSTANTS export enum FilterBy { violations = 'Violations', compliants = 'Compliants', humanReviewRequired = 'Human Review Required', none = 'None', } ``` -------------------------------------------------------------------------------- /src/lib/flaky-tests/getFlakyTests.ts: -------------------------------------------------------------------------------- ```typescript import { getCircleCIClient } from '../../clients/client.js'; import { Test } from '../../clients/schemas.js'; import { rateLimitedRequests } from '../rateLimitedRequests/index.js'; import outputTextTruncated, { SEPARATOR } from '../outputTextTruncated.js'; const getFlakyTests = async ({ projectSlug }: { projectSlug: string }) => { const circleci = getCircleCIClient(); const flakyTests = await circleci.insights.getProjectFlakyTests({ projectSlug, }); if (!flakyTests || !flakyTests.flaky_tests) { throw new Error('Flaky tests not found'); } const flakyTestDetails = [ ...new Set( flakyTests.flaky_tests.map((test) => ({ jobNumber: test.job_number, test_name: test.test_name, })), ), ]; const testsArrays = await rateLimitedRequests( flakyTestDetails.map(({ jobNumber, test_name }) => async () => { try { const tests = await circleci.tests.getJobTests({ projectSlug, jobNumber, }); const matchingTest = tests.find((test) => test.name === test_name); if (matchingTest) { return matchingTest; } console.error(`Test ${test_name} not found in job ${jobNumber}`); return tests.filter((test) => test.result === 'failure'); } catch (error) { if (error instanceof Error && error.message.includes('404')) { console.error(`Job ${jobNumber} not found:`, error); return undefined; } else if (error instanceof Error && error.message.includes('429')) { console.error(`Rate limited for job request ${jobNumber}:`, error); return undefined; } throw error; } }), ); const filteredTestsArrays = testsArrays .flat() .filter((test) => test !== undefined); return filteredTestsArrays; }; export const formatFlakyTests = (tests: Test[]) => { if (tests.length === 0) { return { content: [{ type: 'text' as const, text: 'No flaky tests found' }], }; } const outputText = tests .map((test) => { const fields = [ test.file && `File Name: ${test.file}`, test.classname && `Classname: ${test.classname}`, test.name && `Test name: ${test.name}`, test.result && `Result: ${test.result}`, test.run_time && `Run time: ${test.run_time}`, test.message && `Message: ${test.message}`, ].filter(Boolean); return `${SEPARATOR}${fields.join('\n')}`; }) .join('\n'); return outputTextTruncated(outputText); }; export default getFlakyTests; ``` -------------------------------------------------------------------------------- /src/clients/circleci-private/me.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; import { HTTPClient } from '../circleci/httpClient.js'; import { FollowedProject } from '../schemas.js'; import { defaultPaginationOptions } from '../circleci/index.js'; const FollowedProjectResponseSchema = z.object({ items: z.array(FollowedProject), next_page_token: z.string().nullable(), }); export class MeAPI { protected client: HTTPClient; constructor(client: HTTPClient) { this.client = client; } /** * Get the projects that the user is following with pagination support * @param options Optional configuration for pagination limits * @param options.maxPages Maximum number of pages to fetch (default: 5) * @param options.timeoutMs Timeout in milliseconds (default: 10000) * @returns All followed projects * @throws Error if timeout or max pages reached */ async getFollowedProjects( options: { maxPages?: number; timeoutMs?: number; } = {}, ): Promise<{ projects: FollowedProject[]; reachedMaxPagesOrTimeout: boolean; }> { const { maxPages = 20, timeoutMs = defaultPaginationOptions.timeoutMs } = options; const startTime = Date.now(); const allProjects: FollowedProject[] = []; let nextPageToken: string | null = null; let previousPageToken: string | null = null; let pageCount = 0; do { // Check timeout if (Date.now() - startTime > timeoutMs) { return { projects: allProjects, reachedMaxPagesOrTimeout: true, }; } // Check page limit if (pageCount >= maxPages) { return { projects: allProjects, reachedMaxPagesOrTimeout: true, }; } const params = nextPageToken ? { 'page-token': nextPageToken } : {}; const rawResult = await this.client.get<unknown>( '/me/followed-projects', params, ); // Validate the response against our schema const result = FollowedProjectResponseSchema.parse(rawResult); pageCount++; allProjects.push(...result.items); // Store the current token before updating previousPageToken = nextPageToken; nextPageToken = result.next_page_token; // Break if we received the same token as before (stuck in a loop) if (nextPageToken && nextPageToken === previousPageToken) { return { projects: allProjects, reachedMaxPagesOrTimeout: true, }; } } while (nextPageToken); return { projects: allProjects, reachedMaxPagesOrTimeout: false, }; } } ``` -------------------------------------------------------------------------------- /src/clients/circleci/jobs.ts: -------------------------------------------------------------------------------- ```typescript import { Job } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; import { defaultPaginationOptions } from './index.js'; import { z } from 'zod'; const WorkflowJobResponseSchema = z.object({ items: z.array(Job), next_page_token: z.string().nullable(), }); export class JobsAPI { protected client: HTTPClient; constructor(httpClient: HTTPClient) { this.client = httpClient; } /** * Get job details by job number * @param params Configuration parameters * @param params.projectSlug The project slug (e.g., "gh/CircleCI-Public/api-preview-docs") * @param params.jobNumber The number of the job * @returns Job details */ async getJobByNumber({ projectSlug, jobNumber, }: { projectSlug: string; jobNumber: number; }): Promise<Job> { const rawResult = await this.client.get<unknown>( `/project/${projectSlug}/job/${jobNumber}`, ); // Validate the response against our Job schema return Job.parse(rawResult); } /** * Get jobs for a workflow with pagination support * @param params Configuration parameters * @param params.workflowId The ID of the workflow * @param params.options Optional configuration for pagination limits * @param params.options.maxPages Maximum number of pages to fetch (default: 5) * @param params.options.timeoutMs Timeout in milliseconds (default: 10000) * @returns All jobs for the workflow * @throws Error if timeout or max pages reached */ async getWorkflowJobs({ workflowId, options = {}, }: { workflowId: string; options?: { maxPages?: number; timeoutMs?: number; }; }): Promise<Job[]> { const { maxPages = defaultPaginationOptions.maxPages, timeoutMs = defaultPaginationOptions.timeoutMs, } = options; const startTime = Date.now(); const allJobs: Job[] = []; let nextPageToken: string | null = null; let pageCount = 0; do { // Check timeout if (Date.now() - startTime > timeoutMs) { throw new Error(`Timeout reached after ${timeoutMs}ms`); } // Check page limit if (pageCount >= maxPages) { throw new Error(`Maximum number of pages (${maxPages}) reached`); } const params = nextPageToken ? { 'page-token': nextPageToken } : {}; const rawResult = await this.client.get<unknown>( `/workflow/${workflowId}/job`, params, ); // Validate the response against our WorkflowJobResponse schema const result = WorkflowJobResponseSchema.parse(rawResult); pageCount++; allJobs.push(...result.items); nextPageToken = result.next_page_token; } while (nextPageToken); return allJobs; } } ``` -------------------------------------------------------------------------------- /src/clients/circleci/httpClient.test.ts: -------------------------------------------------------------------------------- ```typescript import { HTTPClient } from './httpClient.js'; import { expect, vi, describe, it, beforeEach, afterEach } from 'vitest'; describe('HTTPClient', () => { let client: HTTPClient; const apiPath = '/api/v2'; const headers = { 'Content-Type': 'application/json' }; const defaultBaseURL = 'https://circleci.com'; const baseURL = defaultBaseURL + apiPath; beforeEach(() => { // Clear any environment variables before each test delete process.env.CIRCLECI_BASE_URL; client = new HTTPClient(defaultBaseURL, apiPath, { headers }); global.fetch = vi.fn(); }); afterEach(() => { vi.resetAllMocks(); // Clean up environment variables delete process.env.CIRCLECI_BASE_URL; }); describe('constructor', () => { it('should use default base URL when CIRCLECI_BASE_URL is not set', () => { const url = (client as any).buildURL('/test'); expect(url.toString()).toBe(`${defaultBaseURL}${apiPath}/test`); }); it('should use CIRCLECI_BASE_URL when set', () => { const customBaseURL = 'https://custom-circleci.example.com'; process.env.CIRCLECI_BASE_URL = customBaseURL; const customClient = new HTTPClient(customBaseURL, apiPath, { headers }); const url = (customClient as any).buildURL('/test'); expect(url.toString()).toBe(`${customBaseURL}${apiPath}/test`); }); }); describe('buildURL', () => { it('should build URL without params', () => { const path = '/test'; const url = (client as any).buildURL(path); expect(url.toString()).toBe(`${baseURL}${path}`); }); it('should build URL with simple params', () => { const path = '/test'; const params = { key: 'value' }; const url = (client as any).buildURL(path, params); expect(url.toString()).toBe(`${baseURL}${path}?key=value`); }); it('should handle array params', () => { const path = '/test'; const params = { arr: ['value1', 'value2'] }; const url = (client as any).buildURL(path, params); expect(url.toString()).toBe(`${baseURL}${path}?arr=value1&arr=value2`); }); }); describe('handleResponse', () => { it('should handle successful response', async () => { const mockData = { success: true }; const response = new Response(JSON.stringify(mockData), { status: 200 }); const result = await (client as any).handleResponse(response); expect(result).toEqual(mockData); }); it('should handle error response', async () => { const errorMessage = 'Not Found'; const response = new Response(JSON.stringify({ message: errorMessage }), { status: 404, }); await expect((client as any).handleResponse(response)).rejects.toThrow( 'CircleCI API Error', ); }); }); }); ``` -------------------------------------------------------------------------------- /src/tools/downloadUsageApiData/tool.ts: -------------------------------------------------------------------------------- ```typescript import { downloadUsageApiDataInputSchema } from './inputSchema.js'; export const downloadUsageApiDataTool = { name: 'download_usage_api_data' as const, description: ` ⚠️ **MANDATORY: The handler will REJECT any call that does not include BOTH outputDir and originalUserMessage. These parameters are REQUIRED for all tool calls.** ⚠️ **MANDATORY OUTPUT DIRECTORY SELECTION FOR AI AGENTS:** 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. 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. 3. Only if neither is available, use the current working directory (\`process.cwd()\`). 4. **Never omit the \`outputDir\` parameter. Always make the output location explicit.** 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.** 6. **AI agents MUST validate their tool calls to ensure \`outputDir\` is present before execution.** Downloads usage data from the CircleCI Usage API for a given organization and date range. This tool both starts the export job and downloads the resulting CSV file when ready. Required parameters: orgId, startDate, endDate, outputDir. **outputDir (required):** The directory where the usage data CSV will be saved. - You MUST provide \`outputDir\` for every tool call. - The file will be saved in the specified directory. - Omitting \`outputDir\` will result in an error. **Directory Selection Instructions for AI Agents:** - 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. - 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\`) - If neither is available, fall back to the current working directory. - Never place output files in a location that is hard to discover for the user. - **Always double-check that \`outputDir\` is present in your tool call.** - **Always double-check that \`originalUserMessage\` is present in your tool call.** 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. `, inputSchema: downloadUsageApiDataInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/runEvaluationTests/tool.ts: -------------------------------------------------------------------------------- ```typescript import { option1DescriptionBranchRequired, promptsOutputDirectory, } from '../shared/constants.js'; import { runEvaluationTestsInputSchema } from './inputSchema.js'; export const runEvaluationTestsTool = { name: 'run_evaluation_tests' as const, description: ` This tool allows the users to run evaluation tests on a circleci pipeline. They can be referred to as "Prompt Tests" or "Evaluation Tests". This tool triggers a new CircleCI pipeline and returns the URL to monitor its progress. The tool will generate an appropriate circleci configuration file and trigger a pipeline using this temporary configuration. The tool will return the project slug. Input options (EXACTLY ONE of these THREE options must be used): ${option1DescriptionBranchRequired} Option 2 - Direct URL (provide ONE of these): - projectURL: The URL of the CircleCI project in any of these formats: * Project URL with branch: https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123 * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/xyz Option 3 - Project Detection (ALL of these must be provided together): - workspaceRoot: The absolute path to the workspace root - gitRemoteURL: The URL of the git remote repository - branch: The name of the current branch Test Files: - promptFiles: Array of prompt template file objects from the ${promptsOutputDirectory} directory, each containing: * fileName: The name of the prompt template file * fileContent: The contents of the prompt template file Pipeline Selection: - If the project has multiple pipeline definitions, the tool will return a list of available pipelines - You must then make another call with the chosen pipeline name using the pipelineChoiceName parameter - The pipelineChoiceName must exactly match one of the pipeline names returned by the tool - If the project has only one pipeline definition, pipelineChoiceName is not needed Additional Requirements: - Never call this tool with incomplete parameters - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects - If using Option 2, the URLs MUST be provided by the user - do not attempt to construct or guess URLs - If using Option 3, ALL THREE parameters (workspaceRoot, gitRemoteURL, branch) must be provided - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call Returns: - A URL to the newly triggered pipeline that can be used to monitor its progress `, inputSchema: runEvaluationTestsInputSchema, }; ``` -------------------------------------------------------------------------------- /src/clients/circleci/deploys.ts: -------------------------------------------------------------------------------- ```typescript import { DeployComponentsResponse, DeployComponentVersionsResponse, DeployEnvironmentResponse, DeploySettingsResponse, RollbackProjectRequest, RollbackProjectResponse } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; export class DeploysAPI { protected client: HTTPClient; constructor(httpClient: HTTPClient) { this.client = httpClient; } async runRollbackPipeline({ projectID, rollbackRequest, }: { projectID: string; rollbackRequest: RollbackProjectRequest; }): Promise<RollbackProjectResponse> { const rawResult = await this.client.post<unknown>( `/projects/${projectID}/rollback`, rollbackRequest, ); const parsedResult = RollbackProjectResponse.safeParse(rawResult); if (!parsedResult.success) { throw new Error(`Failed to parse rollback response: ${parsedResult.error.message}`); } return parsedResult.data; } async fetchComponentVersions({ componentID, environmentID, }: { componentID: string; environmentID: string; }): Promise<DeployComponentVersionsResponse> { const rawResult = await this.client.get<unknown>( `/deploy/components/${componentID}/versions?environment-id=${environmentID}` ); const parsedResult = DeployComponentVersionsResponse.safeParse(rawResult); if (!parsedResult.success) { throw new Error(`Failed to parse component versions: ${parsedResult.error.message}`); } return parsedResult.data; } async fetchEnvironments({ orgID, }: { orgID: string; }): Promise<DeployEnvironmentResponse> { const rawResult = await this.client.get<unknown>( `/deploy/environments?org-id=${orgID}` ); const parsedResult = DeployEnvironmentResponse.safeParse(rawResult); if (!parsedResult.success) { throw new Error(`Failed to parse environments: ${parsedResult.error.message}`); } return parsedResult.data; } async fetchProjectComponents({ projectID, orgID, }: { projectID: string; orgID: string; }): Promise<DeployComponentsResponse> { const rawResult = await this.client.get<unknown>( `/deploy/components?org-id=${orgID}&project-id=${projectID}` ); const parsedResult = DeployComponentsResponse.safeParse(rawResult); if (!parsedResult.success) { throw new Error(`Failed to parse components: ${parsedResult.error.message}`); } return parsedResult.data; } async fetchProjectDeploySettings({ projectID, }: { projectID: string; }): Promise<DeploySettingsResponse> { const rawResult = await this.client.get<unknown>( `/deploy/projects/${projectID}/settings` ); const parsedResult = DeploySettingsResponse.safeParse(rawResult); if (!parsedResult.success) { throw new Error(`Failed to parse project deploy settings: ${parsedResult.error.message}`); } return parsedResult.data; } } ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript import js from '@eslint/js'; import * as tseslint from 'typescript-eslint'; import prettierConfig from 'eslint-config-prettier'; // @ts-check export default tseslint.config( { // Default configuration for all files ignores: ['dist/**', 'node_modules/**'], }, { // For JavaScript files including the config file files: ['**/*.js', '**/*.mjs'], extends: [js.configs.recommended], }, { // For TypeScript files (excluding tests) files: ['**/*.ts'], ignores: ['**/*.test.ts', '**/*.spec.ts'], extends: [...tseslint.configs.recommended, ...tseslint.configs.stylistic], languageOptions: { parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname, }, }, rules: { // No output to stdout that isn't MCP, allow stderr 'no-console': ['error', { allow: ['error'] }], '@typescript-eslint/consistent-type-definitions': ['error', 'type'], '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/non-nullable-type-assertion-style': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/restrict-template-expressions': [ 'error', { allowAny: true, allowBoolean: true, allowNullish: true, allowNumber: true, allowRegExp: true, }, ], }, }, { // For TypeScript test files files: ['**/*.test.ts', '**/*.spec.ts'], extends: [...tseslint.configs.recommended, ...tseslint.configs.stylistic], languageOptions: { parserOptions: { project: './tsconfig.test.json', tsconfigRootDir: import.meta.dirname, }, }, rules: { 'no-console': 'off', '@typescript-eslint/consistent-type-definitions': ['error', 'type'], '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/non-nullable-type-assertion-style': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/restrict-template-expressions': [ 'error', { allowAny: true, allowBoolean: true, allowNullish: true, allowNumber: true, allowRegExp: true, }, ], }, }, prettierConfig, ); ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG.yml: -------------------------------------------------------------------------------- ```yaml name: "\U0001F41E Bug Report" description: Report any identified bugs in the CircleCI MCP Server. title: 'Bug: ' labels: [bug] # assignees: '' body: - type: checkboxes attributes: label: 'Is there an existing issue for this?' 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' options: - label: 'I have searched the existing issues' required: true - type: textarea validations: required: true attributes: label: 'Current behavior' description: 'How does the issue manifest? What MCP tool or functionality is affected?' - type: input validations: required: true attributes: label: 'Minimum reproduction code' 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' placeholder: 'https://github.com/... or tool: Find the latest failed pipeline on my branch' - type: textarea attributes: label: 'Steps to reproduce' description: | Detail the steps to take to replicate the issue. Include the exact MCP tools used and any relevant context. placeholder: | 1. Set up CircleCI API token 2. Run MCP server with tool X 3. Try to execute tool Y 4. See error... - type: textarea validations: required: true attributes: label: 'Expected behavior' description: 'A clear and concise description of what you expected to happen' - type: markdown attributes: value: | --- - type: input attributes: label: 'MCP Server CircleCI version' description: | Which version of `@circleci/mcp-server-circleci` are you using? placeholder: '0.1.0' - type: input attributes: label: 'Node.js version' description: 'Which version of Node.js are you using? Note: This project requires Node.js >= v18.0.0' placeholder: '18.0.0' - type: input attributes: label: 'CircleCI API Token' description: 'Do you have a valid CircleCI API token configured? (Do not share the actual token)' placeholder: 'Yes/No' - type: checkboxes attributes: label: 'In which agents have you tested?' options: - label: Cursor - label: Windsurf - label: Claude Code - label: Other - type: markdown attributes: value: | --- - type: textarea attributes: label: 'Additional context' description: | Anything else relevant? eg: - Error logs - OS version - IDE/Editor being used - Package manager (pnpm version) - MCP Client details (e.g., Cursor version) **Tip:** You can attach images, recordings or log files by clicking this area to highlight it and then dragging files in. ``` -------------------------------------------------------------------------------- /src/lib/pipeline-job-tests/getJobTests.ts: -------------------------------------------------------------------------------- ```typescript import { getCircleCIClient } from '../../clients/client.js'; import { Pipeline } from '../../clients/schemas.js'; import { rateLimitedRequests } from '../rateLimitedRequests/index.js'; /** * Retrieves test metadata for a specific job or all jobs in the latest pipeline * * @param {Object} params - The parameters for the job tests retrieval * @param {string} params.projectSlug - The slug of the CircleCI project * @param {number} [params.pipelineNumber] - The pipeline number to fetch tests for * @param {number} [params.jobNumber] - The job number to fetch tests for * @param {string} [params.branch] - The branch to fetch tests for * @param {string} [params.filterByTestsResult] - The result of the tests to filter by */ export const getJobTests = async ({ projectSlug, pipelineNumber, jobNumber, branch, filterByTestsResult, }: { projectSlug: string; pipelineNumber?: number; jobNumber?: number; branch?: string; filterByTestsResult?: 'failure' | 'success'; }) => { const circleci = getCircleCIClient(); let pipeline: Pipeline | undefined; // If jobNumber is provided, fetch the tests for the specific job if (jobNumber) { const tests = await circleci.tests.getJobTests({ projectSlug, jobNumber, }); if (!filterByTestsResult) { return tests; } return tests.filter((test) => test.result === filterByTestsResult); } if (pipelineNumber) { pipeline = await circleci.pipelines.getPipelineByNumber({ projectSlug, pipelineNumber, }); } // If pipelineNumber is not provided, fetch the tests for the latest pipeline if (!pipeline) { const pipelines = await circleci.pipelines.getPipelines({ projectSlug, branch, }); pipeline = pipelines?.[0]; if (!pipeline) { throw new Error('Pipeline not found'); } } const workflows = await circleci.workflows.getPipelineWorkflows({ pipelineId: pipeline.id, }); const jobs = ( await Promise.all( workflows.map(async (workflow) => { return await circleci.jobs.getWorkflowJobs({ workflowId: workflow.id, }); }), ) ).flat(); const testsArrays = await rateLimitedRequests( jobs.map((job) => async () => { if (!job.job_number) { console.error(`Job ${job.id} has no job number`); return []; } try { const tests = await circleci.tests.getJobTests({ projectSlug, jobNumber: job.job_number, }); return tests; } catch (error) { if (error instanceof Error && error.message.includes('404')) { console.error(`Job ${job.job_number} not found:`, error); return []; } if (error instanceof Error && error.message.includes('429')) { console.error( `Rate limited for job request ${job.job_number}:`, error, ); return []; } throw error; } }), ); const tests = testsArrays.flat(); if (!filterByTestsResult) { return tests; } return tests.filter((test) => test.result === filterByTestsResult); }; ``` -------------------------------------------------------------------------------- /src/tools/runRollbackPipeline/handler.ts: -------------------------------------------------------------------------------- ```typescript import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'; import { runRollbackPipelineInputSchema } from './inputSchema.js'; import mcpErrorOutput from '../../lib/mcpErrorOutput.js'; import { getCircleCIClient } from '../../clients/client.js'; export const runRollbackPipeline: ToolCallback<{ params: typeof runRollbackPipelineInputSchema; }> = async (args: any) => { const { projectSlug, projectID: providedProjectID, environmentName, componentName, currentVersion, targetVersion, namespace, reason, parameters, } = args.params ?? {}; // Init the client and get the base URL const circleci = getCircleCIClient(); // Resolve project ID from projectSlug or use provided projectID let projectID: string; try { if (providedProjectID) { projectID = providedProjectID; } else if (projectSlug) { const { id: resolvedProjectId } = await circleci.projects.getProject({ projectSlug, }); projectID = resolvedProjectId; } else { return mcpErrorOutput('Either projectSlug or projectID must be provided'); } } catch (error) { const errorMessage = projectSlug ? `Failed to resolve project information for ${projectSlug}. Please verify the project slug is correct.` : `Failed to resolve project information for project ID ${providedProjectID}. Please verify the project ID is correct.`; return mcpErrorOutput(`${errorMessage} ${error instanceof Error ? error.message : 'Unknown error'}`); } // First, check if the project has a rollback pipeline definition configured try { const deploySettings = await circleci.deploys.fetchProjectDeploySettings({ projectID, }); if (!deploySettings.rollback_pipeline_definition_id) { return { content: [ { type: 'text', 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.', }, ], }; } } catch (error) { return mcpErrorOutput( `Failed to fetch rollback pipeline definition: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } // Check if this is a new rollback request with required fields const rollbackRequest = { environment_name: environmentName, component_name: componentName, current_version: currentVersion, target_version: targetVersion, ...(namespace && { namespace }), ...(reason && { reason }), ...(parameters && { parameters }), }; try { const rollbackResponse = await circleci.deploys.runRollbackPipeline({ projectID, rollbackRequest, }); return { content: [ { type: 'text', text: `Rollback initiated successfully. ID: ${rollbackResponse.id}, Type: ${rollbackResponse.rollback_type}`, }, ], }; } catch (error) { return mcpErrorOutput( `Failed to initiate rollback: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } }; ``` -------------------------------------------------------------------------------- /src/tools/getJobTestResults/tool.ts: -------------------------------------------------------------------------------- ```typescript import { getJobTestResultsInputSchema } from './inputSchema.js'; import { option1DescriptionBranchRequired } from '../shared/constants.js'; export const getJobTestResultsTool = { name: 'get_job_test_results' as const, description: ` This tool retrieves test metadata for a CircleCI job. PRIORITY USE CASE: - When asked "are tests passing in CI?" or similar questions about test status - When asked to "fix failed tests in CI" or help with CI test failures - Use this tool to check if tests are passing in CircleCI and identify failed tests Common use cases: - Get test metadata for a specific job - Get test metadata for all jobs in a project - Get test metadata for a specific branch - Get test metadata for a specific pipeline - Get test metadata for a specific workflow - Get test metadata for a specific job CRITICAL REQUIREMENTS: 1. Truncation Handling (HIGHEST PRIORITY): - ALWAYS check for <MCPTruncationWarning> in the output - When present, you MUST start your response with: "WARNING: The test results have been truncated. Only showing the most recent entries. Some test data may not be visible." - Only proceed with test result analysis after acknowledging the truncation 2. Test Result Filtering: - Use filterByTestsResult parameter to filter test results: * filterByTestsResult: 'failure' - Show only failed tests * filterByTestsResult: 'success' - Show only successful tests - When looking for failed tests, ALWAYS set filterByTestsResult to 'failure' - When checking if tests are passing, set filterByTestsResult to 'success' Input options (EXACTLY ONE of these THREE options must be used): ${option1DescriptionBranchRequired} Option 2 - Direct URL (provide ONE of these): - projectURL: The URL of the CircleCI job in any of these formats: * Job URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def/jobs/789 * Workflow URL: https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc-def * Pipeline URL: https://app.circleci.com/pipelines/gh/organization/project/123 Option 3 - Project Detection (ALL of these must be provided together): - workspaceRoot: The absolute path to the workspace root - gitRemoteURL: The URL of the git remote repository - branch: The name of the current branch 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. Additional Requirements: - Never call this tool with incomplete parameters - If using Option 1, make sure to extract the projectSlug exactly as provided by listFollowedProjects and include the branch parameter - If using Option 2, the URL MUST be provided by the user - do not attempt to construct or guess URLs - If using Option 3, ALL THREE parameters (workspaceRoot, gitRemoteURL, branch) must be provided - If none of the options can be fully satisfied, ask the user for the missing information before making the tool call `, inputSchema: getJobTestResultsInputSchema, }; ``` -------------------------------------------------------------------------------- /src/tools/listFollowedProjects/handler.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { listFollowedProjects } from './handler.js'; import * as clientModule from '../../clients/client.js'; vi.mock('../../clients/client.js'); describe('listFollowedProjects handler', () => { const mockCircleCIPrivateClient = { me: { getFollowedProjects: vi.fn(), }, }; beforeEach(() => { vi.resetAllMocks(); vi.spyOn(clientModule, 'getCircleCIPrivateClient').mockReturnValue( mockCircleCIPrivateClient as any, ); }); it('should return an error when no projects are found', async () => { mockCircleCIPrivateClient.me.getFollowedProjects.mockResolvedValue({ projects: [], reachedMaxPagesOrTimeout: false, }); const args = { params: {} } as any; const controller = new AbortController(); const response = await listFollowedProjects(args, { signal: controller.signal, }); expect(response).toHaveProperty('content'); expect(response).toHaveProperty('isError', true); expect(Array.isArray(response.content)).toBe(true); expect(response.content[0]).toHaveProperty('type', 'text'); expect(typeof response.content[0].text).toBe('string'); expect(response.content[0].text).toContain('No projects found'); }); it('should return a list of followed projects', async () => { const mockProjects = [ { name: 'Project 1', slug: 'gh/org/project1' }, { name: 'Project 2', slug: 'gh/org/project2' }, ]; mockCircleCIPrivateClient.me.getFollowedProjects.mockResolvedValue({ projects: mockProjects, reachedMaxPagesOrTimeout: false, }); const args = { params: {} } as any; const controller = new AbortController(); const response = await listFollowedProjects(args, { signal: controller.signal, }); expect(response).toHaveProperty('content'); expect(Array.isArray(response.content)).toBe(true); expect(response.content[0]).toHaveProperty('type', 'text'); expect(typeof response.content[0].text).toBe('string'); expect(response.content[0].text).toContain('Projects followed:'); expect(response.content[0].text).toContain('Project 1'); expect(response.content[0].text).toContain('gh/org/project1'); expect(response.content[0].text).toContain('Project 2'); expect(response.content[0].text).toContain('gh/org/project2'); }); it('should add a warning when not all projects were included', async () => { const mockProjects = [{ name: 'Project 1', slug: 'gh/org/project1' }]; mockCircleCIPrivateClient.me.getFollowedProjects.mockResolvedValue({ projects: mockProjects, reachedMaxPagesOrTimeout: true, }); const args = { params: {} } as any; const controller = new AbortController(); const response = await listFollowedProjects(args, { signal: controller.signal, }); expect(response).toHaveProperty('content'); expect(Array.isArray(response.content)).toBe(true); expect(response.content[0]).toHaveProperty('type', 'text'); expect(typeof response.content[0].text).toBe('string'); expect(response.content[0].text).toContain( 'WARNING: Not all projects were included', ); expect(response.content[0].text).toContain('Project 1'); expect(response.content[0].text).toContain('gh/org/project1'); }); }); ``` -------------------------------------------------------------------------------- /src/clients/circleci/workflows.ts: -------------------------------------------------------------------------------- ```typescript import { Workflow, RerunWorkflow } from '../schemas.js'; import { HTTPClient } from './httpClient.js'; import { defaultPaginationOptions } from './index.js'; import { z } from 'zod'; const WorkflowResponseSchema = z.object({ items: z.array(Workflow), next_page_token: z.string().nullable(), }); export class WorkflowsAPI { protected client: HTTPClient; constructor(httpClient: HTTPClient) { this.client = httpClient; } /** * Get all workflows for a pipeline with pagination support * @param params Configuration parameters * @param params.pipelineId The pipeline ID * @param params.options Optional configuration for pagination limits * @param params.options.maxPages Maximum number of pages to fetch (default: 5) * @param params.options.timeoutMs Timeout in milliseconds (default: 10000) * @returns All workflows from the pipeline * @throws Error if timeout or max pages reached */ async getPipelineWorkflows({ pipelineId, options = {}, }: { pipelineId: string; options?: { maxPages?: number; timeoutMs?: number; }; }): Promise<Workflow[]> { const { maxPages = defaultPaginationOptions.maxPages, timeoutMs = defaultPaginationOptions.timeoutMs, } = options; const startTime = Date.now(); const allWorkflows: Workflow[] = []; let nextPageToken: string | null = null; let pageCount = 0; do { // Check timeout if (Date.now() - startTime > timeoutMs) { throw new Error(`Timeout reached after ${timeoutMs}ms`); } // Check page limit if (pageCount >= maxPages) { throw new Error(`Maximum number of pages (${maxPages}) reached`); } const params = nextPageToken ? { 'page-token': nextPageToken } : {}; const rawResult = await this.client.get<unknown>( `/pipeline/${pipelineId}/workflow`, params, ); // Validate the response against our WorkflowResponse schema const result = WorkflowResponseSchema.parse(rawResult); pageCount++; allWorkflows.push(...result.items); nextPageToken = result.next_page_token; } while (nextPageToken); return allWorkflows; } /** * Get a workflow * @param workflowId The workflowId * @returns Information about the workflow * @throws Error if the request fails */ async getWorkflow({ workflowId }: { workflowId: string }): Promise<Workflow> { const rawResult = await this.client.get<unknown>(`/workflow/${workflowId}`); const parsedResult = Workflow.safeParse(rawResult); if (!parsedResult.success) { console.error('Parse error:', parsedResult.error); throw new Error('Failed to parse workflow response'); } return parsedResult.data; } /** * Rerun workflow from failed job or start * @param workflowId The workflowId * @param fromFailed Whether to rerun from failed job or start * @returns A new workflowId * @throws Error if the request fails */ async rerunWorkflow({ workflowId, fromFailed, }: { workflowId: string; fromFailed?: boolean; }): Promise<RerunWorkflow> { const rawResult = await this.client.post<unknown>( `/workflow/${workflowId}/rerun`, { from_failed: fromFailed, }, ); const parsedResult = RerunWorkflow.safeParse(rawResult); if (!parsedResult.success) { throw new Error('Failed to parse workflow response'); } return parsedResult.data; } } ```