├── .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 |
--------------------------------------------------------------------------------