├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── eslint.config.mjs ├── glama.json ├── package-lock.json ├── package.json ├── src ├── cmd │ └── cmd.ts ├── index.ts ├── logging │ └── logging.ts ├── pulumi │ ├── cli.ts │ └── registry.ts └── server │ ├── server.ts │ └── transport.ts ├── test ├── .cache │ └── test_schema.json └── registry.test.ts ├── tsconfig.json └── tsup.config.ts /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | permissions: write-all # Equivalent to default permissions plus id-token: write 2 | env: 3 | ESC_ACTION_OIDC_AUTH: true 4 | ESC_ACTION_OIDC_ORGANIZATION: pulumi 5 | ESC_ACTION_OIDC_REQUESTED_TOKEN_TYPE: urn:pulumi:token-type:access_token:organization 6 | ESC_ACTION_ENVIRONMENT: imports/github-secrets 7 | ESC_ACTION_EXPORT_ENVIRONMENT_VARIABLES: false 8 | name: Publish Package to npmjs 9 | 10 | on: 11 | push: 12 | tags: 13 | - 'v*.*.*' # Trigger on tags like v1.0.0, v2.3.4-beta.1 etc. 14 | 15 | jobs: 16 | publish: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Fetch secrets from ESC 20 | id: esc-secrets 21 | uses: pulumi/esc-action@v1 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: 'npm' 30 | registry-url: 'https://registry.npmjs.org' # Needed for npm publish and auth setup 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Build package 36 | run: make build 37 | 38 | - name: Run tests 39 | run: make test 40 | 41 | - name: Check Version Consistency 42 | id: check_version 43 | run: | 44 | PACKAGE_VERSION=$(jq -r .version package.json) 45 | TAG_NAME="${{ github.ref_name }}" 46 | echo "Tag name: $TAG_NAME" 47 | echo "Package version: $PACKAGE_VERSION" 48 | if [ "$TAG_NAME" != "v$PACKAGE_VERSION" ]; then 49 | echo "Error: Tag ($TAG_NAME) does not match the version in package.json (v$PACKAGE_VERSION)." 50 | exit 1 51 | fi 52 | echo "Version check passed." 53 | 54 | - name: Publish to npmjs 55 | run: npm publish 56 | env: 57 | NODE_AUTH_TOKEN: ${{ steps.esc-secrets.outputs.NPM_TOKEN }} # Use the secret token for authentication 58 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [22.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Run lint 30 | run: npm run lint 31 | 32 | - name: Build 33 | run: npm run build 34 | 35 | - name: Run tests 36 | run: npm test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | dist 83 | 84 | # vitepress build output 85 | **/.vitepress/dist 86 | 87 | # vitepress cache directory 88 | **/.vitepress/cache 89 | 90 | # Docusaurus cache and generated files 91 | .docusaurus 92 | 93 | # Serverless directories 94 | .serverless/ 95 | 96 | # FuseBox cache 97 | .fusebox/ 98 | 99 | # DynamoDB Local files 100 | .dynamodb/ 101 | 102 | # TernJS port file 103 | .tern-port 104 | 105 | # Stores VSCode versions used for testing VSCode extensions 106 | .vscode-test 107 | 108 | # yarn v2 109 | .yarn/cache 110 | .yarn/unplugged 111 | .yarn/build-state.yml 112 | .yarn/install-state.gz 113 | .pnp.* 114 | 115 | # Schema files 116 | *-schema.json 117 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | dist/.cache 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "jsxSingleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build stage 2 | FROM node:22-alpine AS builder 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache curl 6 | 7 | # Create app directory 8 | WORKDIR /app 9 | 10 | # Copy package files 11 | COPY package*.json ./ 12 | COPY tsup.config.ts ./ 13 | 14 | # Install dependencies 15 | RUN npm ci 16 | 17 | # Copy source code 18 | COPY tsconfig.json ./ 19 | COPY src/ ./src/ 20 | 21 | # Build the TypeScript code 22 | RUN npm run build 23 | 24 | # Stage 2: Production stage 25 | FROM node:22-alpine 26 | 27 | # Install Pulumi CLI 28 | RUN apk add --no-cache curl unzip \ 29 | && curl -fsSL https://get.pulumi.com | sh \ 30 | && mv /root/.pulumi/bin/pulumi /usr/local/bin/ \ 31 | && rm -rf /root/.pulumi \ 32 | && apk del curl unzip 33 | 34 | # Create app directory 35 | WORKDIR /app 36 | 37 | # Create a non-root user 38 | RUN addgroup -S appgroup && adduser -S appuser -G appgroup 39 | 40 | # Copy built files from builder stage 41 | COPY --from=builder /app/package*.json ./ 42 | COPY --from=builder /app/dist/ ./dist/ 43 | 44 | # Install production dependencies only 45 | RUN npm ci --omit=dev && npm cache clean --force 46 | 47 | # Set proper permissions 48 | RUN chown -R appuser:appgroup /app 49 | 50 | # Switch to non-root user 51 | USER appuser 52 | 53 | # Set environment variables 54 | ENV NODE_ENV=production 55 | 56 | EXPOSE 3000 57 | 58 | # Command to run the application 59 | ENTRYPOINT ["node", "dist/index.js"] 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Get version from package.json 2 | VERSION := $(shell jq -r .version < package.json) 3 | 4 | .PHONY: ensure 5 | ensure: 6 | @echo "Ensuring..." 7 | npm install 8 | 9 | .PHONY: build 10 | build: 11 | @echo "Building..." 12 | npm run build 13 | 14 | .PHONY: test 15 | test: 16 | @echo "Testing..." 17 | npm test 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pulumi MCP Server 2 | 3 | 4 | 5 | 6 | 7 | > **Note:** This MCP server is currently under active development. Its API (including available commands and their arguments) is experimental and may introduce breaking changes without notice. Please file an issue on [GitHub](https://github.com/pulumi/mcp-server/issues) if you encounter bugs or need support for additional Pulumi commands. 8 | 9 | A server implementing the [Model Context Protocol](https://modelcontextprotocol.io) (MCP) for interacting with Pulumi CLI using the Pulumi Automation API and Pulumi Cloud API. 10 | 11 | This package allows MCP clients to perform Pulumi operations like retrieving package information, previewing changes, deploying updates, and retrieving stack outputs programmatically without needing the Pulumi CLI installed directly in the client environment. 12 | 13 | ## Usage 14 | 15 | The Pulumi CLI has to be installed on you machine. 16 | 17 | This package is primarily intended to be integrated into applications that can use MCP servers as AI tools. For example, here is how you can include Pulumi MCP Server in Claude desktop's MCP configuration file: 18 | 19 | ```json 20 | { 21 | "mcpServers": { 22 | "pulumi": { 23 | "command": "npx", 24 | "args": ["@pulumi/mcp-server@latest","stdio"] 25 | } 26 | } 27 | } 28 | ``` 29 | 30 | Or if you prefer HTTP with Server-Sent Events (SSE) instead of `stdio`: 31 | 32 | ```json 33 | { 34 | "mcpServers": { 35 | "pulumi": { 36 | "command": "npx", 37 | "args": ["@pulumi/mcp-server@latest","sse"] 38 | } 39 | } 40 | } 41 | ``` 42 | 43 | ## Docker Container 44 | 45 | You can also run the Pulumi MCP Server as a Docker container. This approach eliminates the need to install Node.js and the package dependencies directly on your host machine. 46 | 47 | ### Building the Container 48 | 49 | To build the container: 50 | 51 | ```bash 52 | docker build -t pulumi/mcp-server:latest . 53 | ``` 54 | 55 | ### Using with MCP Clients 56 | 57 | To use the containerized server with MCP clients, you'll need to configure the client to use the Docker container. For example, in Claude desktop's MCP configuration: 58 | 59 | ```json 60 | { 61 | "mcpServers": { 62 | "pulumi": { 63 | "command": "docker", 64 | "args": ["run", "-i", "--rm", "pulumi/mcp-server:latest", "stdio"] 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | ### Using with MCP Clients over HTTP (SSE) 71 | 72 | To use the containerized server with MCP clients over HTTP (SSE), you can run the container with the following command: 73 | 74 | ```json 75 | { 76 | "mcpServers": { 77 | "pulumi": { 78 | "command": "docker", 79 | "args": ["run", "-i", "--rm", "-p", "3000:3000", "pulumi/mcp-server:latest", "sse"] 80 | } 81 | } 82 | } 83 | ``` 84 | 85 | 86 | 87 | For Pulumi operations that require access to local Pulumi projects, you'll need to mount the appropriate directories. For example, if your Pulumi project is in `~/projects/my-pulumi-app`: 88 | 89 | ```json 90 | { 91 | "mcpServers": { 92 | "pulumi": { 93 | "command": "docker", 94 | "args": ["run", "-i", "--rm", "-v", "~/projects/my-pulumi-app:/app/project", "pulumi/mcp-server:latest"] 95 | } 96 | } 97 | } 98 | ``` 99 | 100 | Then when using the MCP tools, you would reference the project directory as `/app/project` in your requests. 101 | 102 | ## Available Commands 103 | 104 | The server exposes handlers for the following Pulumi operations, callable via MCP requests: 105 | 106 | * **`preview`**: Runs `pulumi preview` on a specified stack. 107 | * `workDir` (string, required): The working directory containing the `Pulumi.yaml` project file. 108 | * `stackName` (string, optional): The stack name to operate on (defaults to 'dev'). 109 | * **`up`**: Runs `pulumi up` to deploy changes for a specified stack. 110 | * `workDir` (string, required): The working directory containing the `Pulumi.yaml` project file. 111 | * `stackName` (string, optional): The stack name to operate on (defaults to 'dev'). 112 | * **`stack-output`**: Retrieves outputs from a specified stack after a successful deployment. 113 | * `workDir` (string, required): The working directory containing the `Pulumi.yaml` project file. 114 | * `stackName` (string, optional): The stack name to retrieve outputs from (defaults to 'dev'). 115 | * `outputName` (string, optional): The specific stack output name to retrieve. If omitted, all outputs for the stack are returned. 116 | * **`get-resource`**: Returns information about a specific Pulumi Registry resource, including its inputs and outputs. 117 | * `provider` (string, required): The cloud provider (e.g., 'aws', 'azure', 'gcp', 'random') or `github.com/org/repo` for Git-hosted components. 118 | * `module` (string, optional): The module to query (e.g., 's3', 'ec2', 'lambda'). 119 | * `resource` (string, required): The resource type name (e.g., 'Bucket', 'Function', 'Instance'). 120 | * **`list-resources`**: Lists available resources within a Pulumi provider package, optionally filtered by module. 121 | * `provider` (string, required): The cloud provider (e.g., 'aws', 'azure', 'gcp', 'random') or `github.com/org/repo` for Git-hosted components. 122 | * `module` (string, optional): The module to filter by (e.g., 's3', 'ec2', 'lambda'). 123 | 124 | ## Development 125 | 126 | 1. Clone the repository. 127 | 2. Install dependencies: `make ensure` 128 | 3. Build the project: `make build` 129 | 4. Test the project: `make test` 130 | 131 | ## License 132 | 133 | This project is licensed under the Apache-2.0 License. See the [LICENSE](LICENSE) file for details. 134 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import prettierConfig from 'eslint-config-prettier'; 6 | import eslintPluginPrettier from 'eslint-plugin-prettier/recommended'; 7 | 8 | export default tseslint.config( 9 | eslint.configs.recommended, 10 | tseslint.configs.recommended, 11 | prettierConfig, 12 | eslintPluginPrettier, 13 | { 14 | ignores: ['node_modules/**', 'dist/**'] 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /glama.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://glama.ai/mcp/schemas/server.json", 3 | "maintainers": [ 4 | "dirien", 5 | "mikhailshilkov" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pulumi/mcp-server", 3 | "version": "0.1.2", 4 | "description": "A server implementing the Model Context Protocol for Pulumi.", 5 | "author": "Pulumi Corporation", 6 | "license": "Apache-2.0", 7 | "homepage": "https://github.com/pulumi/mcp-server#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/pulumi/mcp-server.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/pulumi/mcp-server/issues" 14 | }, 15 | "type": "module", 16 | "bin": { 17 | "mcp-server": "dist/index.js" 18 | }, 19 | "main": "dist/index.js", 20 | "types": "dist/index.d.ts", 21 | "files": [ 22 | "dist", 23 | "README.md", 24 | "LICENSE" 25 | ], 26 | "scripts": { 27 | "lint": "eslint src/**/*.ts --no-warn-ignored", 28 | "lint:fix": "eslint src/**/*.ts --fix", 29 | "dev": "tsx watch src/index.ts stdio", 30 | "build": "tsup", 31 | "build:watch": "tsup --watch", 32 | "test": "NODE_OPTIONS='--loader ts-node/esm' mocha 'test/**/*.test.ts'", 33 | "test:watch": "NODE_OPTIONS='--loader ts-node/esm' mocha --watch --watch-files test 'test/**/*.test.ts'", 34 | "inspector": " npx @modelcontextprotocol/inspector dist/index.js" 35 | }, 36 | "dependencies": { 37 | "@modelcontextprotocol/sdk": "^1.11.1", 38 | "@pulumi/pulumi": "^3.169.0", 39 | "express": "^5.1.0", 40 | "pino": "^9.6.0", 41 | "zod": "^3.24.4", 42 | "yargs": "^17.7.2" 43 | }, 44 | "devDependencies": { 45 | "@types/chai": "^4.3.11", 46 | "@types/mocha": "^10.0.6", 47 | "@types/express": "^5.0.1", 48 | "@types/node": "^22.14.1", 49 | "@types/yargs": "^17.0.33", 50 | "chai": "^5.1.0", 51 | "mocha": "^10.3.0", 52 | "ts-node": "^10.9.2", 53 | "typescript": "^5.3.0", 54 | "eslint": "^9.26.0", 55 | "tsup": "^8.4.0", 56 | "eslint-config-prettier": "^10.1.5", 57 | "eslint-plugin-prettier": "^5.4.0", 58 | "prettier": "^3.5.3", 59 | "@eslint/js": "^9.26.0", 60 | "typescript-eslint": "^8.32.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/cmd/cmd.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs'; 2 | import { hideBin } from 'yargs/helpers'; 3 | import { connectSSETransport, connectStdioTransport } from '../server/transport.js'; 4 | 5 | export const cmd = () => { 6 | const exe = yargs(hideBin(process.argv)); 7 | 8 | exe.command( 9 | 'stdio', 10 | 'Start Pulumi MCP server using stdio transport.', 11 | () => {}, 12 | () => connectStdioTransport() 13 | ); 14 | 15 | exe.command( 16 | 'sse', 17 | 'Start Pulumi MCP server using SSE transport.', 18 | (yargs) => { 19 | return yargs.option('port', { 20 | type: 'number', 21 | default: 3000 22 | }); 23 | }, 24 | ({ port }) => connectSSETransport(port) 25 | ); 26 | 27 | exe.demandCommand().parseSync(); 28 | }; 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { cmd } from './cmd/cmd.js'; 2 | import { logger } from './logging/logging.js'; 3 | 4 | const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGQUIT']; 5 | 6 | // Handle termination signals 7 | signals.forEach((signal) => { 8 | process.on(signal, async () => { 9 | logger.info('Shutting down Pulumi MCP server...'); 10 | process.exit(1); 11 | }); 12 | }); 13 | 14 | cmd(); 15 | -------------------------------------------------------------------------------- /src/logging/logging.ts: -------------------------------------------------------------------------------- 1 | import { pino } from 'pino'; 2 | import { stderr } from 'process'; 3 | 4 | export const logger = pino(pino.destination(stderr)); 5 | -------------------------------------------------------------------------------- /src/pulumi/cli.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import * as automation from '@pulumi/pulumi/automation/index.js'; 3 | 4 | type PreviewArgs = { 5 | workDir: string; 6 | stackName?: string; 7 | }; 8 | 9 | type UpArgs = { 10 | workDir: string; 11 | stackName?: string; 12 | }; 13 | 14 | export const cliCommands = { 15 | preview: { 16 | description: 'Run pulumi preview for a given project and stack', 17 | schema: { 18 | workDir: z.string().describe('The working directory of the program.'), 19 | stackName: z.string().optional().describe("The associated stack name. Defaults to 'dev'.") 20 | }, 21 | handler: async (args: PreviewArgs) => { 22 | const stackArgs: automation.LocalProgramArgs = { 23 | stackName: args.stackName ?? 'dev', 24 | workDir: args.workDir 25 | }; 26 | 27 | const stack = await automation.LocalWorkspace.createOrSelectStack(stackArgs); 28 | 29 | // Run preview 30 | const previewResult = await stack.preview({ diff: true }); 31 | 32 | // Format the changes 33 | const changes = previewResult.changeSummary; 34 | const changesSummary = [ 35 | `Create: ${changes.create}`, 36 | `Update: ${changes.update}`, 37 | `Delete: ${changes.delete}`, 38 | `Same: ${changes.same}` 39 | ].join('\n'); 40 | 41 | return { 42 | description: 'Pulumi Preview Results', 43 | content: [ 44 | { 45 | type: 'text' as const, 46 | text: ` 47 | Preview Results for stack: ${stack.name} 48 | 49 | Changes: 50 | ${changesSummary} 51 | 52 | ${previewResult.stdout || 'No additional output'} 53 | ` 54 | } 55 | ] 56 | }; 57 | } 58 | }, 59 | 60 | up: { 61 | description: 'Run pulumi up for a given project and stack', 62 | schema: { 63 | workDir: z.string().describe('The working directory of the program.'), 64 | stackName: z.string().optional().describe("The associated stack name. Defaults to 'dev'.") 65 | }, 66 | handler: async (args: UpArgs) => { 67 | const stackArgs: automation.LocalProgramArgs = { 68 | stackName: args.stackName ?? 'dev', 69 | workDir: args.workDir 70 | }; 71 | 72 | const stack = await automation.LocalWorkspace.createOrSelectStack(stackArgs); 73 | 74 | // Run up 75 | const upResult = await stack.up(); 76 | 77 | // Format the changes 78 | const changes = upResult.summary.resourceChanges!; 79 | const changesSummary = [ 80 | `Create: ${changes.create}`, 81 | `Update: ${changes.update}`, 82 | `Delete: ${changes.delete}`, 83 | `Same: ${changes.same}` 84 | ].join('\n'); 85 | 86 | return { 87 | description: 'Pulumi Up Results', 88 | content: [ 89 | { 90 | type: 'text' as const, 91 | text: ` 92 | Deployment Results for stack: ${stack.name} 93 | 94 | Changes: 95 | ${changesSummary} 96 | 97 | ${upResult.stdout || 'No additional output'} 98 | ` 99 | } 100 | ] 101 | }; 102 | } 103 | }, 104 | 105 | 'stack-output': { 106 | description: 'Get the output value(s) of a given stack', 107 | schema: { 108 | workDir: z.string().describe('The working directory of the program.'), 109 | stackName: z.string().optional().describe("The associated stack name. Defaults to 'dev'."), 110 | outputName: z.string().optional().describe('The specific stack output name to retrieve.') 111 | }, 112 | handler: async (args: { workDir: string; stackName?: string; outputName?: string }) => { 113 | const stackArgs: automation.LocalProgramArgs = { 114 | stackName: args.stackName ?? 'dev', 115 | workDir: args.workDir 116 | }; 117 | 118 | const stack = await automation.LocalWorkspace.selectStack(stackArgs); 119 | 120 | // Get stack outputs 121 | const outputs = await stack.outputs(); 122 | 123 | let description: string; 124 | let outputContent: string; 125 | 126 | if (args.outputName) { 127 | // Return a specific output 128 | const specificOutput = outputs[args.outputName]; 129 | if (specificOutput) { 130 | description = `Pulumi Stack Output: ${args.outputName}`; 131 | outputContent = `${args.outputName}: ${JSON.stringify(specificOutput.value)}`; 132 | } else { 133 | description = `Pulumi Stack Output: ${args.outputName}`; 134 | outputContent = `Output '${args.outputName}' not found.`; 135 | } 136 | } else { 137 | // Return all outputs 138 | description = 'Pulumi Stack Outputs'; 139 | outputContent = Object.entries(outputs) 140 | .map(([key, value]) => `${key}: ${JSON.stringify(value.value)}`) 141 | .join('\\n'); 142 | if (!outputContent) { 143 | outputContent = 'No outputs found'; 144 | } 145 | } 146 | 147 | return { 148 | description: description, 149 | content: [ 150 | { 151 | type: 'text' as const, 152 | text: ` 153 | Stack: ${stack.name} 154 | 155 | ${outputContent} 156 | ` 157 | } 158 | ] 159 | }; 160 | } 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /src/pulumi/registry.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import * as fs from 'node:fs'; 3 | import * as path from 'node:path'; 4 | import { execSync } from 'node:child_process'; 5 | 6 | // Define schema types 7 | type ResourceProperty = { 8 | type: string; 9 | description: string; 10 | }; 11 | 12 | type ResourceSchema = { 13 | description: string; 14 | properties: Record; 15 | required: string[]; 16 | inputProperties: Record; 17 | requiredInputs: string[]; 18 | }; 19 | 20 | type Schema = { 21 | name: string; 22 | resources: Record; 23 | }; 24 | 25 | type GetResourceArgs = { 26 | provider: string; 27 | module?: string; 28 | resource: string; 29 | }; 30 | 31 | type ListResourcesArgs = { 32 | provider: string; 33 | module?: string; 34 | }; 35 | 36 | export const registryCommands = function (cacheDir: string) { 37 | // Function to get schema with caching 38 | async function getSchema(provider: string): Promise { 39 | const cacheFile = path.join(cacheDir, `${provider.replace(/[^a-zA-Z0-9]/g, '_')}_schema.json`); 40 | 41 | if (!fs.existsSync(cacheFile)) { 42 | execSync(`pulumi package get-schema ${provider} >> ${cacheFile}`); 43 | } 44 | return JSON.parse(fs.readFileSync(cacheFile, 'utf-8')); 45 | } 46 | 47 | return { 48 | 'get-resource': { 49 | description: 'Get information about a specific resource from the Pulumi Registry', 50 | schema: { 51 | provider: z 52 | .string() 53 | .describe( 54 | "The cloud provider (e.g., 'aws', 'azure', 'gcp', 'random') or github.com/org/repo for Git-hosted components" 55 | ), 56 | module: z 57 | .string() 58 | .optional() 59 | .describe( 60 | "The module to query (e.g., 's3', 'ec2', 'lambda'). Optional for smaller providers, will be 'index by default." 61 | ), 62 | resource: z 63 | .string() 64 | .describe("The resource type to query (e.g., 'Bucket', 'Function', 'Instance')") 65 | }, 66 | handler: async (args: GetResourceArgs) => { 67 | const schema = await getSchema(args.provider); 68 | // Find the resource entry [key, data] directly 69 | const resourceEntry = Object.entries(schema.resources).find(([key]) => { 70 | const [, modulePath, resourceName] = key.split(':'); 71 | const mainModule = modulePath.split('/')[0]; 72 | 73 | if (args.module) { 74 | // If module is provided, match module and resource name 75 | return mainModule === args.module && resourceName === args.resource; 76 | } else { 77 | // If no module provided, match resource name only 78 | return resourceName === args.resource; 79 | } 80 | }); 81 | 82 | if (resourceEntry) { 83 | // Destructure the found entry - TS knows these are defined now 84 | const [resourceKey, resourceData] = resourceEntry; 85 | 86 | return { 87 | description: 'Returns information about Pulumi Registry resources', 88 | content: [ 89 | { 90 | type: 'text' as const, 91 | text: formatSchema(resourceKey, resourceData) // No '!' needed 92 | } 93 | ] 94 | }; 95 | } else { 96 | // Handle the case where the resource was not found 97 | const availableResources = Object.keys(schema.resources) 98 | .map((key) => key.split(':').pop()) 99 | .filter(Boolean); 100 | 101 | return { 102 | description: 'Returns information about Pulumi Registry resources', // Consider making this more specific, e.g., "Resource not found" 103 | content: [ 104 | { 105 | type: 'text' as const, 106 | text: `No information found for ${args.resource}${args.module ? ` in module ${args.module}` : ''}. Available resources: ${availableResources.join(', ')}` // Slightly improved message 107 | } 108 | ] 109 | }; 110 | } 111 | } 112 | }, 113 | 114 | 'list-resources': { 115 | description: 'List all resource types for a given provider and module', 116 | schema: { 117 | provider: z 118 | .string() 119 | .describe( 120 | "The cloud provider (e.g., 'aws', 'azure', 'gcp', 'random') or github.com/org/repo for Git-hosted components" 121 | ), 122 | module: z 123 | .string() 124 | .optional() 125 | .describe("Optional module to filter by (e.g., 's3', 'ec2', 'lambda')") 126 | }, 127 | handler: async (args: ListResourcesArgs) => { 128 | const schema = await getSchema(args.provider); 129 | 130 | // Filter and format resources 131 | const resources = Object.entries(schema.resources) 132 | .filter(([key]) => { 133 | if (args.module) { 134 | const [, modulePath] = key.split(':'); 135 | const mainModule = modulePath.split('/')[0]; 136 | return mainModule === args.module; 137 | } 138 | return true; 139 | }) 140 | .map(([key, resource]) => { 141 | const resourceName = key.split(':').pop() || ''; 142 | const modulePath = key.split(':')[1]; 143 | const mainModule = modulePath.split('/')[0]; 144 | // Trim description at first '#' character 145 | const shortDescription = 146 | resource.description?.split('\n')[0].trim() ?? ''; 147 | return { 148 | name: resourceName, 149 | module: mainModule, 150 | description: shortDescription 151 | }; 152 | }); 153 | 154 | if (resources.length === 0) { 155 | return { 156 | description: 'No resources found', 157 | content: [ 158 | { 159 | type: 'text' as const, 160 | text: args.module 161 | ? `No resources found for provider '${args.provider}' in module '${args.module}'` 162 | : `No resources found for provider '${args.provider}'` 163 | } 164 | ] 165 | }; 166 | } 167 | 168 | const resourceList = resources 169 | .map((r) => `- ${r.name} (${r.module})\n ${r.description}`) 170 | .join('\n\n'); 171 | 172 | return { 173 | description: 'Lists available Pulumi Registry resources', 174 | content: [ 175 | { 176 | type: 'text' as const, 177 | text: args.module 178 | ? `Available resources for ${args.provider}/${args.module}:\n\n${resourceList}` 179 | : `Available resources for ${args.provider}:\n\n${resourceList}` 180 | } 181 | ] 182 | }; 183 | } 184 | } 185 | }; 186 | }; 187 | 188 | // Helper function to format schema 189 | export function formatSchema(resourceKey: string, resourceData: ResourceSchema): string { 190 | // Format the input properties section 191 | const inputProperties = Object.entries(resourceData.inputProperties ?? {}) 192 | .sort(([nameA], [nameB]) => { 193 | const isRequiredA = (resourceData.requiredInputs ?? []).includes(nameA); 194 | const isRequiredB = (resourceData.requiredInputs ?? []).includes(nameB); 195 | if (isRequiredA !== isRequiredB) { 196 | return isRequiredA ? -1 : 1; 197 | } 198 | return nameA.localeCompare(nameB); 199 | }) 200 | .map(([name, prop]) => { 201 | const isRequired = (resourceData.requiredInputs ?? []).includes(name); 202 | return `- ${name} (${prop.type}${isRequired ? ', required' : ''}): ${prop.description ?? ''}`; 203 | }) 204 | .join('\n'); 205 | 206 | // Format the output properties section 207 | const outputProperties = Object.entries(resourceData.properties ?? {}) 208 | .sort(([nameA], [nameB]) => { 209 | const isRequiredA = (resourceData.required ?? []).includes(nameA); 210 | const isRequiredB = (resourceData.required ?? []).includes(nameB); 211 | if (isRequiredA !== isRequiredB) { 212 | return isRequiredA ? -1 : 1; 213 | } 214 | return nameA.localeCompare(nameB); 215 | }) 216 | .map(([name, prop]) => { 217 | const isRequired = (resourceData.required ?? []).includes(name); 218 | return `- ${name} (${prop.type}${isRequired ? ', always present' : ''}): ${prop.description ?? ''}`; 219 | }) 220 | .join('\n'); 221 | 222 | return ` 223 | Resource: ${resourceKey} 224 | 225 | ${resourceData.description ?? ''} 226 | 227 | Input Properties: 228 | ${inputProperties} 229 | 230 | Output Properties: 231 | ${outputProperties} 232 | `; 233 | } 234 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import packageJSON from '../../package.json' with { type: 'json' }; 3 | import { registryCommands } from '../pulumi/registry.js'; 4 | import * as fs from 'node:fs'; 5 | import * as path from 'node:path'; 6 | import { fileURLToPath } from 'node:url'; 7 | import { cliCommands } from '../pulumi/cli.js'; 8 | import { logger } from '../logging/logging.js'; 9 | 10 | // Get the directory of the current module 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | 14 | // Create cache directory if it doesn't exist 15 | const CACHE_DIR = path.join(__dirname, './.cache'); 16 | if (!fs.existsSync(CACHE_DIR)) { 17 | fs.mkdirSync(CACHE_DIR, { recursive: true }); 18 | } 19 | 20 | type TextContent = { 21 | type: 'text'; 22 | text: string; 23 | }; 24 | 25 | function handleError( 26 | error: unknown, 27 | toolName: string 28 | ): { description: string; content: TextContent[] } { 29 | logger.error(`Error in tool ${toolName}:`, error); 30 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 31 | return { 32 | description: `Error executing ${toolName}`, 33 | content: [ 34 | { 35 | type: 'text' as const, 36 | text: `Operation failed: ${errorMessage}` 37 | } 38 | ] 39 | }; 40 | } 41 | 42 | export class Server extends McpServer { 43 | constructor(description: string) { 44 | super({ 45 | name: packageJSON.name, 46 | version: packageJSON.version, 47 | description: description 48 | }); 49 | // Centralized error handling function 50 | 51 | // Register registry commands 52 | Object.entries(registryCommands(CACHE_DIR)).forEach(([commandName, command]) => { 53 | const toolName = `pulumi-registry-${commandName}`; 54 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 | this.tool(toolName, command.description, command.schema, async (args: any) => { 56 | try { 57 | return await command.handler(args); 58 | } catch (error) { 59 | return handleError(error, toolName); 60 | } 61 | }); 62 | }); 63 | 64 | // Register CLI commands 65 | Object.entries(cliCommands).forEach(([commandName, command]) => { 66 | const toolName = `pulumi-cli-${commandName}`; 67 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 68 | this.tool(toolName, command.description, command.schema, async (args: any) => { 69 | try { 70 | return await command.handler(args); 71 | } catch (error) { 72 | return handleError(error, toolName); 73 | } 74 | }); 75 | }); 76 | } 77 | } 78 | 79 | export const createServer = () => { 80 | return new Server( 81 | 'An MCP server for querying Pulumi Registry information and running Pulumi CLI commands' 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/server/transport.ts: -------------------------------------------------------------------------------- 1 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 2 | import { createServer } from './server.js'; 3 | import express from 'express'; 4 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; 5 | import { logger } from '../logging/logging.js'; 6 | 7 | export const connectStdioTransport = () => { 8 | const transport = new StdioServerTransport(); 9 | 10 | const server = createServer(); 11 | // Connect the server to the transport 12 | server.connect(transport).catch((error) => { 13 | logger.error('Failed to connect server:', error); 14 | process.exit(1); 15 | }); 16 | }; 17 | 18 | export const connectSSETransport = (port: number) => { 19 | const app = express(); 20 | const transports: { [sessionId: string]: SSEServerTransport } = {}; 21 | 22 | app.get('/sse', async (req, res) => { 23 | const server = createServer(); 24 | 25 | const transport = new SSEServerTransport('/messages', res); 26 | transports[transport.sessionId] = transport; 27 | res.on('close', () => { 28 | delete transports[transport.sessionId]; 29 | }); 30 | await server.connect(transport); 31 | }); 32 | 33 | app.post('/messages', async (req, res) => { 34 | const sessionId = req.query.sessionId as string; 35 | const transport = transports[sessionId]; 36 | if (transport) { 37 | await transport.handlePostMessage(req, res); 38 | } else { 39 | res.status(400).send(`No transport found for sessionId: ${sessionId}`); 40 | } 41 | }); 42 | 43 | logger.info(`Connecting to SSE transport on port: ${port}`); 44 | app.listen(port); 45 | }; 46 | -------------------------------------------------------------------------------- /test/.cache/test_schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "resources": { 4 | "test:test:Test": { 5 | "description": "A test resource for unit testing", 6 | "properties": { 7 | "id": { 8 | "type": "string", 9 | "description": "The unique identifier of the resource" 10 | }, 11 | "name": { 12 | "type": "string", 13 | "description": "The name of the resource" 14 | }, 15 | "arn": { 16 | "type": "string", 17 | "description": "The ARN of the resource" 18 | } 19 | }, 20 | "required": ["id", "arn"], 21 | "inputProperties": { 22 | "name": { 23 | "type": "string", 24 | "description": "The name to give to the resource" 25 | }, 26 | "tags": { 27 | "type": "object", 28 | "description": "Tags to apply to the resource" 29 | } 30 | }, 31 | "requiredInputs": ["name"] 32 | }, 33 | "test:test:AnotherTest": { 34 | "description": "Another test resource with different properties", 35 | "properties": { 36 | "id": { 37 | "type": "string", 38 | "description": "The unique identifier" 39 | }, 40 | "value": { 41 | "type": "number", 42 | "description": "A numeric value" 43 | } 44 | }, 45 | "required": ["id"], 46 | "inputProperties": { 47 | "value": { 48 | "type": "number", 49 | "description": "The value to set" 50 | }, 51 | "enabled": { 52 | "type": "boolean", 53 | "description": "Whether the resource is enabled" 54 | } 55 | }, 56 | "requiredInputs": ["value", "enabled"] 57 | }, 58 | "test:module:ModuleTest": { 59 | "description": "A test resource in a different module", 60 | "properties": { 61 | "id": { 62 | "type": "string", 63 | "description": "The unique identifier" 64 | }, 65 | "status": { 66 | "type": "string", 67 | "description": "The current status" 68 | } 69 | }, 70 | "required": ["id", "status"], 71 | "inputProperties": { 72 | "config": { 73 | "type": "object", 74 | "description": "Configuration object" 75 | } 76 | }, 77 | "requiredInputs": [] 78 | }, 79 | "test:complex/module:ComplexTest": { 80 | "description": "A test resource in a complex module path", 81 | "properties": { 82 | "id": { 83 | "type": "string", 84 | "description": "The unique identifier" 85 | }, 86 | "complexity": { 87 | "type": "number", 88 | "description": "The complexity level" 89 | } 90 | }, 91 | "required": ["id"], 92 | "inputProperties": { 93 | "complexity": { 94 | "type": "number", 95 | "description": "The complexity level to set" 96 | } 97 | }, 98 | "requiredInputs": ["complexity"] 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /test/registry.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import { formatSchema,registryCommands } from '../src/pulumi/registry.js'; 5 | 6 | // Get the directory of the current module 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const CACHE_DIR = path.join(__dirname, './.cache'); 10 | 11 | // Implement the lowercaseFirstChar function directly 12 | function lowercaseFirstChar(str: string): string { 13 | if (!str) return str; 14 | return str.charAt(0).toLowerCase() + str.slice(1); 15 | } 16 | 17 | describe('Registry Commands', () => { 18 | describe('lowercaseFirstChar', () => { 19 | it('should convert the first character to lowercase', () => { 20 | expect(lowercaseFirstChar('Hello')).to.equal('hello'); 21 | expect(lowercaseFirstChar('WORLD')).to.equal('wORLD'); 22 | expect(lowercaseFirstChar('aBC')).to.equal('aBC'); 23 | }); 24 | 25 | it('should handle empty strings', () => { 26 | expect(lowercaseFirstChar('')).to.equal(''); 27 | }); 28 | 29 | it('should handle single character strings', () => { 30 | expect(lowercaseFirstChar('A')).to.equal('a'); 31 | expect(lowercaseFirstChar('b')).to.equal('b'); 32 | }); 33 | }); 34 | 35 | describe('formatSchema', () => { 36 | it('should format schema with required and optional properties', () => { 37 | const resourceKey = 'test:test:Test'; 38 | const resourceData = { 39 | description: 'A test resource for unit testing', 40 | properties: { 41 | 'id': { 42 | type: 'string', 43 | description: 'The unique identifier of the resource' 44 | }, 45 | 'name': { 46 | type: 'string', 47 | description: 'The name of the resource' 48 | }, 49 | 'arn': { 50 | type: 'string', 51 | description: 'The ARN of the resource' 52 | } 53 | }, 54 | required: ['id', 'arn'], 55 | inputProperties: { 56 | 'name': { 57 | type: 'string', 58 | description: 'The name to give to the resource' 59 | }, 60 | 'tags': { 61 | type: 'object', 62 | description: 'Tags to apply to the resource' 63 | } 64 | }, 65 | requiredInputs: ['name'] 66 | }; 67 | 68 | const formatted = formatSchema(resourceKey, resourceData); 69 | 70 | expect(formatted).to.include('Resource: test:test:Test'); 71 | expect(formatted).to.include('A test resource for unit testing'); 72 | expect(formatted).to.include('Input Properties:'); 73 | expect(formatted).to.include('name (string, required)'); 74 | expect(formatted).to.include('tags (object)'); 75 | expect(formatted).to.include('Output Properties:'); 76 | expect(formatted).to.include('id (string, always present)'); 77 | expect(formatted).to.include('arn (string, always present)'); 78 | expect(formatted).to.include('name (string)'); 79 | }); 80 | 81 | it('should format schema with different property types', () => { 82 | const resourceKey = 'test:test:AnotherTest'; 83 | const resourceData = { 84 | description: 'Another test resource with different properties', 85 | properties: { 86 | 'id': { 87 | type: 'string', 88 | description: 'The unique identifier' 89 | }, 90 | 'value': { 91 | type: 'number', 92 | description: 'A numeric value' 93 | } 94 | }, 95 | required: ['id'], 96 | inputProperties: { 97 | 'value': { 98 | type: 'number', 99 | description: 'The value to set' 100 | }, 101 | 'enabled': { 102 | type: 'boolean', 103 | description: 'Whether the resource is enabled' 104 | } 105 | }, 106 | requiredInputs: ['value', 'enabled'] 107 | }; 108 | 109 | const formatted = formatSchema(resourceKey, resourceData); 110 | 111 | expect(formatted).to.include('Resource: test:test:AnotherTest'); 112 | expect(formatted).to.include('Another test resource with different properties'); 113 | expect(formatted).to.include('value (number, required)'); 114 | expect(formatted).to.include('enabled (boolean, required)'); 115 | expect(formatted).to.include('value (number)'); 116 | }); 117 | 118 | it('should format schema with no required inputs', () => { 119 | const resourceKey = 'test:module:ModuleTest'; 120 | const resourceData = { 121 | description: 'A test resource in a different module', 122 | properties: { 123 | 'id': { 124 | type: 'string', 125 | description: 'The unique identifier' 126 | }, 127 | 'status': { 128 | type: 'string', 129 | description: 'The current status' 130 | } 131 | }, 132 | required: ['id', 'status'], 133 | inputProperties: { 134 | 'config': { 135 | type: 'object', 136 | description: 'Configuration object' 137 | } 138 | }, 139 | requiredInputs: [] 140 | }; 141 | 142 | const formatted = formatSchema(resourceKey, resourceData); 143 | 144 | expect(formatted).to.include('Resource: test:module:ModuleTest'); 145 | expect(formatted).to.include('A test resource in a different module'); 146 | expect(formatted).to.include('config (object)'); 147 | expect(formatted).to.include('id (string, always present)'); 148 | expect(formatted).to.include('status (string, always present)'); 149 | }); 150 | }); 151 | 152 | describe('getResource handler', () => { 153 | const commands = registryCommands(CACHE_DIR); 154 | 155 | it('should return resource information when resource exists', async () => { 156 | const args = { 157 | provider: 'test', 158 | module: 'test', 159 | resource: 'Test' 160 | }; 161 | 162 | const result = await commands["get-resource"].handler(args); 163 | 164 | expect(result.description).to.equal('Returns information about Pulumi Registry resources'); 165 | expect(result.content[0].type).to.equal('text'); 166 | expect(result.content[0].text).to.include('Resource: test:test:Test'); 167 | expect(result.content[0].text).to.include('A test resource for unit testing'); 168 | }); 169 | 170 | it('should handle resources in different modules', async () => { 171 | const args = { 172 | provider: 'test', 173 | module: 'module', 174 | resource: 'ModuleTest' 175 | }; 176 | 177 | const result = await commands["get-resource"].handler(args); 178 | 179 | expect(result.description).to.equal('Returns information about Pulumi Registry resources'); 180 | expect(result.content[0].type).to.equal('text'); 181 | expect(result.content[0].text).to.include('Resource: test:module:ModuleTest'); 182 | }); 183 | 184 | it('should handle missing module parameter', async () => { 185 | const args = { 186 | provider: 'test', 187 | resource: 'Test' 188 | }; 189 | 190 | const result = await commands["get-resource"].handler(args); 191 | 192 | expect(result.description).to.equal('Returns information about Pulumi Registry resources'); 193 | expect(result.content[0].type).to.equal('text'); 194 | expect(result.content[0].text).to.include('Resource: test:test:Test'); 195 | expect(result.content[0].text).to.include('A test resource for unit testing'); 196 | }); 197 | 198 | it('should find resource in any module when module is not specified', async () => { 199 | const args = { 200 | provider: 'test', 201 | resource: 'ModuleTest' 202 | }; 203 | 204 | const result = await commands["get-resource"].handler(args); 205 | 206 | expect(result.description).to.equal('Returns information about Pulumi Registry resources'); 207 | expect(result.content[0].type).to.equal('text'); 208 | expect(result.content[0].text).to.include('Resource: test:module:ModuleTest'); 209 | expect(result.content[0].text).to.include('A test resource in a different module'); 210 | }); 211 | 212 | it('should find resource in complex module path', async () => { 213 | const args = { 214 | provider: 'test', 215 | resource: 'ComplexTest' 216 | }; 217 | 218 | const result = await commands["get-resource"].handler(args); 219 | 220 | expect(result.description).to.equal('Returns information about Pulumi Registry resources'); 221 | expect(result.content[0].type).to.equal('text'); 222 | expect(result.content[0].text).to.include('Resource: test:complex/module:ComplexTest'); 223 | expect(result.content[0].text).to.include('A test resource in a complex module path'); 224 | }); 225 | 226 | it('should find resource in complex module path by main module name', async () => { 227 | const args = { 228 | provider: 'test', 229 | module: 'complex', 230 | resource: 'ComplexTest' 231 | }; 232 | 233 | const result = await commands["get-resource"].handler(args); 234 | 235 | expect(result.description).to.equal('Returns information about Pulumi Registry resources'); 236 | expect(result.content[0].type).to.equal('text'); 237 | expect(result.content[0].text).to.include('Resource: test:complex/module:ComplexTest'); 238 | expect(result.content[0].text).to.include('A test resource in a complex module path'); 239 | }); 240 | 241 | it('should prefer exact module match when module is specified', async () => { 242 | const args = { 243 | provider: 'test', 244 | module: 'test', 245 | resource: 'Test' 246 | }; 247 | 248 | const result = await commands["get-resource"].handler(args); 249 | 250 | expect(result.description).to.equal('Returns information about Pulumi Registry resources'); 251 | expect(result.content[0].type).to.equal('text'); 252 | expect(result.content[0].text).to.include('Resource: test:test:Test'); 253 | expect(result.content[0].text).to.include('A test resource for unit testing'); 254 | }); 255 | 256 | it('should handle non-existent resources with module specified', async () => { 257 | const args = { 258 | provider: 'test', 259 | module: 'test', 260 | resource: 'NonExistent' 261 | }; 262 | 263 | const result = await commands["get-resource"].handler(args); 264 | 265 | expect(result.description).to.equal('Returns information about Pulumi Registry resources'); 266 | expect(result.content[0].type).to.equal('text'); 267 | expect(result.content[0].text).to.include('No information found for NonExistent'); 268 | expect(result.content[0].text).to.include('Available resources:'); 269 | }); 270 | }); 271 | 272 | describe('listResources handler', () => { 273 | const commands = registryCommands(CACHE_DIR); 274 | 275 | it('should list all resources for a provider with their modules', async () => { 276 | const args = { 277 | provider: 'test' 278 | }; 279 | 280 | const result = await commands["list-resources"].handler(args); 281 | 282 | expect(result.description).to.equal('Lists available Pulumi Registry resources'); 283 | expect(result.content[0].type).to.equal('text'); 284 | expect(result.content[0].text).to.include('Available resources for test:'); 285 | expect(result.content[0].text).to.include('Test (test)'); 286 | expect(result.content[0].text).to.include('AnotherTest (test)'); 287 | expect(result.content[0].text).to.include('ModuleTest (module)'); 288 | expect(result.content[0].text).to.include('ComplexTest (complex)'); 289 | }); 290 | 291 | it('should filter resources by main module name', async () => { 292 | const args = { 293 | provider: 'test', 294 | module: 'complex' 295 | }; 296 | 297 | const result = await commands["list-resources"].handler(args); 298 | 299 | expect(result.description).to.equal('Lists available Pulumi Registry resources'); 300 | expect(result.content[0].type).to.equal('text'); 301 | expect(result.content[0].text).to.include('Available resources for test/complex:'); 302 | expect(result.content[0].text).to.include('ComplexTest (complex)'); 303 | expect(result.content[0].text).to.not.include('Test (test)'); 304 | expect(result.content[0].text).to.not.include('ModuleTest (module)'); 305 | }); 306 | 307 | it('should handle non-existent module', async () => { 308 | const args = { 309 | provider: 'test', 310 | module: 'nonexistent' 311 | }; 312 | 313 | const result = await commands["list-resources"].handler(args); 314 | 315 | expect(result.description).to.equal('No resources found'); 316 | expect(result.content[0].type).to.equal('text'); 317 | expect(result.content[0].text).to.include(`No resources found for provider 'test' in module 'nonexistent'`); 318 | }); 319 | }); 320 | }); 321 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "declaration": true, 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true 14 | }, 15 | "include": [ 16 | "src/**/*", 17 | "package.json" 18 | ], 19 | "exclude": [ 20 | "node_modules" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup" 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | splitting: false, 6 | sourcemap: true, 7 | clean: true, 8 | dts: true, 9 | format: ["esm"], 10 | target: "ES2022", 11 | banner: { 12 | js: '#!/usr/bin/env node' 13 | } 14 | }) 15 | --------------------------------------------------------------------------------