This is page 1 of 2. Use http://codebase.md/googlecloudplatform/cloud-run-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .c8rc.json
├── .github
│ ├── images
│ │ ├── deploy_from_apps.gif
│ │ ├── deploy_from_ide.gif
│ │ └── deploycli.gif
│ └── workflows
│ ├── lint-checker.yml
│ ├── local-tests.yml
│ └── npm-publish.yml
├── .gitignore
├── .kokoro
│ ├── kokoro_build.sh
│ ├── presubmit.cfg
│ └── run_tests.sh
├── .prettierignore
├── .prettierrc.json
├── CONTRIBUTING.md
├── Dockerfile
├── example-sources-to-deploy
│ ├── Dockerfile
│ ├── go.mod
│ └── main.go
├── gemini-extension
│ └── GEMINI.md
├── gemini-extension.json
├── lib
│ ├── cloud-api
│ │ ├── auth.js
│ │ ├── billing.js
│ │ ├── build.js
│ │ ├── helpers.js
│ │ ├── metadata.js
│ │ ├── projects.js
│ │ ├── registry.js
│ │ ├── run.js
│ │ └── storage.js
│ ├── deployment
│ │ └── deployer.js
│ └── util
│ ├── archive.js
│ └── helpers.js
├── LICENSE
├── mcp-server.js
├── package-lock.json
├── package.json
├── prompts.js
├── README.md
├── REVIEWING.md
├── test
│ ├── local
│ │ ├── cloud-api
│ │ │ ├── build.test.js
│ │ │ └── projects.test.js
│ │ ├── gemini-extension.test.js
│ │ ├── mcp-server-stdio.test.js
│ │ ├── mcp-server-streamable-http.test.js
│ │ ├── mcp-server.test.js
│ │ ├── notifications.test.js
│ │ ├── npx.test.js
│ │ ├── prompts.test.js
│ │ ├── test-utils.js
│ │ └── tools.test.js
│ └── need-gcp
│ ├── cloud-run-services.test.js
│ ├── deployer.test.js
│ ├── gcp-auth-check.test.js
│ ├── gcp-projects.test.js
│ ├── test-helpers.js
│ └── workflows
│ └── deployment-workflows.test.js
└── tools
├── register-tools.js
└── tools.js
```
# Files
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
```
1 | node_modules
2 | package-lock.json
3 |
```
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "tabWidth": 2,
5 | "useTabs": false,
6 | "printWidth": 80,
7 | "trailingComma": "es5"
8 | }
9 |
```
--------------------------------------------------------------------------------
/.c8rc.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "all": true,
3 | "reporter": ["lcov", "text"],
4 | "lines": 43,
5 | "functions": 35,
6 | "branches": 70,
7 | "statements": 43,
8 | "exclude": ["**/test/**", "**/coverage/**"]
9 | }
10 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Environment variables
2 | .env
3 | .env.local
4 | .env.development.local
5 | .env.test.local
6 | .env.production.local
7 |
8 | # Node.js
9 | node_modules/
10 |
11 | # Test coverage
12 | coverage/
13 |
14 | # Settings
15 | .gemini/
16 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Cloud Run MCP server and Gemini CLI extension
2 |
3 | Enable MCP-compatible AI agents to deploy apps to Cloud Run.
4 |
5 | ```json
6 | "mcpServers":{
7 | "cloud-run": {
8 | "command": "npx",
9 | "args": ["-y", "@google-cloud/cloud-run-mcp"]
10 | }
11 | }
12 | ```
13 |
14 | Deploy from Gemini CLI and other AI-powered CLI agents:
15 |
16 | <img src="https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-run-mcp/refs/heads/main/.github/images/deploycli.gif" width="800">
17 |
18 | Deploy from AI-powered IDEs:
19 |
20 | <img src="https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-run-mcp/refs/heads/main/.github/images/deploy_from_ide.gif" width="800">
21 |
22 | Deploy from AI assistant apps:
23 |
24 | <img src="https://raw.githubusercontent.com/GoogleCloudPlatform/cloud-run-mcp/refs/heads/main/.github/images/deploy_from_apps.gif" width="800">
25 |
26 | Deploy from agent SDKs, like the [Google Gen AI SDK](https://ai.google.dev/gemini-api/docs/function-calling?example=meeting#use_model_context_protocol_mcp) or [Agent Development Kit](https://google.github.io/adk-docs/tools/mcp-tools/).
27 |
28 | > [!NOTE]
29 | > This is the repository of an MCP server to deploy code to Cloud Run, to learn how to **host** MCP servers on Cloud Run, [visit the Cloud Run documentation](https://cloud.google.com/run/docs/host-mcp-servers).
30 |
31 | ## Tools
32 |
33 | - `deploy-file-contents`: Deploys files to Cloud Run by providing their contents directly.
34 | - `list-services`: Lists Cloud Run services in a given project and region.
35 | - `get-service`: Gets details for a specific Cloud Run service.
36 | - `get-service-log`: Gets Logs and Error Messages for a specific Cloud Run service.
37 |
38 | - `deploy-local-folder`\*: Deploys a local folder to a Google Cloud Run service.
39 | - `list-projects`\*: Lists available GCP projects.
40 | - `create-project`\*: Creates a new GCP project and attach it to the first available billing account. A project ID can be optionally specified.
41 |
42 | _\* only available when running locally_
43 |
44 | ## Prompts
45 |
46 | Prompts are natural language commands that can be used to perform common tasks. They are shortcuts for executing tool calls with pre-filled arguments.
47 |
48 | - `deploy`: Deploys the current working directory to Cloud Run. If a service name is not provided, it will use the `DEFAULT_SERVICE_NAME` environment variable, or the name of the current working directory.
49 | - `logs`: Gets the logs for a Cloud Run service. If a service name is not provided, it will use the `DEFAULT_SERVICE_NAME` environment variable, or the name of the current working directory.
50 |
51 | ## Environment Variables
52 |
53 | The Cloud Run MCP server can be configured using the following environment variables:
54 |
55 | | Variable | Description |
56 | | :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57 | | `GOOGLE_CLOUD_PROJECT` | The default project ID to use for Cloud Run services. |
58 | | `GOOGLE_CLOUD_REGION` | The default region to use for Cloud Run services. |
59 | | `DEFAULT_SERVICE_NAME` | The default service name to use for Cloud Run services. |
60 | | `SKIP_IAM_CHECK` | Controls whether to check for IAM permissions for a Cloud Run service. Set to `false` to enable checks. This is `true` by default which is a recommended way to make the service public. |
61 | | `ENABLE_HOST_VALIDATION` | Prevents [DNS Rebinding](https://en.wikipedia.org/wiki/DNS_rebinding) attacks by validating the Host header. This is disabled by default. |
62 | | `ALLOWED_HOSTS` | Comma-separated list of allowed Host headers (if host validation is enabled). The default value is `localhost,127.0.0.1,::1`. |
63 |
64 | ## Use as a Gemini CLI extension
65 |
66 | To install this as a [Gemini CLI](https://github.com/google-gemini/gemini-cli) extension, run the following command:
67 |
68 | 2. Install the extension:
69 |
70 | ```bash
71 | gemini extensions install https://github.com/GoogleCloudPlatform/cloud-run-mcp
72 | ```
73 |
74 | 3. Log in to your Google Cloud account using the command:
75 |
76 | ```bash
77 | gcloud auth login
78 | ```
79 |
80 | 4. Set up application credentials using the command:
81 | ```bash
82 | gcloud auth application-default login
83 | ```
84 |
85 | ## Use in MCP Clients
86 |
87 | ### Learn how to configure your MCP client
88 |
89 | Most MCP clients require a configuration file to be created or modified to add the MCP server.
90 |
91 | The configuration file syntax can be different across clients. Please refer to the following links for the latest expected syntax:
92 |
93 | - [**Antigravity**](https://antigravity.google/docs/mcp)
94 | - [**Windsurf**](https://docs.windsurf.com/windsurf/mcp)
95 | - [**VSCode**](https://code.visualstudio.com/docs/copilot/chat/mcp-servers)
96 | - [**Claude Desktop**](https://modelcontextprotocol.io/quickstart/user)
97 | - [**Cursor**](https://docs.cursor.com/context/model-context-protocol)
98 |
99 | Once you have identified how to configure your MCP client, select one of these two options to set up the MCP server.
100 | We recommend setting up as a local MCP server using Node.js.
101 |
102 | ### Set up as local MCP server
103 |
104 | Run the Cloud Run MCP server on your local machine using local Google Cloud credentials. This is best if you are using an AI-assisted IDE (e.g. Cursor) or a desktop AI application (e.g. Claude).
105 |
106 | 1. Install the [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) and authenticate with your Google account.
107 |
108 | 2. Log in to your Google Cloud account using the command:
109 |
110 | ```bash
111 | gcloud auth login
112 | ```
113 |
114 | 3. Set up application credentials using the command:
115 | ```bash
116 | gcloud auth application-default login
117 | ```
118 |
119 | Then configure the MCP server using either Node.js or Docker:
120 |
121 | #### Using Node.js
122 |
123 | 0. Install [Node.js](https://nodejs.org/en/download/) (LTS version recommended).
124 |
125 | 1. Update the MCP configuration file of your MCP client with the following:
126 |
127 | ```json
128 | "cloud-run": {
129 | "command": "npx",
130 | "args": ["-y", "@google-cloud/cloud-run-mcp"]
131 | }
132 | ```
133 |
134 | 2. [Optional] Add default configurations
135 |
136 | ```json
137 | "cloud-run": {
138 | "command": "npx",
139 | "args": ["-y", "@google-cloud/cloud-run-mcp"],
140 | "env": {
141 | "GOOGLE_CLOUD_PROJECT": "PROJECT_NAME",
142 | "GOOGLE_CLOUD_REGION": "PROJECT_REGION",
143 | "DEFAULT_SERVICE_NAME": "SERVICE_NAME"
144 | }
145 | }
146 | ```
147 |
148 | #### Using Docker
149 |
150 | See Docker's [MCP catalog](https://hub.docker.com/mcp/server/cloud-run-mcp/overview), or use these manual instructions:
151 |
152 | 0. Install [Docker](https://www.docker.com/get-started/)
153 |
154 | 1. Update the MCP configuration file of your MCP client with the following:
155 |
156 | ```json
157 | "cloud-run": {
158 | "command": "docker",
159 | "args": [
160 | "run",
161 | "-i",
162 | "--rm",
163 | "-e",
164 | "GOOGLE_APPLICATION_CREDENTIALS",
165 | "-v",
166 | "/local-directory:/local-directory",
167 | "mcp/cloud-run-mcp:latest"
168 | ],
169 | "env": {
170 | "GOOGLE_APPLICATION_CREDENTIALS": "/Users/slim/.config/gcloud/application_default-credentials.json",
171 | "DEFAULT_SERVICE_NAME": "SERVICE_NAME"
172 | }
173 | }
174 | ```
175 |
176 | ### Set up as remote MCP server
177 |
178 | > [!WARNING]
179 | > Do not use the remote MCP server without authentication. In the following instructions, we will use IAM authentication to secure the connection to the MCP server from your local machine. This is important to prevent unauthorized access to your Google Cloud resources.
180 |
181 | Run the Cloud Run MCP server itself on Cloud Run with connection from your local machine authenticated via IAM.
182 | With this option, you will only be able to deploy code to the same Google Cloud project as where the MCP server is running.
183 |
184 | 1. Install the [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) and authenticate with your Google account.
185 |
186 | 2. Log in to your Google Cloud account using the command:
187 |
188 | ```bash
189 | gcloud auth login
190 | ```
191 |
192 | 3. Set your Google Cloud project ID using the command:
193 | ```bash
194 | gcloud config set project YOUR_PROJECT_ID
195 | ```
196 | 4. Deploy the Cloud Run MCP server to Cloud Run:
197 |
198 | ```bash
199 | gcloud run deploy cloud-run-mcp --image us-docker.pkg.dev/cloudrun/container/mcp --no-allow-unauthenticated
200 | ```
201 |
202 | When prompted, pick a region, for example `europe-west1`.
203 |
204 | Note that the MCP server is _not_ publicly accessible, it requires authentication via IAM.
205 |
206 | 5. [Optional] Add default configurations
207 |
208 | ```bash
209 | gcloud run services update cloud-run-mcp --region=REGION --update-env-vars GOOGLE_CLOUD_PROJECT=PROJECT_NAME,GOOGLE_CLOUD_REGION=PROJECT_REGION,DEFAULT_SERVICE_NAME=SERVICE_NAME,SKIP_IAM_CHECK=false
210 | ```
211 |
212 | 6. Run a Cloud Run proxy on your local machine to connect securely using your identity to the remote MCP server running on Cloud Run:
213 |
214 | ```bash
215 | gcloud run services proxy cloud-run-mcp --port=3000 --region=REGION --project=PROJECT_ID
216 | ```
217 |
218 | This will create a local proxy on port 3000 that forwards requests to the remote MCP server and injects your identity.
219 |
220 | 7. Update the MCP configuration file of your MCP client with the following:
221 |
222 | ```json
223 | "cloud-run": {
224 | "url": "http://localhost:3000/sse"
225 | }
226 |
227 | ```
228 |
229 | If your MCP client does not support the `url` attribute, you can use [mcp-remote](https://www.npmjs.com/package/mcp-remote):
230 |
231 | ```json
232 | "cloud-run": {
233 | "command": "npx",
234 | "args": ["-y", "mcp-remote", "http://localhost:3000/sse"]
235 | }
236 | ```
237 |
238 | The Google Cloud Platform Terms of Service (available at https://cloud.google.com/terms/) and the Data Processing and Security Terms (available at https://cloud.google.com/terms/data-processing-terms) do not apply to any component of the Cloud Run MCP Server software.
239 |
```
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
1 | # How to contribute
2 |
3 | We'd love to accept your patches and contributions to this project.
4 |
5 | ## Before you begin
6 |
7 | ### Sign our Contributor License Agreement
8 |
9 | Contributions to this project must be accompanied by a
10 | [Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
11 | You (or your employer) retain the copyright to your contribution; this simply
12 | gives us permission to use and redistribute your contributions as part of the
13 | project.
14 |
15 | If you or your current employer have already signed the Google CLA (even if it
16 | was for a different project), you probably don't need to do it again.
17 |
18 | Visit <https://cla.developers.google.com/> to see your current agreements or to
19 | sign a new one.
20 |
21 | ### Review our community guidelines
22 |
23 | This project follows
24 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/).
25 |
26 | ## Contribution process
27 |
28 | ### Start with an issue
29 |
30 | Before sending a pull request, please open an issue describing the bug or feature
31 | you would like to address. This will allow maintainers of this project to guide
32 | you in your design and implementation.
33 |
34 | ### Code reviews
35 |
36 | All submissions, including submissions by project members, require review. We
37 | use GitHub pull requests for this purpose. Consult
38 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
39 | information on using pull requests.
40 |
41 | ## Development
42 |
43 | ```bash
44 | npm install
45 | ```
46 |
47 | ### Using MCP inspector
48 |
49 | Load MCP Inspector in your browser:
50 |
51 | ```bash
52 | npm run test:mcp
53 | ```
54 |
55 | Open http://localhost:6274/
56 |
57 | ### Using a real MCP client
58 |
59 | To use local stdio MCP server. In your MCP client configuration, use the following:
60 |
61 | ```json
62 | {
63 | "mcpServers": {
64 | "cloud-run": {
65 | "command": "node",
66 | "args": ["/path/to/this/repo/cloud-run-mcp/mcp-server.js"]
67 | }
68 | }
69 | }
70 | ```
71 |
72 | To use remote MCP Server in a MCP client:
73 |
74 | Start the MCP server locally with:
75 |
76 | ```bash
77 | GCP_STDIO=false node /path/to/this/repo/cloud-run-mcp/mcp-server.js
78 | ```
79 |
80 | Then, in your MCP client configuration, use the following:
81 |
82 | ```json
83 | {
84 | "mcpServers": {
85 | "cloud-run": {
86 | "command": "npx",
87 | "args": ["mcp-remote", "http://localhost:3000/mcp"]
88 | }
89 | }
90 | }
91 | ```
92 |
93 | or, if your client supports the `url` attribute, you can use:
94 |
95 | ```
96 | {
97 | "mcpServers": {
98 | "cloud-run": {
99 | "url": "http://localhost:3000/mcp"
100 | }
101 | }
102 | }
103 | ```
104 |
105 | ## Testing
106 |
107 | ### To test creating a new project (not using MCP)
108 |
109 | See the `test/test-create-project.js` script. Run it with:
110 |
111 | ```bash
112 | npm run test:create-project
113 | ```
114 |
115 | This script will guide you through creating a new Google Cloud project and attempting to link it to a billing account. You can optionally provide a desired project ID.
116 |
117 | ### To test a simple deployment (not using MCP)
118 |
119 | See the `test/test-deploy.js` script. Run it with:
120 |
121 | ```bash
122 | npm run test:deploy
123 | ```
124 |
125 | This script requires an existing Google Cloud Project ID to be provided when prompted or as a command-line argument.
126 |
127 | ## Publishing to npm
128 |
129 | To update the [npm package](https://www.npmjs.com/package/@google-cloud/cloud-run-mcp)
130 |
131 | Run the following:
132 |
133 | - `git checkout -b new-release`
134 | - `npm test`
135 | - `npm version minor`
136 | - `git push --set-upstream origin new-release --follow-tags`
137 | - Create a [pull request for 'new-release' on GitHub](https://github.com/GoogleCloudPlatform/cloud-run-mcp/pull/new/new-release)
138 | - Get approval
139 | - Rebase and merge into main
140 | - On GitHub, [create a new release](https://github.com/GoogleCloudPlatform/cloud-run-mcp/releases/new) for this tag.
141 |
142 | Then a [GitHub Action](https://github.com/GoogleCloudPlatform/cloud-run-mcp/blob/main/.github/workflows/npm-publish.yml) automatically publishes to npm when new releases are pushes.
143 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM node:22-slim
2 | WORKDIR /usr/src/app
3 | COPY package*.json ./
4 | RUN npm install --omit=dev
5 | COPY . .
6 | EXPOSE 3000
7 | CMD [ "node", "mcp-server.js" ]
8 |
```
--------------------------------------------------------------------------------
/gemini-extension.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "cloud-run",
3 | "version": "1.0.0",
4 | "mcpServers": {
5 | "cloud-run": {
6 | "command": "npx",
7 | "args": ["-y", "@google-cloud/cloud-run-mcp"]
8 | }
9 | },
10 | "contextFileName": "gemini-extension/GEMINI.md"
11 | }
12 |
```
--------------------------------------------------------------------------------
/.github/workflows/local-tests.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Run Local Tests
2 |
3 | on:
4 | push:
5 | pull_request:
6 | branches: [main]
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-node@v4
14 | with:
15 | node-version: 20
16 | - run: npm ci
17 | - run: npm test
18 |
```
--------------------------------------------------------------------------------
/.github/workflows/lint-checker.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Run Lint Checker
2 |
3 | on:
4 | push:
5 | pull_request:
6 | branches: [main]
7 |
8 | jobs:
9 | lint-check:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-node@v4
14 | with:
15 | node-version: 20
16 | - run: npm ci
17 | - run: npm run lint:check
18 |
```
--------------------------------------------------------------------------------
/test/local/cloud-api/projects.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import assert from 'node:assert/strict';
2 | import { describe, it } from 'node:test';
3 | import esmock from 'esmock';
4 |
5 | describe('projects', () => {
6 | describe('generateProjectId', () => {
7 | it('should generate a project id in the correct format', async () => {
8 | const { generateProjectId } = await esmock(
9 | '../../../lib/cloud-api/projects.js',
10 | {}
11 | );
12 |
13 | const projectId = generateProjectId();
14 | assert.ok(projectId.startsWith('mcp-'));
15 | assert.strictEqual(projectId.length, 11);
16 | });
17 | });
18 | });
19 |
```
--------------------------------------------------------------------------------
/.github/workflows/npm-publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Publish to npm
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | publish-npm:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | - uses: actions/setup-node@v4
13 | with:
14 | node-version: 20
15 | - run: npm ci
16 | # Now configure with the publish service for install.
17 | - uses: actions/setup-node@v4
18 | with:
19 | node-version: 20
20 | registry-url: https://wombat-dressing-room.appspot.com/
21 | - run: npm publish
22 | env:
23 | NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}}
24 |
```
--------------------------------------------------------------------------------
/test/need-gcp/gcp-projects.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { test } from 'node:test';
18 | import { setupProject } from './test-helpers.js';
19 |
20 | test('should create a new project and attach billing', async (t) => {
21 | console.log('Attempting to create a new project and attach billing...');
22 | await setupProject(t);
23 | });
24 |
```
--------------------------------------------------------------------------------
/.kokoro/kokoro_build.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 |
3 | # Copyright 2022 Google LLC
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | # Code under repo is checked out to ${KOKORO_ARTIFACTS_DIR}/git.
18 | # The final directory name in this path is determined by the scm name specified
19 | # in the job configuration.
20 |
21 | cd "${KOKORO_ARTIFACTS_DIR}/github/cloud-run-mcp"
22 | .kokoro/run_tests.sh
23 |
```
--------------------------------------------------------------------------------
/.kokoro/run_tests.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 | #
3 | # Copyright 2022 Google LLC
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 |
17 | # Fail on any error.
18 | set -xe
19 |
20 | # cd to project root
21 | cd "$(dirname "$0")"/..
22 |
23 | # Install dependencies
24 | npm install
25 |
26 | # Run tests
27 | npm run test:workflows # Run tests related to all workflows
28 | npm run test:projects # Run tests related to GCP projects
29 | npm run test:services # Run tests related to services
30 | npm run test:deploy # Run tests related to deployments
31 | npm run test:gcp-auth # Run tests related to GCP authentication
```
--------------------------------------------------------------------------------
/REVIEWING.md:
--------------------------------------------------------------------------------
```markdown
1 | # Reviewing Pull Requests
2 |
3 | This document provides guidelines for reviewing Pull Requests.
4 |
5 | ## Guidelines
6 |
7 | ### Presubmit Tests
8 |
9 | Running presubmit tests is a mandatory requirement for Pull Requests to be eligible for submission.
10 |
11 | **Before triggering tests:**
12 | Review the code changes, especially new or modified tests, to ensure they do not contain any malicious or harmful code that could unnecessarily consume or harm Google's resources during test execution. In particular, ensure the tests:
13 |
14 | - **Do not modify environment variables:** The tests should not modify environment variables in an unexpected or harmful way.
15 | - **Do not consume excessive resources:** Ensure tests do not create an excessive number of Google Cloud projects or other high-cost resources.
16 | - **Do not contain hardcoded credentials:** Credentials should not be present in the code.
17 | - **Do not rely on untrusted external dependencies:** Any new external dependencies should be reviewed for trustworthiness.
18 |
19 | **Triggering tests:**
20 | If the code is safe to run, trigger presubmit tests by adding a comment with `kokoro:run` to the Pull Request. Tests are run via Kokoro.
21 |
```
--------------------------------------------------------------------------------
/test/local/gemini-extension.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { test, describe } from 'node:test';
2 | import assert from 'node:assert/strict';
3 | import fs from 'node:fs';
4 | import path from 'node:path';
5 | import { fileURLToPath } from 'node:url';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 | const rootDir = path.resolve(__dirname, '..', '..');
10 | const geminiExtensionJsonPath = path.resolve(rootDir, 'gemini-extension.json');
11 |
12 | describe('Gemini CLI extension', () => {
13 | test('gemini-extension.json should be at the root', () => {
14 | assert.ok(
15 | fs.existsSync(geminiExtensionJsonPath),
16 | 'gemini-extension.json not found at the project root'
17 | );
18 | });
19 |
20 | test('contextFileName from gemini-extension.json should exist', () => {
21 | const geminiExtensionJson = JSON.parse(
22 | fs.readFileSync(geminiExtensionJsonPath, 'utf-8')
23 | );
24 | const contextFileName = geminiExtensionJson.contextFileName;
25 | assert.ok(
26 | contextFileName,
27 | 'contextFileName not found in gemini-extension.json'
28 | );
29 | const contextFilePath = path.resolve(rootDir, contextFileName);
30 | assert.ok(
31 | fs.existsSync(contextFilePath),
32 | `context file name '${contextFileName}' not found`
33 | );
34 | });
35 | });
36 |
```
--------------------------------------------------------------------------------
/example-sources-to-deploy/main.go:
--------------------------------------------------------------------------------
```go
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 |
18 | // Sample run-helloworld is a minimal Cloud Run service.
19 | package main
20 |
21 | import (
22 | "fmt"
23 | "log"
24 | "net/http"
25 | "os"
26 | )
27 |
28 | func main() {
29 | log.Print("starting server...")
30 | http.HandleFunc("/", handler)
31 |
32 | // Determine port for HTTP service.
33 | port := os.Getenv("PORT")
34 | if port == "" {
35 | port = "8080"
36 | log.Printf("defaulting to port %s", port)
37 | }
38 |
39 | // Start HTTP server.
40 | log.Printf("listening on port %s", port)
41 | if err := http.ListenAndServe(":"+port, nil); err != nil {
42 | log.Fatal(err)
43 | }
44 | }
45 |
46 | func handler(w http.ResponseWriter, r *http.Request) {
47 | name := os.Getenv("NAME")
48 | if name == "" {
49 | name = "World"
50 | }
51 | fmt.Fprintf(w, "Hello %s!\n", name)
52 | }
```
--------------------------------------------------------------------------------
/lib/util/helpers.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | /**
18 | * Helper function to log a message and call the progress callback.
19 | * @param {string} message - The message to log.
20 | * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
21 | * @param {'debug' | 'info' | 'warn' | 'error'} [severity='info'] - The severity level of the message.
22 | */
23 | export async function logAndProgress(
24 | message,
25 | progressCallback,
26 | severity = 'info'
27 | ) {
28 | switch (severity) {
29 | case 'error':
30 | console.error(message);
31 | break;
32 | case 'warn':
33 | case 'info':
34 | case 'debug':
35 | default:
36 | console.log(message);
37 | break;
38 | }
39 | if (progressCallback) {
40 | progressCallback({ level: severity, data: message });
41 | }
42 | }
43 |
```
--------------------------------------------------------------------------------
/test/local/notifications.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import assert from 'node:assert/strict';
2 | import { describe, it, mock } from 'node:test';
3 | import esmock from 'esmock';
4 |
5 | describe('Tool Notifications', () => {
6 | it('should send notifications during deployment', async () => {
7 | const server = {
8 | registerTool: mock.fn(),
9 | };
10 |
11 | const { registerTools } = await esmock(
12 | '../../tools/tools.js',
13 | {},
14 | {
15 | '../../lib/deployment/deployer.js': {
16 | deploy: () => Promise.resolve({ uri: 'my-uri' }),
17 | },
18 | }
19 | );
20 |
21 | registerTools(server, { gcpCredentialsAvailable: true });
22 |
23 | const handler = server.registerTool.mock.calls.find(
24 | (call) => call.arguments[0] === 'deploy_local_folder'
25 | ).arguments[2];
26 |
27 | const sendNotification = mock.fn();
28 |
29 | await handler(
30 | {
31 | project: 'my-project',
32 | region: 'my-region',
33 | service: 'my-service',
34 | folderPath: '/my/folder',
35 | },
36 | { sendNotification }
37 | );
38 |
39 | assert.strictEqual(sendNotification.mock.callCount(), 1);
40 | assert.deepStrictEqual(sendNotification.mock.calls[0].arguments[0], {
41 | method: 'notifications/message',
42 | params: {
43 | level: 'info',
44 | data: 'Starting deployment of local folder for service my-service in project my-project...',
45 | },
46 | });
47 | });
48 | });
49 |
```
--------------------------------------------------------------------------------
/test/local/npx.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { test, describe } from 'node:test';
2 | import assert from 'node:assert/strict';
3 | import fs from 'node:fs';
4 | import path from 'node:path';
5 | import { fileURLToPath } from 'node:url';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = path.dirname(__filename);
9 | const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json');
10 | const mcpServerPath = path.resolve(__dirname, '..', '..', 'mcp-server.js');
11 |
12 | describe('The repository is properly structured to be executed using npx', () => {
13 | test('package.json should be at the root', () => {
14 | assert.ok(
15 | fs.existsSync(packageJsonPath),
16 | 'package.json not found at the project root'
17 | );
18 | });
19 |
20 | test('package.json should have a bin attribute for cloud-run-mcp', () => {
21 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
22 | assert.ok(packageJson.bin, 'bin attribute not found in package.json');
23 | assert.ok(
24 | packageJson.bin['cloud-run-mcp'],
25 | 'cloud-run-mcp not found in bin attribute'
26 | );
27 | });
28 |
29 | test('mcp-server.js should start with #!/usr/bin/env node', () => {
30 | const mcpServerContent = fs.readFileSync(mcpServerPath, 'utf-8');
31 | assert.ok(
32 | mcpServerContent.startsWith('#!/usr/bin/env node'),
33 | 'mcp-server.js does not start with #!/usr/bin/env node'
34 | );
35 | });
36 | });
37 |
```
--------------------------------------------------------------------------------
/test/local/mcp-server.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { test, describe, before, after } from 'node:test';
2 | import assert from 'node:assert/strict';
3 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
5 |
6 | describe('MCP Server in stdio mode', () => {
7 | let client;
8 | let transport;
9 |
10 | before(async () => {
11 | transport = new StdioClientTransport({
12 | command: 'node',
13 | args: ['mcp-server.js'],
14 | });
15 | client = new Client({
16 | name: 'test-client',
17 | version: '1.0.0',
18 | });
19 | await client.connect(transport);
20 | });
21 |
22 | after(() => {
23 | client.close();
24 | });
25 |
26 | test('should list tools', async () => {
27 | const response = await client.listTools();
28 |
29 | const tools = response.tools;
30 | assert(Array.isArray(tools));
31 | const toolNames = tools.map((t) => t.name);
32 | assert.deepStrictEqual(
33 | toolNames.sort(),
34 | [
35 | 'create_project',
36 | 'deploy_container_image',
37 | 'deploy_file_contents',
38 | 'deploy_local_folder',
39 | 'get_service',
40 | 'get_service_log',
41 | 'list_projects',
42 | 'list_services',
43 | ].sort()
44 | );
45 | });
46 |
47 | test('should list prompts', async () => {
48 | const response = await client.listPrompts();
49 |
50 | const prompts = response.prompts;
51 | assert(Array.isArray(prompts));
52 | const promptNames = prompts.map((p) => p.name);
53 | assert.deepStrictEqual(promptNames.sort(), ['deploy', 'logs'].sort());
54 | });
55 | });
56 |
```
--------------------------------------------------------------------------------
/gemini-extension/GEMINI.md:
--------------------------------------------------------------------------------
```markdown
1 | # Cloud Run MCP Server
2 |
3 | ## Code that can be deployed
4 |
5 | Only web servers can be deployed using this MCP server.
6 | The code needs to listen for HTTP requests on the port defined by the $PORT environment variable or 8080.
7 |
8 | ### Supported languages
9 |
10 | - If the code is in Node.js, Python, Go, Java, .NET, PHP, Ruby, a Dockerfile is not needed.
11 | - If the code is in another language, or has any custom dependency needs, a Dockerfile is needed.
12 |
13 | ### Static-only apps
14 |
15 | To deploy static-only applications, create a Dockerfile that serves these static files. For example using `nginx`:
16 |
17 | `Dockerfile`
18 |
19 | ```
20 | FROM nginx:stable
21 |
22 | COPY ./static /var/www
23 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf
24 |
25 | CMD ["nginx", "-g", "daemon off;"]
26 | ```
27 |
28 | `nginx.conf`:
29 |
30 | ```
31 | server {
32 | listen 8080;
33 | server_name _;
34 |
35 | root /var/www/;
36 | index index.html;
37 |
38 | # Force all paths to load either itself (js files) or go through index.html.
39 | location / {
40 | try_files $uri /index.html;
41 | }
42 | }
43 | ```
44 |
45 | ## Google Cloud pre-requisities
46 |
47 | The user must have an existing Google Cloud account with billing set up, and ideally an existing Google Cloud project.
48 |
49 | If deployment fails because of an access or IAM error, it is likely that the user doesn't have Google Cloud credentials on the local machine.
50 | The user must follow these steps:
51 |
52 | 1. Install the [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) and authenticate with their Google account.
53 |
54 | 2. Set up application credentials using the command:
55 | ```bash
56 | gcloud auth application-default login
57 | ```
58 |
```
--------------------------------------------------------------------------------
/test/local/mcp-server-stdio.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { test, describe, before, after } from 'node:test';
2 | import assert from 'node:assert/strict';
3 | import { spawn } from 'child_process';
4 | import { waitForString } from './test-utils.js';
5 |
6 | describe('MCP Server stdio startup', () => {
7 | let serverProcess;
8 | let stderr = '';
9 | const stdioMsg = 'Cloud Run MCP server stdio transport connected';
10 |
11 | describe('when GCP_STDIO=true', () => {
12 | before(async () => {
13 | stderr = '';
14 | serverProcess = spawn('node', ['mcp-server.js'], {
15 | cwd: process.cwd(),
16 | env: { ...process.env, GCP_STDIO: 'true' },
17 | });
18 | stderr = await waitForString(serverProcess.stderr, stdioMsg);
19 | });
20 |
21 | after(() => {
22 | if (serverProcess) {
23 | serverProcess.kill();
24 | }
25 | });
26 |
27 | test('should start in stdio mode', () => {
28 | assert.ok(stderr.includes(stdioMsg));
29 | });
30 | });
31 |
32 | describe('when GCP_STDIO=false', () => {
33 | before(async () => {
34 | stderr = '';
35 | const env = { ...process.env };
36 | env.GCP_STDIO = 'false';
37 | serverProcess = spawn('node', ['mcp-server.js'], {
38 | cwd: process.cwd(),
39 | env: env,
40 | });
41 | const stderrChunks = [];
42 | serverProcess.stderr.on('data', (chunk) => {
43 | stderrChunks.push(chunk);
44 | });
45 | await new Promise((resolve) => setTimeout(resolve, 2000));
46 | stderr = Buffer.concat(stderrChunks).toString();
47 | });
48 |
49 | after(() => {
50 | if (serverProcess) {
51 | serverProcess.kill();
52 | }
53 | });
54 |
55 | test('should not start in stdio mode', () => {
56 | assert.ok(!stderr.includes(stdioMsg));
57 | });
58 | });
59 | });
60 |
```
--------------------------------------------------------------------------------
/example-sources-to-deploy/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Copyright 2025 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # https://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | # [START cloudrun_helloworld_dockerfile_go]
16 |
17 | # Use the official Go image to create a binary.
18 | # This is based on Debian and sets the GOPATH to /go.
19 | # https://hub.docker.com/_/golang
20 | FROM golang:1.23-bookworm AS builder
21 |
22 | # Create and change to the app directory.
23 | WORKDIR /app
24 |
25 | # Retrieve application dependencies.
26 | # This allows the container build to reuse cached dependencies.
27 | # Expecting to copy go.mod and if present go.sum.
28 | COPY go.* ./
29 | RUN go mod download
30 |
31 | # Copy local code to the container image.
32 | COPY . ./
33 |
34 | # Build the binary.
35 | RUN go build -v -o server
36 |
37 | # Use the official Debian slim image for a lean production container.
38 | # https://hub.docker.com/_/debian
39 | # https://docs.docker.com/develop/develop-images/multistage-build/#use-multi-stage-builds
40 | FROM debian:bookworm-slim
41 | RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
42 | ca-certificates && \
43 | rm -rf /var/lib/apt/lists/*
44 |
45 | # Copy the binary to the production image from the builder stage.
46 | COPY --from=builder /app/server /app/server
47 |
48 | # Run the web service on container startup.
49 | CMD ["/app/server"]
```
--------------------------------------------------------------------------------
/test/local/mcp-server-streamable-http.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3 | import { test, describe, before, after } from 'node:test';
4 | import assert from 'node:assert/strict';
5 | import { spawn } from 'child_process';
6 | import { waitForString } from './test-utils.js';
7 |
8 | class MCPClient {
9 | client = null;
10 | transport = null;
11 |
12 | constructor(serverName) {
13 | this.client = new Client({
14 | name: `mcp-client-for-${serverName}`,
15 | version: '1.0.0',
16 | url: `http://localhost:3000/mcp`,
17 | });
18 | }
19 |
20 | async connectToServer(serverUrl) {
21 | this.transport = new StreamableHTTPClientTransport(serverUrl);
22 | await this.client.connect(this.transport);
23 | }
24 |
25 | async cleanup() {
26 | await this.client.close();
27 | }
28 | }
29 |
30 | describe('MCP Server in Streamble HTTP mode', () => {
31 | let client;
32 | let serverProcess;
33 | let stdout = '';
34 | const httpMsg = 'Cloud Run MCP server listening on port 3000';
35 |
36 | describe('when GCP_STDIO=false', () => {
37 | before(async () => {
38 | stdout = '';
39 | // Start MCP server as a child process
40 | serverProcess = spawn('node', ['mcp-server.js'], {
41 | cwd: process.cwd(),
42 | env: { ...process.env, GCP_STDIO: 'false' },
43 | });
44 | stdout = await waitForString(serverProcess.stdout, httpMsg);
45 |
46 | client = new MCPClient('http-server');
47 | });
48 |
49 | after(async () => {
50 | await client.cleanup();
51 | if (serverProcess) {
52 | serverProcess.kill();
53 | }
54 | });
55 |
56 | test('should start an HTTP server', async () => {
57 | await client.connectToServer('http://localhost:3000/mcp');
58 | assert.ok(stdout.includes(httpMsg));
59 | });
60 | });
61 | });
62 |
```
--------------------------------------------------------------------------------
/tools/tools.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | you may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { z } from 'zod';
18 |
19 | import {
20 | registerListProjectsTool,
21 | registerCreateProjectTool,
22 | registerListServicesTool,
23 | registerGetServiceTool,
24 | registerGetServiceLogTool,
25 | registerDeployLocalFolderTool,
26 | registerDeployFileContentsTool,
27 | registerDeployContainerImageTool,
28 | } from './register-tools.js';
29 |
30 | export const registerTools = (server, options = {}) => {
31 | registerListProjectsTool(server, options);
32 | registerCreateProjectTool(server, options);
33 | registerListServicesTool(server, options);
34 | registerGetServiceTool(server, options);
35 | registerGetServiceLogTool(server, options);
36 | registerDeployLocalFolderTool(server, options);
37 | registerDeployFileContentsTool(server, options);
38 | registerDeployContainerImageTool(server, options);
39 | };
40 |
41 | export const registerToolsRemote = (server, options = {}) => {
42 | // For remote, use the same registration functions but with effective project/region passed in options
43 | registerListServicesTool(server, options);
44 | registerGetServiceTool(server, options);
45 | registerGetServiceLogTool(server, options);
46 | registerDeployFileContentsTool(server, options);
47 | registerDeployContainerImageTool(server, options);
48 | };
49 |
```
--------------------------------------------------------------------------------
/test/local/test-utils.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | /**
18 | * Waits for a specific string to appear in a stream's output.
19 | * This is useful for synchronizing tests with asynchronous child processes,
20 | * ensuring that a server or process has started and emitted an
21 | * expected "ready" message before proceeding with test assertions.
22 | *
23 | * @param {ReadableStream} stream - The stream to listen to (e.g., process.stdout or process.stderr).
24 | * @param {string} str - The string to wait for in the stream's output.
25 | * @param {number} [timeoutMs=7000] - The maximum time in milliseconds to wait before rejecting.
26 | * @returns {Promise<string>} A promise that resolves with the accumulated data
27 | * when the string is found, or rejects if the timeout is reached.
28 | */
29 | export async function waitForString(stream, str, timeoutMs = 7000) {
30 | let accumulatedData = '';
31 | return new Promise((resolve, reject) => {
32 | const timeout = setTimeout(() => {
33 | stream.removeListener('data', onData);
34 | reject(
35 | new Error(`waitForString timed out after ${timeoutMs}ms waiting for "${str}".
36 | Saw:
37 | ${accumulatedData}`)
38 | );
39 | }, timeoutMs);
40 |
41 | function onData(data) {
42 | accumulatedData += data.toString();
43 | if (accumulatedData.includes(str)) {
44 | clearTimeout(timeout);
45 | stream.removeListener('data', onData);
46 | resolve(accumulatedData);
47 | }
48 | }
49 | stream.on('data', onData);
50 | });
51 | }
52 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@google-cloud/cloud-run-mcp",
3 | "version": "1.6.0",
4 | "type": "module",
5 | "description": "Cloud Run MCP deployment tool",
6 | "main": "mcp-server.js",
7 | "bin": {
8 | "cloud-run-mcp": "mcp-server.js"
9 | },
10 | "scripts": {
11 | "deploy": "gcloud run deploy cloud-run-mcp --source . --no-invoker-iam-check",
12 | "test:workflows": "node --test test/need-gcp/workflows/*.test.js",
13 | "test:deploy": "node test/need-gcp/deployer.test.js",
14 | "test:projects": "node test/need-gcp/gcp-projects.test.js",
15 | "test:services": "node test/need-gcp/cloud-run-services.test.js",
16 | "test:gcp-auth": "node test/need-gcp/gcp-auth-check.test.js",
17 | "test:prompts": "node --test test/local/prompts.test.js",
18 | "test:mcp": "node --test test/local/mcp-server.test.js",
19 | "test:tools": "node --test test/local/tools.test.js",
20 | "test:local": "node --test test/local/*.test.js test/local/**/*.test.js",
21 | "test": "c8 --check-coverage npm run test:local",
22 | "mcp-inspector": "npx @modelcontextprotocol/inspector node mcp-server.js",
23 | "start": "node mcp-server.js",
24 | "lint:check": "prettier --check .",
25 | "lint": "prettier --write ."
26 | },
27 | "repository": {
28 | "type": "git",
29 | "url": "git+https://github.com/GoogleCloudPlatform/cloud-run-mcp.git"
30 | },
31 | "keywords": [
32 | "mcp",
33 | "cloud-run",
34 | "gcp"
35 | ],
36 | "author": "[email protected]",
37 | "license": "Apache-2.0",
38 | "bugs": {
39 | "url": "https://github.com/GoogleCloudPlatform/cloud-run-mcp/issues"
40 | },
41 | "homepage": "https://github.com/GoogleCloudPlatform/cloud-run-mcp#readme",
42 | "dependencies": {
43 | "@google-cloud/artifact-registry": "^4.0.1",
44 | "@google-cloud/billing": "^5.0.1",
45 | "@google-cloud/cloudbuild": "^5.0.1",
46 | "@google-cloud/logging": "^11.2.0",
47 | "@google-cloud/resource-manager": "^6.0.1",
48 | "@google-cloud/run": "^2.0.1",
49 | "@google-cloud/service-usage": "^4.1.0",
50 | "@google-cloud/storage": "^7.16.0",
51 | "@modelcontextprotocol/sdk": "^1.24.3",
52 | "archiver": "^7.0.1",
53 | "dotenv": "^17.2.1",
54 | "express": "^5.1.0",
55 | "google-proto-files": "^5.0.0",
56 | "zod": "^3.25.76"
57 | },
58 | "devDependencies": {
59 | "c8": "^10.1.3",
60 | "esmock": "^2.7.1",
61 | "prettier": "3.6.2"
62 | }
63 | }
64 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/metadata.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | // Helper function to fetch data from GCP metadata server
18 | /**
19 | * Fetches metadata from the Google Cloud metadata server.
20 | *
21 | * @param {string} path - The metadata path to fetch (e.g., `/computeMetadata/v1/...`).
22 | * @returns {Promise<string>} A promise that resolves to the metadata value as a string.
23 | * @throws {Error} If the metadata request fails with a non-OK status.
24 | */
25 | async function fetchMetadata(path) {
26 | const response = await fetch(`http://metadata.google.internal${path}`, {
27 | headers: {
28 | 'Metadata-Flavor': 'Google',
29 | },
30 | });
31 | if (!response.ok) {
32 | throw new Error(`Metadata request failed with status ${response.status}`);
33 | }
34 | return await response.text();
35 | }
36 |
37 | /**
38 | * Checks if the GCP metadata server is available and retrieves project ID and region.
39 | * @returns {Promise<Object|null>} A promise that resolves to an object { project: string, region: string } or null if not available or an error occurs.
40 | */
41 | export async function checkGCP() {
42 | try {
43 | const projectId = await fetchMetadata(
44 | '/computeMetadata/v1/project/project-id'
45 | );
46 | // Expected format: projects/PROJECT_NUMBER/regions/REGION_NAME
47 | const regionPath = await fetchMetadata(
48 | '/computeMetadata/v1/instance/region'
49 | );
50 |
51 | if (projectId && regionPath) {
52 | const regionParts = regionPath.split('/');
53 | const region = regionParts[regionParts.length - 1];
54 | return { project: projectId, region: region };
55 | }
56 | return null;
57 | } catch (error) {
58 | // Intentionally suppress error logging for cleaner output if metadata server is not available.
59 | // console.warn('Failed to fetch GCP metadata:', error.message); // Uncomment for debugging
60 | return null;
61 | }
62 | }
63 |
```
--------------------------------------------------------------------------------
/test/need-gcp/deployer.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { describe, test } from 'node:test';
18 | import assert from 'node:assert';
19 | import { deploy, deployImage } from '../../lib/deployment/deployer.js';
20 |
21 | const projectId = process.env.GOOGLE_CLOUD_PROJECT || process.argv[2];
22 | if (!projectId) {
23 | console.error('Usage: node <script> <projectId> or set GOOGLE_CLOUD_PROJECT');
24 | process.exit(1);
25 | }
26 |
27 | describe('Cloud Run Deployments', () => {
28 | test('should fail to deploy without a service name', async () => {
29 | const config = {
30 | projectId: projectId,
31 | region: 'europe-west1',
32 | files: ['example-sources-to-deploy/main.go'],
33 | };
34 | await assert.rejects(deploy(config), {
35 | message: 'Error: serviceName is required in the configuration object.',
36 | });
37 | });
38 |
39 | test('should fail to deploy image without a service name', async () => {
40 | const config = {
41 | projectId: projectId,
42 | region: 'europe-west1',
43 | imageUrl: 'gcr.io/cloudrun/hello',
44 | };
45 | await assert.rejects(deployImage(config), {
46 | message: 'Error: serviceName is required in the configuration object.',
47 | });
48 | });
49 |
50 | test('should fail to deploy without a project id', async () => {
51 | const config = {
52 | serviceName: 'hello-from-image',
53 | region: 'europe-west1',
54 | files: ['example-sources-to-deploy/main.go'],
55 | };
56 | await assert.rejects(deploy(config), {
57 | message: 'Error: projectId is required in the configuration object.',
58 | });
59 | });
60 |
61 | test('should fail to deploy image without a project id', async () => {
62 | const config = {
63 | serviceName: 'hello-from-image',
64 | region: 'europe-west1',
65 | imageUrl: 'gcr.io/cloudrun/hello',
66 | };
67 | await assert.rejects(deployImage(config), {
68 | message: 'Error: projectId is required in the configuration object.',
69 | });
70 | });
71 | });
72 |
```
--------------------------------------------------------------------------------
/test/need-gcp/cloud-run-services.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { test } from 'node:test';
18 | import { getServiceLogs, listServices } from '../../lib/cloud-api/run.js';
19 | import assert from 'node:assert';
20 |
21 | /**
22 | * Get the GCP project ID from GOOGLE_CLOUD_PROJECT or command line argument.
23 | * @returns {string} The GCP project ID
24 | */
25 | function getProjectId() {
26 | const projectId = process.env.GOOGLE_CLOUD_PROJECT || process.argv[2];
27 | if (!projectId) {
28 | console.error(
29 | 'Usage: node <script> <projectId> or set GOOGLE_CLOUD_PROJECT'
30 | );
31 | process.exit(1);
32 | }
33 | console.log(`Using Project ID: ${projectId}`);
34 | return projectId;
35 | }
36 |
37 | /**
38 | * Gets service details from GOOGLE_CLOUD_PROJECT or command line arguments.
39 | * @returns {{projectId: string, region: string, serviceId: string}} The service details
40 | */
41 | async function getServiceDetails() {
42 | const projectId = getProjectId();
43 | let region, serviceId;
44 |
45 | try {
46 | const allServices = await listServices(projectId);
47 |
48 | if (!allServices || Object.keys(allServices).length === 0) {
49 | assert.fail('No services found for the given project.');
50 | }
51 |
52 | const regions = Object.keys(allServices);
53 | region = regions[0];
54 |
55 | if (!allServices[region] || allServices[region].length === 0) {
56 | assert.fail(`No services found in region: ${region}`);
57 | }
58 |
59 | const serviceName = allServices[region][0].name;
60 | serviceId = serviceName.split('/').pop();
61 | console.log(`Using region - ${region} and service ID - ${serviceId}`);
62 |
63 | return { projectId, region, serviceId };
64 | } catch (error) {
65 | // Better error handling for the API call itself
66 | console.error('Error fetching services:', error.message);
67 | throw error; // Re-throw the error to fail the test
68 | }
69 | }
70 |
71 | test('should list services', async () => {
72 | const projectId = getProjectId();
73 | const services = await listServices(projectId);
74 | console.log('Services found:', services ? Object.keys(services) : 'None');
75 | console.log('All Services', services);
76 | });
77 |
78 | test('should fetch service logs', async () => {
79 | const { projectId, region, serviceId } = await getServiceDetails();
80 |
81 | const result = await getServiceLogs(projectId, region, serviceId);
82 |
83 | if (result.logs) {
84 | console.log('\nLog entries:');
85 | console.log(result.logs);
86 | } else {
87 | console.log('No logs found for this service.');
88 | }
89 | });
90 |
```
--------------------------------------------------------------------------------
/test/need-gcp/workflows/deployment-workflows.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import fs from 'fs/promises';
18 | import assert from 'node:assert';
19 | import { test, describe, before, after } from 'node:test';
20 | import path from 'path';
21 |
22 | import { deploy, deployImage } from '../../../lib/deployment/deployer.js';
23 | import {
24 | cleanupProject,
25 | setSourceDeployProjectPermissions,
26 | setupProject,
27 | } from '../test-helpers.js';
28 |
29 | describe('Deployment workflows', () => {
30 | let projectId;
31 |
32 | before(async () => {
33 | try {
34 | projectId = await setupProject();
35 | await setSourceDeployProjectPermissions(projectId);
36 | } catch (err) {
37 | console.error('Error during project creation and setup:', err);
38 | throw err;
39 | }
40 | });
41 |
42 | test('Scenario-1: Starting deployment of hello image...', async () => {
43 | const configImageDeploy = {
44 | projectId: projectId,
45 | serviceName: 'hello-scenario',
46 | region: 'europe-west1',
47 | imageUrl: 'gcr.io/cloudrun/hello',
48 | };
49 | await deployImage(configImageDeploy);
50 |
51 | console.log('Scenario-1: Deployment completed.');
52 | });
53 |
54 | test('Scenario-2: Starting deployment with invalid files...', async () => {
55 | const configFailingBuild = {
56 | projectId: projectId,
57 | serviceName: 'example-failing-app',
58 | region: 'europe-west1',
59 | files: [
60 | {
61 | filename: 'main.txt',
62 | content:
63 | 'This is not a valid application source file and should cause a build failure.',
64 | },
65 | ],
66 | };
67 | await assert.rejects(
68 | deploy(configFailingBuild),
69 | { message: /ERROR: failed to detect: no buildpacks participating/ },
70 | 'Deployment should have failed with a buildpack detection error'
71 | );
72 | });
73 |
74 | test('Scenario-3: Starting deployment of Go app with file content...', async () => {
75 | const mainGoContent = await fs.readFile(
76 | path.resolve('example-sources-to-deploy/main.go'),
77 | 'utf-8'
78 | );
79 | const goModContent = await fs.readFile(
80 | path.resolve('example-sources-to-deploy/go.mod'),
81 | 'utf-8'
82 | );
83 | const configGoWithContent = {
84 | projectId: projectId,
85 | serviceName: 'example-go-app-content',
86 | region: 'europe-west1',
87 | files: [
88 | { filename: 'main.go', content: mainGoContent },
89 | { filename: 'go.mod', content: goModContent },
90 | ],
91 | };
92 | await deploy(configGoWithContent);
93 | console.log('Scenario-3: Deployment completed.');
94 | });
95 |
96 | after(async () => {
97 | // Clean up: delete the project created for tests
98 | cleanupProject(projectId);
99 | });
100 | });
101 |
```
--------------------------------------------------------------------------------
/prompts.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | * Copyright 2025 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * https://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | import { z } from 'zod';
17 |
18 | export const registerPrompts = (server) => {
19 | // Prompts will be registered here.
20 | server.registerPrompt(
21 | 'deploy',
22 | {
23 | description: 'Deploys the current working directory to Cloud Run.',
24 | argsSchema: {
25 | name: z
26 | .string()
27 | .describe(
28 | 'Name of the Cloud Run service to deploy to. Defaults to the name of the current directory'
29 | )
30 | .optional(),
31 | project: z.string().describe('Google Cloud project ID').optional(),
32 | region: z
33 | .string()
34 | .describe('Region where the services are located')
35 | .optional(),
36 | },
37 | },
38 | async ({ name, project, region }) => {
39 | const serviceNamePrompt =
40 | name ||
41 | 'a name for the application based on the current working directory.';
42 | const projectPrompt = project ? ` in project ${project}` : '';
43 | const regionPrompt = region ? ` in region ${region}` : '';
44 |
45 | return {
46 | messages: [
47 | {
48 | role: 'user',
49 | content: {
50 | type: 'text',
51 | text: `Use the deploy_local_folder tool to deploy the current folder${projectPrompt}${regionPrompt}. The service name should be ${serviceNamePrompt}`,
52 | },
53 | },
54 | ],
55 | };
56 | }
57 | );
58 |
59 | server.registerPrompt(
60 | 'logs',
61 | {
62 | description: 'Gets the logs for a Cloud Run service.',
63 | argsSchema: {
64 | service: z
65 | .string()
66 | .describe(
67 | 'Name of the Cloud Run service. Defaults to the name of the current directory.'
68 | )
69 | .optional(),
70 | project: z.string().describe('Google Cloud project ID').optional(),
71 | region: z
72 | .string()
73 | .describe('Region where the services are located')
74 | .optional(),
75 | },
76 | },
77 | async ({ service, project, region }) => {
78 | const serviceNamePrompt =
79 | service || 'named for the current working directory';
80 | const projectPrompt = project ? ` in project ${project}` : '';
81 | const regionPrompt = region ? ` in region ${region}` : '';
82 |
83 | return {
84 | messages: [
85 | {
86 | role: 'user',
87 | content: {
88 | type: 'text',
89 | text: `Use get_service_log to get logs${projectPrompt}${regionPrompt} for the service ${serviceNamePrompt}`,
90 | },
91 | },
92 | ],
93 | };
94 | }
95 | );
96 | };
97 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/auth.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { GoogleAuth } from 'google-auth-library';
18 |
19 | /**
20 | * Checks for the presence of properly setup Google Cloud Platform (GCP) authentication
21 | * using the official Google Auth Library for Node.js. If GCP auth is not found, it logs an
22 | * error message and returns false.
23 | * @async
24 | * @returns {Promise<boolean>} A promise that resolves to true if GCP auth are found, and false otherwise.
25 | */
26 | export async function ensureGCPCredentials() {
27 | console.error('Checking for Google Cloud Application Default Credentials...');
28 | try {
29 | const auth = new GoogleAuth();
30 | // Attempt to get credentials. This will throw an error if ADC are not found.
31 | const client = await auth.getClient();
32 | // Attempt to get an access token to verify credentials are usable.
33 | // This is done because getClient() might succeed but credentials might be invalid/expired.
34 | await client.getAccessToken();
35 |
36 | console.log('Application Default Credentials found.');
37 | return true;
38 | } catch (error) {
39 | console.error(
40 | 'ERROR: Google Cloud Application Default Credentials are not set up.'
41 | );
42 |
43 | if (error.response && error.response.status) {
44 | console.error(
45 | `An HTTP error occurred (Status: ${error.response.status}). This often means misconfigured, expired credentials, or a network issue.`
46 | );
47 | } else if (error instanceof TypeError) {
48 | // Catches TypeErrors specifically, which might indicate a malformed response or unexpected data type
49 | console.error(
50 | 'An unexpected error occurred during credential verification (e.g., malformed response or invalid type).'
51 | );
52 | } else {
53 | // General fallback for any other unexpected errors
54 | console.error(
55 | 'An unexpected error occurred during credential verification.'
56 | );
57 | }
58 |
59 | console.error('\nFor more details or alternative setup methods, consider:');
60 | console.error(
61 | '1. If running locally, run: gcloud auth application-default login.'
62 | );
63 | console.error(
64 | '2. Ensuring the `GOOGLE_APPLICATION_CREDENTIALS` environment variable points to a valid service account key file.'
65 | );
66 | console.error(
67 | '3. If on a Google Cloud environment (e.g., GCE, Cloud Run), verify the associated service account has necessary permissions.'
68 | );
69 | console.error(
70 | `\nOriginal error message from Google Auth Library: ${error.message}`
71 | );
72 |
73 | // Print the stack for debugging
74 | if (error.stack) {
75 | console.error('Error stack:', error.stack);
76 | }
77 | return false;
78 | }
79 | }
80 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/registry.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { callWithRetry } from './helpers.js';
18 | import { logAndProgress } from '../util/helpers.js';
19 |
20 | /**
21 | * Ensures that an Artifact Registry repository exists.
22 | * If the repository does not exist, it attempts to create it with the specified format.
23 | *
24 | * @async
25 | * @param {object} context - The context object containing clients and other parameters.
26 | * @param {string} projectId - The Google Cloud project ID.
27 | * @param {string} location - The Google Cloud region for the repository.
28 | * @param {string} repositoryId - The ID for the Artifact Registry repository.
29 | * @param {string} [format='DOCKER'] - The format of the repository (e.g., 'DOCKER', 'NPM'). Defaults to 'DOCKER'.
30 | * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
31 | * @returns {Promise<object>} A promise that resolves with the Artifact Registry repository object.
32 | * @throws {Error} If there's an error checking or creating the repository.
33 | */
34 | export async function ensureArtifactRegistryRepoExists(
35 | context,
36 | projectId,
37 | location,
38 | repositoryId,
39 | format = 'DOCKER',
40 | progressCallback
41 | ) {
42 | const parent = `projects/${projectId}/locations/${location}`;
43 | const repoPath = context.artifactRegistryClient.repositoryPath(
44 | projectId,
45 | location,
46 | repositoryId
47 | );
48 |
49 | try {
50 | const [repository] = await callWithRetry(
51 | () => context.artifactRegistryClient.getRepository({ name: repoPath }),
52 | `artifactRegistry.getRepository ${repositoryId}`
53 | );
54 | await logAndProgress(
55 | `Repository ${repositoryId} already exists in ${location}.`,
56 | progressCallback
57 | );
58 | return repository;
59 | } catch (error) {
60 | if (error.code === 5) {
61 | await logAndProgress(
62 | `Repository ${repositoryId} does not exist in ${location}. Creating...`,
63 | progressCallback
64 | );
65 | const repositoryToCreate = {
66 | format: format,
67 | };
68 |
69 | const [operation] = await callWithRetry(
70 | () =>
71 | context.artifactRegistryClient.createRepository({
72 | parent: parent,
73 | repository: repositoryToCreate,
74 | repositoryId: repositoryId,
75 | }),
76 | `artifactRegistry.createRepository ${repositoryId}`
77 | );
78 | await logAndProgress(
79 | `Creating Artifact Registry repository ${repositoryId}...`,
80 | progressCallback
81 | );
82 | const [result] = await operation.promise();
83 | await logAndProgress(
84 | `Artifact Registry repository ${result.name} created successfully.`,
85 | progressCallback
86 | );
87 | return result;
88 | } else {
89 | const errorMessage = `Error checking/creating repository ${repositoryId}: ${error.message}`;
90 | console.error(
91 | `Error checking/creating repository ${repositoryId}:`,
92 | error
93 | );
94 | await logAndProgress(errorMessage, progressCallback, 'error');
95 | throw error;
96 | }
97 | }
98 | }
99 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/billing.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | /**
18 | * Lists all accessible Google Cloud Billing Accounts.
19 | * @async
20 | * @function listBillingAccounts
21 | * @returns {Promise<Array<{name: string, displayName: string, open: boolean}>>} A promise that resolves to an array of billing account objects,
22 | * each with 'name', 'displayName', and 'open' status. Returns an empty array on error.
23 | */
24 | export async function listBillingAccounts() {
25 | const { CloudBillingClient } = await import('@google-cloud/billing');
26 | const client = new CloudBillingClient();
27 | try {
28 | const [accounts] = await client.listBillingAccounts();
29 | if (!accounts || accounts.length === 0) {
30 | console.log('No billing accounts found.');
31 | return [];
32 | }
33 | return accounts.map((account) => ({
34 | name: account.name, // e.g., billingAccounts/0X0X0X-0X0X0X-0X0X0X
35 | displayName: account.displayName,
36 | open: account.open,
37 | }));
38 | } catch (error) {
39 | console.error('Error listing GCP billing accounts:', error);
40 | return [];
41 | }
42 | }
43 |
44 | /**
45 | * Attaches a Google Cloud Project to a specified Billing Account.
46 | * @async
47 | * @function attachProjectToBillingAccount
48 | * @param {string} projectId - The ID of the project to attach.
49 | * @param {string} billingAccountName - The resource name of the billing account (e.g., 'billingAccounts/0X0X0X-0X0X0X-0X0X0X').
50 | * @returns {Promise<object|null>} A promise that resolves to the updated project billing information object if successful, or null on error.
51 | */
52 | export async function attachProjectToBillingAccount(
53 | projectId,
54 | billingAccountName
55 | ) {
56 | const { CloudBillingClient } = await import('@google-cloud/billing');
57 | const client = new CloudBillingClient();
58 | const projectName = `projects/${projectId}`;
59 |
60 | if (!projectId) {
61 | console.error('Error: projectId is required.');
62 | return null;
63 | }
64 | if (
65 | !billingAccountName ||
66 | !billingAccountName.startsWith('billingAccounts/')
67 | ) {
68 | console.error(
69 | 'Error: billingAccountName is required and must be in the format "billingAccounts/XXXXXX-XXXXXX-XXXXXX".'
70 | );
71 | return null;
72 | }
73 |
74 | try {
75 | console.log(
76 | `Attempting to attach project ${projectId} to billing account ${billingAccountName}...`
77 | );
78 | const [updatedBillingInfo] = await client.updateProjectBillingInfo({
79 | name: projectName,
80 | projectBillingInfo: {
81 | billingAccountName: billingAccountName,
82 | },
83 | });
84 | console.log(
85 | `Successfully attached project ${projectId} to billing account ${billingAccountName}.`
86 | );
87 | console.log(`Billing enabled: ${updatedBillingInfo.billingEnabled}`);
88 | return updatedBillingInfo;
89 | } catch (error) {
90 | console.error(
91 | `Error attaching project ${projectId} to billing account ${billingAccountName}:`,
92 | error.message || error
93 | );
94 | // Log more details if available, e.g. error.details
95 | // if (error.details) console.error("Error details:", error.details);
96 | return null;
97 | }
98 | }
99 |
```
--------------------------------------------------------------------------------
/lib/util/archive.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { logAndProgress } from './helpers.js';
18 |
19 | /**
20 | * Creates a zip archive in memory from a list of file paths and/or file objects.
21 | * File objects should have `filename` (string) and `content` (Buffer or string) properties.
22 | *
23 | * @param {Array<string|{filename: string, content: Buffer|string}>} files - An array of items to zip.
24 | * Each item can be a string representing a file/directory path, or an object
25 | * with `filename` and `content` properties for in-memory files.
26 | * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
27 | * @returns {Promise<Buffer>} A promise that resolves with a Buffer containing the zip data.
28 | * @throws {Error} If an input file path is not found, an input item has an invalid format, or an archiver error occurs.
29 | */
30 | export async function zipFiles(files, progressCallback) {
31 | const path = await import('path');
32 | const fs = await import('fs');
33 | const archiver = (await import('archiver')).default;
34 |
35 | return new Promise((resolve, reject) => {
36 | logAndProgress('Creating in-memory zip archive...', progressCallback);
37 | const chunks = [];
38 | const archive = archiver('zip', {
39 | zlib: { level: 9 },
40 | });
41 |
42 | archive.on('data', (chunk) => chunks.push(chunk));
43 | archive.on('end', () => {
44 | logAndProgress(
45 | `Files zipped successfully. Total size: ${archive.pointer()} bytes`,
46 | progressCallback
47 | );
48 | resolve(Buffer.concat(chunks));
49 | });
50 |
51 | archive.on('warning', (err) => {
52 | const warningMessage = `Archiver warning: ${err}`;
53 | logAndProgress(warningMessage, progressCallback, 'warn');
54 | if (err.code !== 'ENOENT') {
55 | // ENOENT is often just a warning, others might be more critical for zip
56 | reject(err);
57 | }
58 | });
59 |
60 | archive.on('error', (err) => {
61 | const errorMessage = `Archiver error: ${err.message}`;
62 | console.error(errorMessage, err);
63 | logAndProgress(errorMessage, progressCallback, 'error');
64 | reject(err);
65 | });
66 |
67 | files.forEach((file) => {
68 | if (typeof file === 'object' && 'filename' in file && 'content' in file) {
69 | archive.append(file.content, { name: file.filename });
70 | } else if (typeof file === 'string') {
71 | let pathInput = file;
72 |
73 | // This is a "hack" to better support WSL on Windows. AI agents tend to send path that start with '/c' in that case. Re-write it to '/mnt/c'
74 | if (pathInput.startsWith('/c')) {
75 | pathInput = `/mnt${pathInput}`;
76 | }
77 | const filePath = path.resolve(pathInput);
78 | if (!fs.existsSync(filePath)) {
79 | throw new Error(`File or directory not found: ${filePath}`);
80 | }
81 |
82 | const stats = fs.statSync(filePath);
83 | if (stats.isDirectory()) {
84 | archive.directory(filePath, false);
85 | } else {
86 | archive.file(filePath, { name: path.basename(filePath) });
87 | }
88 | } else {
89 | throw new Error(`Invalid file format: ${JSON.stringify(file)}`);
90 | }
91 | });
92 |
93 | archive.finalize();
94 | });
95 | }
96 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/storage.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { callWithRetry } from './helpers.js';
18 | import { logAndProgress } from '../util/helpers.js';
19 |
20 | /**
21 | * Ensures that a Google Cloud Storage bucket exists.
22 | * If the bucket does not exist, it attempts to create it in the specified location.
23 | *
24 | * @async
25 | * @param {object} context - The context object containing clients and other parameters.
26 | * @param {string} bucketName - The name of the storage bucket.
27 | * @param {string} [location='us'] - The location to create the bucket in if it doesn't exist. Defaults to 'us'.
28 | * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
29 | * @returns {Promise<import('@google-cloud/storage').Bucket>} A promise that resolves with the GCS Bucket object.
30 | * @throws {Error} If there's an error checking or creating the bucket.
31 | */
32 | export async function ensureStorageBucketExists(
33 | context,
34 | bucketName,
35 | location = 'us',
36 | progressCallback
37 | ) {
38 | const bucket = context.storage.bucket(bucketName);
39 | try {
40 | const [exists] = await callWithRetry(
41 | () => bucket.exists(),
42 | `storage.bucket.exists ${bucketName}`
43 | );
44 | if (exists) {
45 | await logAndProgress(
46 | `Bucket ${bucketName} already exists.`,
47 | progressCallback
48 | );
49 | return bucket;
50 | } else {
51 | await logAndProgress(
52 | `Bucket ${bucketName} does not exist. Creating in location ${location}...`,
53 | progressCallback
54 | );
55 | try {
56 | const [createdBucket] = await callWithRetry(
57 | () =>
58 | context.storage.createBucket(bucketName, { location: location }),
59 | `storage.createBucket ${bucketName}`
60 | );
61 | await logAndProgress(
62 | `Storage bucket ${createdBucket.name} created successfully in ${location}.`,
63 | progressCallback
64 | );
65 | return createdBucket;
66 | } catch (createError) {
67 | const errorMessage = `Failed to create storage bucket ${bucketName}. Error details: ${createError.message}`;
68 | console.error(
69 | `Failed to create storage bucket ${bucketName}. Error details:`,
70 | createError
71 | );
72 | await logAndProgress(errorMessage, progressCallback, 'error');
73 | throw createError;
74 | }
75 | }
76 | } catch (error) {
77 | const errorMessage = `Error checking/creating bucket ${bucketName}: ${error.message}`;
78 | console.error(`Error checking/creating bucket ${bucketName}:`, error);
79 | await logAndProgress(errorMessage, progressCallback, 'error');
80 | throw error;
81 | }
82 | }
83 |
84 | /**
85 | * Uploads a buffer to a specified Google Cloud Storage bucket and blob name.
86 | *
87 | * @async
88 | * @param {object} context - The context object containing clients and other parameters.
89 | * @param {import('@google-cloud/storage').Bucket} bucket - The Google Cloud Storage bucket object.
90 | * @param {Buffer} buffer - The buffer containing the data to upload.
91 | * @param {string} destinationBlobName - The name for the blob in the bucket.
92 | * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
93 | * @returns {Promise<import('@google-cloud/storage').File>} A promise that resolves with the GCS File object representing the uploaded blob.
94 | * @throws {Error} If the upload fails.
95 | */
96 | export async function uploadToStorageBucket(
97 | context,
98 | bucket,
99 | buffer,
100 | destinationBlobName,
101 | progressCallback
102 | ) {
103 | try {
104 | await logAndProgress(
105 | `Uploading buffer to gs://${bucket.name}/${destinationBlobName}...`,
106 | progressCallback
107 | );
108 | await callWithRetry(
109 | () => bucket.file(destinationBlobName).save(buffer),
110 | `storage.bucket.file.save ${destinationBlobName}`
111 | );
112 | await logAndProgress(
113 | `File ${destinationBlobName} uploaded successfully to gs://${bucket.name}/${destinationBlobName}.`,
114 | progressCallback
115 | );
116 | return bucket.file(destinationBlobName);
117 | } catch (error) {
118 | const errorMessage = `Error uploading buffer: ${error.message}`;
119 | console.error(`Error uploading buffer:`, error);
120 | await logAndProgress(errorMessage, progressCallback, 'error');
121 | throw error;
122 | }
123 | }
124 |
```
--------------------------------------------------------------------------------
/test/local/prompts.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import assert from 'node:assert/strict';
18 | import { describe, it, mock } from 'node:test';
19 | import { registerPrompts } from '../../prompts.js';
20 |
21 | describe('registerPrompts', () => {
22 | it('should register deploy and logs prompts', () => {
23 | const server = {
24 | registerPrompt: mock.fn(),
25 | };
26 |
27 | registerPrompts(server);
28 |
29 | assert.strictEqual(server.registerPrompt.mock.callCount(), 2);
30 | assert.strictEqual(
31 | server.registerPrompt.mock.calls[0].arguments[0],
32 | 'deploy'
33 | );
34 | assert.strictEqual(
35 | server.registerPrompt.mock.calls[1].arguments[0],
36 | 'logs'
37 | );
38 | });
39 |
40 | describe('deploy prompt', () => {
41 | it('should use the provided name', async () => {
42 | const server = {
43 | registerPrompt: mock.fn(),
44 | };
45 | registerPrompts(server);
46 | const handler = server.registerPrompt.mock.calls[0].arguments[2];
47 | const result = await handler({ name: 'my-service' });
48 | assert.deepStrictEqual(result, {
49 | messages: [
50 | {
51 | role: 'user',
52 | content: {
53 | type: 'text',
54 | text: `Use the deploy_local_folder tool to deploy the current folder. The service name should be my-service`,
55 | },
56 | },
57 | ],
58 | });
59 | });
60 |
61 | it('should use the current directory name', async () => {
62 | const server = {
63 | registerPrompt: mock.fn(),
64 | };
65 | registerPrompts(server);
66 | const handler = server.registerPrompt.mock.calls[0].arguments[2];
67 | const result = await handler({});
68 | const serviceName =
69 | 'a name for the application based on the current working directory.';
70 | assert.deepStrictEqual(result, {
71 | messages: [
72 | {
73 | role: 'user',
74 | content: {
75 | type: 'text',
76 | text: `Use the deploy_local_folder tool to deploy the current folder. The service name should be ${serviceName}`,
77 | },
78 | },
79 | ],
80 | });
81 | });
82 |
83 | it('should use the provided project and region', async () => {
84 | const server = {
85 | registerPrompt: mock.fn(),
86 | };
87 | registerPrompts(server);
88 | const handler = server.registerPrompt.mock.calls[0].arguments[2];
89 | const result = await handler({
90 | name: 'my-service',
91 | project: 'my-project',
92 | region: 'my-region',
93 | });
94 | assert.deepStrictEqual(result, {
95 | messages: [
96 | {
97 | role: 'user',
98 | content: {
99 | type: 'text',
100 | text: `Use the deploy_local_folder tool to deploy the current folder in project my-project in region my-region. The service name should be my-service`,
101 | },
102 | },
103 | ],
104 | });
105 | });
106 | });
107 |
108 | describe('logs prompt', () => {
109 | it('should use the provided service name', async () => {
110 | const server = {
111 | registerPrompt: mock.fn(),
112 | };
113 | registerPrompts(server);
114 | const handler = server.registerPrompt.mock.calls[1].arguments[2];
115 | const result = await handler({ service: 'my-service' });
116 | assert.deepStrictEqual(result, {
117 | messages: [
118 | {
119 | role: 'user',
120 | content: {
121 | type: 'text',
122 | text: `Use get_service_log to get logs for the service my-service`,
123 | },
124 | },
125 | ],
126 | });
127 | });
128 |
129 | it('should use the current directory name for the service name', async () => {
130 | const server = {
131 | registerPrompt: mock.fn(),
132 | };
133 | registerPrompts(server);
134 | const handler = server.registerPrompt.mock.calls[1].arguments[2];
135 | const result = await handler({});
136 | const serviceName = 'named for the current working directory';
137 | assert.deepStrictEqual(result, {
138 | messages: [
139 | {
140 | role: 'user',
141 | content: {
142 | type: 'text',
143 | text: `Use get_service_log to get logs for the service ${serviceName}`,
144 | },
145 | },
146 | ],
147 | });
148 | });
149 |
150 | it('should use the provided project and region', async () => {
151 | const server = {
152 | registerPrompt: mock.fn(),
153 | };
154 | registerPrompts(server);
155 | const handler = server.registerPrompt.mock.calls[1].arguments[2];
156 | const result = await handler({
157 | service: 'my-service',
158 | project: 'my-project',
159 | region: 'my-region',
160 | });
161 | assert.deepStrictEqual(result, {
162 | messages: [
163 | {
164 | role: 'user',
165 | content: {
166 | type: 'text',
167 | text: `Use get_service_log to get logs in project my-project in region my-region for the service my-service`,
168 | },
169 | },
170 | ],
171 | });
172 | });
173 | });
174 | });
175 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/helpers.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | /**
18 | * Calls a function with retry logic for GCP API calls.
19 | * Retries on gRPC error code 7 (PERMISSION_DENIED).
20 | * @param {Function} fn The function to call.
21 | * @param {string} description A description of the function being called, for logging.
22 | * @returns {Promise<any>} The result of the function.
23 | */
24 | export async function callWithRetry(fn, description) {
25 | const maxRetries = 7;
26 | const initialBackoff = 1000; // 1 second
27 | let retries = 0;
28 |
29 | while (true) {
30 | try {
31 | return await fn();
32 | } catch (error) {
33 | if (error.code === 7 && retries < maxRetries) {
34 | retries++;
35 | let backoff;
36 | if (retries === 1) {
37 | backoff = 15000; // 15 seconds for the first retry
38 | } else {
39 | backoff = initialBackoff * Math.pow(2, retries - 2);
40 | }
41 | console.warn(
42 | `API call "${description}" failed with PERMISSION_DENIED. Retrying in ${
43 | backoff / 1000
44 | }s... (attempt ${retries}/${maxRetries})`
45 | );
46 | await new Promise((resolve) => setTimeout(resolve, backoff));
47 | } else {
48 | throw error;
49 | }
50 | }
51 | }
52 | }
53 |
54 | /**
55 | * Checks if a single Google Cloud API is enabled and enables it if not.
56 | *
57 | * @param {object} serviceUsageClient - The Service Usage client.
58 | * @param {string} serviceName - The full name of the service (e.g., 'projects/my-project/services/run.googleapis.com').
59 | * @param {string} api - The API identifier (e.g., 'run.googleapis.com').
60 | * @param {function(string, string=): void} progressCallback - A function to call with progress updates.
61 | * @returns {Promise<void>} A promise that resolves when the API is enabled.
62 | * @throws {Error} If the API fails to enable or if there's an issue checking its status.
63 | */
64 | async function checkAndEnableApi(
65 | serviceUsageClient,
66 | serviceName,
67 | api,
68 | progressCallback
69 | ) {
70 | const [service] = await callWithRetry(
71 | () => serviceUsageClient.getService({ name: serviceName }),
72 | `getService ${api}`
73 | );
74 | if (service.state !== 'ENABLED') {
75 | const message = `API [${api}] is not enabled. Enabling...`;
76 | console.log(message);
77 | if (progressCallback) progressCallback({ level: 'info', data: message });
78 |
79 | const [operation] = await callWithRetry(
80 | () => serviceUsageClient.enableService({ name: serviceName }),
81 | `enableService ${api}`
82 | );
83 | await operation.promise();
84 | }
85 | }
86 |
87 | /**
88 | * Ensures that the specified Google Cloud APIs are enabled for the given project.
89 | * If an API is not enabled, it attempts to enable it. Retries any failure once after 1s.
90 | * Throws an error if an API cannot be enabled.
91 | *
92 | * @async
93 | * @param {object} context - The context object containing clients and other parameters.
94 | * @param {string} projectId - The Google Cloud project ID.
95 | * @param {string[]} apis - An array of API identifiers to check and enable (e.g., 'run.googleapis.com').
96 | * @param {function(string, string=): void} progressCallback - A function to call with progress updates.
97 | * The first argument is the message, the optional second argument is the type ('error', 'warning', 'info').
98 | * @throws {Error} If an API fails to enable or if there's an issue checking its status.
99 | * @returns {Promise<void>} A promise that resolves when all specified APIs are enabled.
100 | */
101 | export async function ensureApisEnabled(
102 | context,
103 | projectId,
104 | apis,
105 | progressCallback
106 | ) {
107 | const message = 'Checking and enabling required APIs...';
108 | console.log(message);
109 | if (progressCallback) progressCallback({ level: 'info', data: message });
110 |
111 | for (const api of apis) {
112 | const serviceName = `projects/${projectId}/services/${api}`;
113 | try {
114 | await checkAndEnableApi(
115 | context.serviceUsageClient,
116 | serviceName,
117 | api,
118 | progressCallback
119 | );
120 | } catch (error) {
121 | // First attempt failed, log a warning and retry once after a delay.
122 | const warnMsg = `Failed to check/enable ${api}, retrying in 1s...`;
123 | console.warn(warnMsg);
124 | if (progressCallback) progressCallback({ level: 'warn', data: warnMsg });
125 |
126 | await new Promise((resolve) => setTimeout(resolve, 1000));
127 | try {
128 | await checkAndEnableApi(
129 | context.serviceUsageClient,
130 | serviceName,
131 | api,
132 | progressCallback
133 | );
134 | } catch (retryError) {
135 | // If the retry also fails, throw an error.
136 | const errorMessage = `Failed to ensure API [${api}] is enabled after retry. Please check manually.`;
137 | console.error(errorMessage, retryError);
138 | if (progressCallback)
139 | progressCallback({ level: 'error', data: errorMessage });
140 | throw new Error(errorMessage);
141 | }
142 | }
143 | }
144 | const successMsg = 'All required APIs are enabled.';
145 | console.log(successMsg);
146 | if (progressCallback) progressCallback({ level: 'info', data: successMsg });
147 | }
148 |
```
--------------------------------------------------------------------------------
/test/need-gcp/test-helpers.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 | import assert from 'node:assert';
17 | import {
18 | createProjectAndAttachBilling,
19 | deleteProject,
20 | generateProjectId,
21 | } from '../../lib/cloud-api/projects.js';
22 | import {
23 | callWithRetry,
24 | ensureApisEnabled,
25 | } from '../../lib/cloud-api/helpers.js';
26 |
27 | /**
28 | * Gets project number from project ID.
29 | * @param {string} projectId
30 | * @returns {Promise<string>} project number
31 | */
32 | export async function getProjectNumber(projectId) {
33 | const { ProjectsClient } = await import('@google-cloud/resource-manager');
34 | const client = new ProjectsClient();
35 | try {
36 | const [project] = await client.getProject({
37 | name: `projects/${projectId}`,
38 | });
39 | // project.name is in format projects/123456
40 | return project.name.split('/')[1];
41 | } catch (error) {
42 | console.error(
43 | `Error getting project number for project ${projectId}:`,
44 | error.message
45 | );
46 | throw error;
47 | }
48 | }
49 |
50 | /**
51 | * Adds an IAM policy binding to a project.
52 | * @param {string} projectId The project ID.
53 | * @param {string} member The member to add, e.g., 'user:[email protected]'.
54 | * @param {string} role The role to grant, e.g., 'roles/viewer'.
55 | */
56 | export async function addIamPolicyBinding(projectId, member, role) {
57 | const { ProjectsClient } = await import('@google-cloud/resource-manager');
58 | const client = new ProjectsClient();
59 |
60 | console.log(
61 | `Adding IAM binding for ${member} with role ${role} to project ${projectId}`
62 | );
63 |
64 | try {
65 | const [policy] = await client.getIamPolicy({
66 | resource: `projects/${projectId}`,
67 | });
68 |
69 | console.log('Current IAM Policy:', JSON.stringify(policy, null, 2));
70 |
71 | // Check if the binding already exists
72 | const binding = policy.bindings.find((b) => b.role === role);
73 | if (binding) {
74 | if (!binding.members.includes(member)) {
75 | binding.members.push(member);
76 | }
77 | } else {
78 | policy.bindings.push({
79 | role: role,
80 | members: [member],
81 | });
82 | }
83 |
84 | console.log('Updated IAM Policy:', JSON.stringify(policy, null, 2));
85 |
86 | // Set the updated policy
87 | await client.setIamPolicy({
88 | resource: `projects/${projectId}`,
89 | policy: policy,
90 | });
91 |
92 | console.log(
93 | `Successfully added IAM binding for ${member} with role ${
94 | role
95 | } to project ${projectId}`
96 | );
97 | } catch (error) {
98 | console.error(
99 | `Error adding IAM policy binding to project ${projectId}:`,
100 | error.message
101 | );
102 | throw error;
103 | }
104 | }
105 |
106 | /**
107 | * Create project, attach billing.
108 | * @returns {Promise<string>} projectId
109 | */
110 | export async function setupProject() {
111 | const projectId = 'test-' + generateProjectId();
112 | console.log(`Generated project ID: ${projectId}`);
113 | const parent = process.env.GCP_PARENT || process.argv[2];
114 | const newProjectResult = await createProjectAndAttachBilling(
115 | projectId,
116 | parent
117 | );
118 | assert(newProjectResult, 'newProjectResult should not be null');
119 | assert(
120 | newProjectResult.projectId,
121 | 'newProjectResult.projectId should not be null'
122 | );
123 | assert(
124 | newProjectResult.billingMessage,
125 | 'newProjectResult.billingMessage should not be null'
126 | );
127 | assert(
128 | newProjectResult.billingMessage.startsWith(
129 | `Project ${newProjectResult.projectId} created successfully.`
130 | ),
131 | 'newProjectResult.billingMessage should start with success message'
132 | );
133 | console.log(`Successfully created project: ${newProjectResult.projectId}`);
134 | console.log(newProjectResult.billingMessage);
135 |
136 | return projectId;
137 | }
138 |
139 | /**
140 | * Delete project
141 | * @param {string} projectId
142 | */
143 | export async function cleanupProject(projectId) {
144 | try {
145 | await deleteProject(projectId);
146 | console.log(`Successfully deleted project: ${projectId}`);
147 | } catch (e) {
148 | console.error(`Failed to delete project ${projectId}:`, e.message);
149 | }
150 | }
151 |
152 | /**
153 | * Enable APIs and set IAM permissions for source deployments.
154 | * Note: This function is only needed for Cloud Build since it uses the compute
155 | * default service account. The compute service account needs the editor role to
156 | * deploy to Cloud Run, which is usually granted by default, but in this case we
157 | * ensure it due to restrictions in some organizations.
158 | * @param {string} projectId
159 | */
160 | export async function setSourceDeployProjectPermissions(projectId) {
161 | const { ServiceUsageClient } = await import('@google-cloud/service-usage');
162 | const serviceUsageClient = new ServiceUsageClient({ projectId });
163 | const context = {
164 | serviceUsageClient: serviceUsageClient,
165 | };
166 | await ensureApisEnabled(context, projectId, ['run.googleapis.com']);
167 | console.log('Adding editor role to Compute SA...');
168 | const projectNumber = await getProjectNumber(projectId);
169 | const member = `serviceAccount:${projectNumber}[email protected]`;
170 | await callWithRetry(
171 | () => addIamPolicyBinding(projectId, member, 'roles/editor'),
172 | `addIamPolicyBinding roles/editor to ${member}`
173 | );
174 | console.log('Compute SA editor role added.');
175 | }
176 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/build.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import { callWithRetry } from './helpers.js';
18 | import { logAndProgress } from '../util/helpers.js';
19 |
20 | const DELAY_WAIT_FOR_BUILD_LOGS = 10000; // 10 seconds delay to allow logs to propagate
21 | const BUILD_LOGS_LINES_TO_FETCH = 100; // Number of log lines to fetch for build logs snippet
22 |
23 | /**
24 | * Triggers a Google Cloud Build job to build a container image from source code in a GCS bucket.
25 | * It uses either a Dockerfile found in the source or Google Cloud Buildpacks if no Dockerfile is present.
26 | * Waits for the build to complete and returns the build result.
27 | *
28 | * @async
29 | * @param {object} context - The context object containing clients and other parameters.
30 | * @param {string} projectId - The Google Cloud project ID.
31 | * @param {string} location - The Google Cloud region for the build.
32 | * @param {string} sourceBucketName - The GCS bucket name where the source code (zip) is stored.
33 | * @param {string} sourceBlobName - The GCS blob name (the zip file) for the source code.
34 | * @param {string} targetRepoName - The name of the target Artifact Registry repository (used for context, not directly in build steps).
35 | * @param {string} targetImageUrl - The full Artifact Registry URL for the image to be built (e.g., `location-docker.pkg.dev/project/repo/image:tag`).
36 | * @param {boolean} hasDockerfile - Indicates whether a Dockerfile is present in the source to guide the build process.
37 | * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
38 | * @returns {Promise<object>} A promise that resolves with the completed Cloud Build object.
39 | * @throws {Error} If the Cloud Build job fails, times out, or encounters an error during initiation or execution.
40 | */
41 | export async function triggerCloudBuild(
42 | context,
43 | projectId,
44 | location,
45 | sourceBucketName,
46 | sourceBlobName,
47 | targetRepoName,
48 | targetImageUrl,
49 | hasDockerfile,
50 | progressCallback
51 | ) {
52 | let buildSteps;
53 |
54 | if (hasDockerfile) {
55 | buildSteps = [
56 | {
57 | name: 'gcr.io/cloud-builders/docker',
58 | args: ['build', '-t', targetImageUrl, '.'],
59 | dir: '/workspace',
60 | },
61 | ];
62 | } else {
63 | buildSteps = [
64 | {
65 | name: 'gcr.io/k8s-skaffold/pack',
66 | entrypoint: 'pack',
67 | args: [
68 | 'build',
69 | targetImageUrl,
70 | '--builder',
71 | 'gcr.io/buildpacks/builder:latest',
72 | ],
73 | dir: '/workspace',
74 | },
75 | ];
76 | }
77 |
78 | const build = {
79 | source: {
80 | storageSource: {
81 | bucket: sourceBucketName,
82 | object: sourceBlobName,
83 | },
84 | },
85 | steps: buildSteps,
86 | images: [targetImageUrl],
87 | };
88 |
89 | await logAndProgress(
90 | `Initiating Cloud Build for gs://${sourceBucketName}/${sourceBlobName} in ${location}...`,
91 | progressCallback
92 | );
93 | const [operation] = await callWithRetry(
94 | () =>
95 | context.cloudBuildClient.createBuild({
96 | projectId: projectId,
97 | build: build,
98 | }),
99 | 'cloudBuild.createBuild'
100 | );
101 |
102 | await logAndProgress(`Cloud Build job started...`, progressCallback);
103 | const buildId = operation.metadata.build.id;
104 | let completedBuild;
105 | while (true) {
106 | const [getBuildOperation] = await callWithRetry(
107 | () =>
108 | context.cloudBuildClient.getBuild({
109 | projectId: projectId,
110 | id: buildId,
111 | }),
112 | `cloudBuild.getBuild ${buildId}`
113 | );
114 | if (
115 | ['SUCCESS', 'FAILURE', 'INTERNAL_ERROR', 'TIMEOUT', 'CANCELLED'].includes(
116 | getBuildOperation.status
117 | )
118 | ) {
119 | completedBuild = getBuildOperation;
120 | break;
121 | }
122 | await logAndProgress(
123 | `Build status: ${getBuildOperation.status}. Waiting...`,
124 | progressCallback,
125 | 'debug'
126 | );
127 | await new Promise((resolve) => setTimeout(resolve, 5000));
128 | }
129 |
130 | if (completedBuild.status === 'SUCCESS') {
131 | await logAndProgress(
132 | `Cloud Build job ${buildId} completed successfully.`,
133 | progressCallback
134 | );
135 | await logAndProgress(
136 | `Image built: ${completedBuild.results.images[0].name}`,
137 | progressCallback
138 | );
139 | return completedBuild;
140 | } else {
141 | const failureMessage = `Cloud Build job ${buildId} failed with status: ${completedBuild.status}`;
142 | await logAndProgress(failureMessage, progressCallback, 'error');
143 | const logsMessage = `Build logs: ${completedBuild.logUrl}`;
144 | await logAndProgress(logsMessage, progressCallback); // Log URL is info, failure is error
145 |
146 | let buildLogsSnippet = `\n\nRefer to Log URL for full details: ${completedBuild.logUrl}`; // Default snippet
147 | try {
148 | const logFilter = `resource.type="build" AND resource.labels.build_id="${buildId}"`;
149 | await logAndProgress(
150 | `Attempting to fetch last ${BUILD_LOGS_LINES_TO_FETCH} log lines for build ${buildId}...`,
151 | progressCallback,
152 | 'debug'
153 | );
154 |
155 | // Wait for a short period to allow logs to propagate
156 | await new Promise((resolve) =>
157 | setTimeout(resolve, DELAY_WAIT_FOR_BUILD_LOGS)
158 | );
159 |
160 | // Fetch the most recent N log entries
161 | const [entries] = await callWithRetry(
162 | () =>
163 | context.loggingClient.getEntries({
164 | filter: logFilter,
165 | orderBy: 'timestamp desc', // Get latest logs first
166 | pageSize: BUILD_LOGS_LINES_TO_FETCH,
167 | }),
168 | `logging.getEntries for build ${buildId}`
169 | );
170 |
171 | if (entries && entries.length > 0) {
172 | // Entries are newest first, reverse for chronological order of the snippet
173 | const logLines = entries.reverse().map((entry) => entry.data || '');
174 | if (logLines.length > 0) {
175 | buildLogsSnippet = `\n\nLast ${logLines.length} log lines from build ${buildId}:\n${logLines.join('\n')}`;
176 | await logAndProgress(
177 | `Successfully fetched snippet of build logs for ${buildId}.`,
178 | progressCallback,
179 | 'info'
180 | );
181 | }
182 | } else {
183 | await logAndProgress(
184 | `No specific log entries retrieved for build ${buildId}. ${buildLogsSnippet}`,
185 | progressCallback,
186 | 'warn'
187 | );
188 | }
189 | } catch (logError) {
190 | console.error(`Error fetching build logs for ${buildId}:`, logError);
191 | await logAndProgress(
192 | `Failed to fetch build logs snippet: ${logError.message}. ${buildLogsSnippet}`,
193 | progressCallback,
194 | 'warn'
195 | );
196 | // buildLogsSnippet already contains the Log URL as a fallback
197 | }
198 | throw new Error(`Build ${buildId} failed.${buildLogsSnippet}`);
199 | }
200 | }
201 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/projects.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import {
18 | listBillingAccounts,
19 | attachProjectToBillingAccount,
20 | } from './billing.js';
21 |
22 | /**
23 | * Lists all accessible Google Cloud Platform projects.
24 | * @async
25 | * @function listProjects
26 | * @returns {Promise<Array<{id: string}>>} A promise that resolves to an array of project objects, each with an 'id' property. Returns an empty array on error.
27 | */
28 | export async function listProjects() {
29 | const { ProjectsClient } = await import('@google-cloud/resource-manager');
30 | const client = new ProjectsClient();
31 | try {
32 | const [projects] = await client.searchProjects();
33 | return projects.map((project) => ({
34 | id: project.projectId,
35 | }));
36 | } catch (error) {
37 | console.error('Error listing GCP projects:', error);
38 | return [];
39 | }
40 | }
41 |
42 | /**
43 | * Creates a compliant GCP project ID in the format 'mcp-cvc-cvc', where 'c' is a consonant and 'v' is a vowel.
44 | * @function generateProjectId
45 | * @returns {string} A randomly generated, compliant GCP project ID in the format 'mcp-cvc-cvc'.
46 | */
47 | export function generateProjectId() {
48 | const consonants = 'bcdfghjklmnpqrstvwxyz';
49 | const vowels = 'aeiou';
50 |
51 | const getRandomChar = (source) =>
52 | source.charAt(Math.floor(Math.random() * source.length));
53 |
54 | const generateCVC = () => {
55 | const c1 = getRandomChar(consonants);
56 | const v = getRandomChar(vowels);
57 | const c2 = getRandomChar(consonants);
58 | return `${c1}${v}${c2}`;
59 | };
60 |
61 | const cvc1 = generateCVC();
62 | const cvc2 = generateCVC();
63 | return `mcp-${cvc1}-${cvc2}`;
64 | }
65 |
66 | /**
67 | * Creates a new Google Cloud Platform project.
68 | * @async
69 | * @function createProject
70 | * @param {string} [projectId] - Optional. The desired ID for the new project. If not provided, a compliant ID will be generated automatically (e.g., app-cvc-cvc).
71 | * @param {string} [parent] - Optional. The resource name of the parent under which the project is to be created. e.g., "organizations/123" or "folders/456".
72 | * @returns {Promise<{projectId: string}|null>} A promise that resolves to an object containing the new project's ID.
73 | */
74 | export async function createProject(projectId, parent) {
75 | const { ProjectsClient } = await import('@google-cloud/resource-manager');
76 | const client = new ProjectsClient();
77 | let projectIdToUse = projectId;
78 |
79 | if (!projectIdToUse) {
80 | projectIdToUse = generateProjectId();
81 | console.log(`Project ID not provided, generated ID: ${projectIdToUse}`);
82 | }
83 |
84 | try {
85 | const projectPayload = { projectId: projectIdToUse, parent };
86 |
87 | console.log(`Attempting to create project with ID: ${projectIdToUse}`);
88 |
89 | const [operation] = await client.createProject({ project: projectPayload });
90 |
91 | const [createdProjectResponse] = await operation.promise();
92 |
93 | console.log(
94 | `Project ${createdProjectResponse.projectId} created successfully.`
95 | );
96 | return {
97 | projectId: createdProjectResponse.projectId,
98 | };
99 | } catch (error) {
100 | console.error(
101 | `Error creating GCP project ${projectIdToUse}:`,
102 | error.message
103 | );
104 | throw error; // Re-throw to be caught by the caller
105 | }
106 | }
107 |
108 | /**
109 | * Creates a new Google Cloud Platform project and attempts to attach it to the first available billing account.
110 | * @async
111 | * @function createProjectAndAttachBilling
112 | * @param {string} [projectIdParam] - Optional. The desired ID for the new project.
113 | * @param {string} [parent] - Optional. The resource name of the parent under which the project is to be created. e.g., "organizations/123" or "folders/456".
114 | * @returns {Promise<{projectId: string, billingMessage: string}>} A promise that resolves to an object containing the project ID and a billing status message.
115 | */
116 | export async function createProjectAndAttachBilling(projectIdParam, parent) {
117 | let newProject;
118 | try {
119 | newProject = await createProject(projectIdParam, parent);
120 | } catch (error) {
121 | throw new Error(`Failed to create project: ${error.message}`);
122 | }
123 |
124 | if (!newProject || !newProject.projectId) {
125 | throw new Error('Project creation did not return a valid project ID.');
126 | }
127 |
128 | const { projectId } = newProject;
129 | let billingMessage = `Project ${projectId} created successfully.`;
130 |
131 | try {
132 | const billingAccounts = await listBillingAccounts();
133 | if (billingAccounts && billingAccounts.length > 0) {
134 | const firstBillingAccount = billingAccounts.find((acc) => acc.open); // Prefer an open account
135 | if (firstBillingAccount) {
136 | console.log(
137 | `Found billing account: ${firstBillingAccount.displayName} (${firstBillingAccount.name}). Attempting to attach project ${projectId}.`
138 | );
139 | const billingInfo = await attachProjectToBillingAccount(
140 | projectId,
141 | firstBillingAccount.name
142 | );
143 | if (billingInfo && billingInfo.billingEnabled) {
144 | billingMessage += ` It has been attached to billing account ${firstBillingAccount.displayName}.`;
145 | } else {
146 | billingMessage += ` However, it could not be attached to billing account ${firstBillingAccount.displayName} or billing not enabled. Please check manually: https://console.cloud.google.com/billing/linkedaccount?project=${projectId}`;
147 | }
148 | } else {
149 | const allBillingAccounts = billingAccounts
150 | .map((b) => `${b.displayName} (Open: ${b.open})`)
151 | .join(', ');
152 | billingMessage += ` However, no open billing accounts were found. Available (may not be usable): ${allBillingAccounts || 'None'}. Please link billing manually: https://console.cloud.google.com/billing/linkedaccount?project=${projectId}`;
153 | }
154 | } else {
155 | billingMessage += ` However, no billing accounts were found. Please link billing manually: https://console.cloud.google.com/billing/linkedaccount?project=${projectId}`;
156 | }
157 | } catch (billingError) {
158 | console.error(
159 | `Error during billing operations for project ${projectId}:`,
160 | billingError
161 | );
162 | billingMessage += ` However, an error occurred during billing operations: ${billingError.message}. Please check manually: https://console.cloud.google.com/billing/linkedaccount?project=${projectId}`;
163 | }
164 |
165 | return { projectId, billingMessage };
166 | }
167 |
168 | /**
169 | * Deletes a Google Cloud Platform project.
170 | * @async
171 | * @function deleteProject
172 | * @param {string} projectId - The ID of the project to delete.
173 | * @returns {Promise<void>} A promise that resolves when the delete operation is initiated.
174 | */
175 | export async function deleteProject(projectId) {
176 | const { ProjectsClient } = await import('@google-cloud/resource-manager');
177 | const client = new ProjectsClient();
178 | try {
179 | console.log(`Attempting to delete project with ID: ${projectId}`);
180 | await client.deleteProject({ name: `projects/${projectId}` });
181 | console.log(`Project ${projectId} deletion initiated successfully.`);
182 | } catch (error) {
183 | console.error(`Error deleting GCP project ${projectId}:`, error.message);
184 | throw error; // Re-throw to be caught by the caller
185 | }
186 | }
187 |
```
--------------------------------------------------------------------------------
/mcp-server.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 |
3 | /*
4 | Copyright 2025 Google LLC
5 |
6 | Licensed under the Apache License, Version 2.0 (the "License");
7 | you may not use this file except in compliance with the License.
8 | You may obtain a copy of the License at
9 |
10 | https://www.apache.org/licenses/LICENSE-2.0
11 |
12 | Unless required by applicable law or agreed to in writing, software
13 | distributed under the License is distributed on an "AS IS" BASIS,
14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | See the License for the specific language governing permissions and
16 | limitations under the License.
17 | */
18 |
19 | import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
20 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
22 | // Support SSE for backward compatibility
23 | import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
24 | // Support stdio, as it is easier to use locally
25 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
26 | import { registerTools, registerToolsRemote } from './tools/tools.js';
27 | import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js';
28 | import { registerPrompts } from './prompts.js';
29 | import { checkGCP } from './lib/cloud-api/metadata.js';
30 | import { ensureGCPCredentials } from './lib/cloud-api/auth.js';
31 | import 'dotenv/config';
32 |
33 | const gcpInfo = await checkGCP();
34 | let gcpCredentialsAvailable = false;
35 |
36 | /**
37 | * Ensure that console.log and console.error are compatible with stdio.
38 | * (Right now, it just disables them)
39 | */
40 | function makeLoggingCompatibleWithStdio() {
41 | // redirect all console.log (which usually go to to stdout) to stderr.
42 | console.log = console.error;
43 | }
44 |
45 | function shouldStartStdio() {
46 | if (process.env.GCP_STDIO === 'false' || (gcpInfo && gcpInfo.project)) {
47 | return false;
48 | }
49 | return true;
50 | }
51 |
52 | if (shouldStartStdio()) {
53 | makeLoggingCompatibleWithStdio();
54 | }
55 |
56 | // Read default configurations from environment variables
57 | const envProjectId = process.env.GOOGLE_CLOUD_PROJECT || undefined;
58 | const envRegion = process.env.GOOGLE_CLOUD_REGION;
59 | const defaultServiceName = process.env.DEFAULT_SERVICE_NAME;
60 | const skipIamCheck = process.env.SKIP_IAM_CHECK !== 'false';
61 | const enableHostValidation = process.env.ENABLE_HOST_VALIDATION === 'true';
62 | const allowedHosts = process.env.ALLOWED_HOSTS
63 | ? process.env.ALLOWED_HOSTS.split(',')
64 | : undefined;
65 |
66 | async function getServer() {
67 | // Create an MCP server with implementation details
68 | const server = new McpServer(
69 | {
70 | name: 'cloud-run',
71 | version: '1.0.0',
72 | },
73 | { capabilities: { logging: {} } }
74 | );
75 |
76 | // this is no-op handler is required for mcp-inspector to function due to a mismatch between the SDK mcp-inspector
77 | server.server.setRequestHandler(SetLevelRequestSchema, (request) => {
78 | console.log(`Log Level: ${request.params.level}`);
79 | return {};
80 | });
81 |
82 | // Get GCP metadata info once
83 | const gcpInfo = await checkGCP();
84 |
85 | // Determine the effective project and region based on priority: Env Var > GCP Metadata > Hardcoded default
86 | const effectiveProjectId =
87 | envProjectId || (gcpInfo && gcpInfo.project) || undefined;
88 | const effectiveRegion =
89 | envRegion || (gcpInfo && gcpInfo.region) || 'europe-west1';
90 |
91 | if (shouldStartStdio() || !(gcpInfo && gcpInfo.project)) {
92 | console.log('Using tools optimized for local or stdio mode.');
93 | // Pass the determined defaults to the local tool registration
94 | await registerTools(server, {
95 | defaultProjectId: effectiveProjectId,
96 | defaultRegion: effectiveRegion,
97 | defaultServiceName,
98 | skipIamCheck,
99 | gcpCredentialsAvailable,
100 | });
101 | } else {
102 | console.log(
103 | `Running on GCP project: ${effectiveProjectId}, region: ${effectiveRegion}. Using tools optimized for remote use.`
104 | );
105 | // Pass the determined defaults to the remote tool registration
106 | await registerToolsRemote(server, {
107 | defaultProjectId: effectiveProjectId,
108 | defaultRegion: effectiveRegion,
109 | defaultServiceName,
110 | skipIamCheck,
111 | gcpCredentialsAvailable,
112 | });
113 | }
114 |
115 | // Register prompts with the server
116 | registerPrompts(server);
117 |
118 | return server;
119 | }
120 |
121 | // stdio
122 | if (shouldStartStdio()) {
123 | gcpCredentialsAvailable = await ensureGCPCredentials();
124 | const stdioTransport = new StdioServerTransport();
125 | const server = await getServer();
126 | await server.connect(stdioTransport);
127 | console.log('Cloud Run MCP server stdio transport connected');
128 | } else {
129 | // non-stdio mode
130 | console.log('Stdio transport mode is turned off.');
131 | gcpCredentialsAvailable = await ensureGCPCredentials();
132 |
133 | const app = enableHostValidation
134 | ? createMcpExpressApp({ allowedHosts })
135 | : createMcpExpressApp({ host: null });
136 |
137 | if (!enableHostValidation) {
138 | console.warn(
139 | `Warning: Server is running without DNS rebinding protection. ` +
140 | 'Consider enabling host validation by passing env variable ENABLE_HOST_VALIDATION=true and adding the ALLOWED_HOSTS to restrict allowed hosts'
141 | );
142 | }
143 |
144 | app.post('/mcp', async (req, res) => {
145 | console.log('/mcp Received:', req.body);
146 | const server = await getServer();
147 | try {
148 | const transport = new StreamableHTTPServerTransport({
149 | sessionIdGenerator: undefined,
150 | });
151 | await server.connect(transport);
152 | await transport.handleRequest(req, res, req.body);
153 | res.on('close', () => {
154 | console.log('Request closed');
155 | transport.close();
156 | server.close();
157 | });
158 | } catch (error) {
159 | console.error('Error handling MCP request:', error);
160 | if (!res.headersSent) {
161 | res.status(500).json({
162 | jsonrpc: '2.0',
163 | error: {
164 | code: -32603,
165 | message: 'Internal server error',
166 | },
167 | id: null,
168 | });
169 | }
170 | }
171 | });
172 |
173 | app.get('/mcp', async (req, res) => {
174 | console.log('Received GET MCP request');
175 | res.writeHead(405).end(
176 | JSON.stringify({
177 | jsonrpc: '2.0',
178 | error: {
179 | code: -32000,
180 | message: 'Method not allowed.',
181 | },
182 | id: null,
183 | })
184 | );
185 | });
186 |
187 | app.delete('/mcp', async (req, res) => {
188 | console.log('Received DELETE MCP request');
189 | res.writeHead(405).end(
190 | JSON.stringify({
191 | jsonrpc: '2.0',
192 | error: {
193 | code: -32000,
194 | message: 'Method not allowed.',
195 | },
196 | id: null,
197 | })
198 | );
199 | });
200 |
201 | // Support SSE for baackward compatibility
202 | const sseTransports = {};
203 |
204 | // Legacy SSE endpoint for older clients
205 | app.get('/sse', async (req, res) => {
206 | console.log('/sse Received:', req.body);
207 | const server = await getServer();
208 | // Create SSE transport for legacy clients
209 | const transport = new SSEServerTransport('/messages', res);
210 | sseTransports[transport.sessionId] = transport;
211 |
212 | res.on('close', () => {
213 | delete sseTransports[transport.sessionId];
214 | });
215 |
216 | await server.connect(transport);
217 | });
218 |
219 | // Legacy message endpoint for older clients
220 | app.post('/messages', async (req, res) => {
221 | console.log('/messages Received:', req.body);
222 | const sessionId = req.query.sessionId;
223 | const transport = sseTransports[sessionId];
224 | if (transport) {
225 | await transport.handlePostMessage(req, res, req.body);
226 | } else {
227 | res.status(400).send('No transport found for sessionId');
228 | }
229 | });
230 |
231 | // Start the server
232 | const PORT = process.env.PORT || 3000;
233 | app.listen(PORT, () => {
234 | console.log(`Cloud Run MCP server listening on port ${PORT}`);
235 | });
236 | }
237 |
238 | // Handle server shutdown
239 | process.on('SIGINT', async () => {
240 | console.log('Shutting down server...');
241 | process.exit(0);
242 | });
243 |
```
--------------------------------------------------------------------------------
/test/need-gcp/gcp-auth-check.test.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES, OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import assert from 'node:assert/strict';
18 | import { describe, it, mock, beforeEach, afterEach } from 'node:test';
19 | import { GoogleAuth } from 'google-auth-library';
20 | import { ensureGCPCredentials } from '../../lib/cloud-api/auth.js';
21 |
22 | describe('ensureGCPCredentials', () => {
23 | let originalConsoleLog;
24 | let originalConsoleError;
25 |
26 | let capturedConsoleOutput;
27 |
28 | let consoleLogMockFn;
29 | let consoleErrorMockFn;
30 |
31 | let getClientMock;
32 | let getAccessTokenMock;
33 |
34 | beforeEach(() => {
35 | // Store original methods
36 | originalConsoleLog = console.log;
37 | originalConsoleError = console.error;
38 |
39 | capturedConsoleOutput = []; // Reset for each test
40 |
41 | // Create mock.fn instances and assign them to global console/process
42 | consoleLogMockFn = mock.fn((...args) => {
43 | capturedConsoleOutput.push(args.join(' '));
44 | });
45 | consoleErrorMockFn = mock.fn((...args) => {
46 | capturedConsoleOutput.push(args.join(' '));
47 | });
48 |
49 | // Overwrite the global console and process methods
50 | console.log = consoleLogMockFn;
51 | console.error = consoleErrorMockFn;
52 |
53 | // Mock GoogleAuth.prototype.getClient and AuthClient.prototype.getAccessToken.
54 | const mockClient = {
55 | getAccessToken: mock.fn(async () => ({ token: 'mock-token' })),
56 | };
57 | getAccessTokenMock = mockClient.getAccessToken;
58 | getClientMock = mock.method(
59 | GoogleAuth.prototype,
60 | 'getClient',
61 | async () => mockClient
62 | );
63 | });
64 |
65 | afterEach(() => {
66 | // Restore original methods
67 | console.log = originalConsoleLog;
68 | console.error = originalConsoleError;
69 |
70 | // Restore other mocks created with `mock.method`
71 | mock.restoreAll();
72 | });
73 |
74 | it('should return true when ADC are found', async () => {
75 | const result = await ensureGCPCredentials();
76 | assert.strictEqual(result, true, 'Should return true on success');
77 |
78 | assert.deepStrictEqual(
79 | capturedConsoleOutput,
80 | [
81 | 'Checking for Google Cloud Application Default Credentials...',
82 | 'Application Default Credentials found.',
83 | ],
84 | 'Console output should indicate successful ADC discovery'
85 | );
86 |
87 | assert.strictEqual(
88 | consoleErrorMockFn.mock.callCount(),
89 | 1,
90 | 'console.error should be called once (checking message)'
91 | );
92 | assert.strictEqual(
93 | consoleLogMockFn.mock.callCount(),
94 | 1,
95 | 'console.log should be called once (for success message)'
96 | );
97 |
98 | assert.strictEqual(
99 | getClientMock.mock.callCount(),
100 | 1,
101 | 'GoogleAuth.getClient should be called once'
102 | );
103 | assert.strictEqual(
104 | getAccessTokenMock.mock.callCount(),
105 | 1,
106 | 'client.getAccessToken should be called once'
107 | );
108 | });
109 |
110 | it('should return false and log error when ADC are not found (generic error)', async () => {
111 | const errorMessage = 'Failed to find credentials.';
112 | const errorWithStack = new Error(errorMessage);
113 | getClientMock.mock.mockImplementation(async () => {
114 | throw errorWithStack;
115 | });
116 |
117 | const result = await ensureGCPCredentials();
118 | assert.strictEqual(result, false, 'Should return false on failure');
119 |
120 | const expectedOutput = [
121 | 'Checking for Google Cloud Application Default Credentials...',
122 | 'ERROR: Google Cloud Application Default Credentials are not set up.',
123 | 'An unexpected error occurred during credential verification.',
124 | '\nFor more details or alternative setup methods, consider:',
125 | '1. If running locally, run: gcloud auth application-default login.',
126 | '2. Ensuring the `GOOGLE_APPLICATION_CREDENTIALS` environment variable points to a valid service account key file.',
127 | '3. If on a Google Cloud environment (e.g., GCE, Cloud Run), verify the associated service account has necessary permissions.',
128 | `\nOriginal error message from Google Auth Library: ${errorMessage}`,
129 | `Error stack: ${errorWithStack.stack}`,
130 | ];
131 | assert.deepStrictEqual(
132 | capturedConsoleOutput,
133 | expectedOutput,
134 | 'Console output should match generic error messages'
135 | );
136 | });
137 |
138 | it('should return false and log HTTP error when google-auth-library throws an HTTP error', async () => {
139 | const httpErrorMessage = 'Request failed with status code 401';
140 | const httpError = new Error(httpErrorMessage);
141 | httpError.response = { status: 401 };
142 | const errorWithStack = httpError;
143 |
144 | getClientMock.mock.mockImplementation(async () => {
145 | throw httpError;
146 | });
147 |
148 | const result = await ensureGCPCredentials();
149 | assert.strictEqual(result, false, 'Should return false on failure');
150 |
151 | const expectedOutput = [
152 | 'Checking for Google Cloud Application Default Credentials...',
153 | 'ERROR: Google Cloud Application Default Credentials are not set up.',
154 | `An HTTP error occurred (Status: 401). This often means misconfigured, expired credentials, or a network issue.`,
155 | '\nFor more details or alternative setup methods, consider:',
156 | '1. If running locally, run: gcloud auth application-default login.',
157 | '2. Ensuring the `GOOGLE_APPLICATION_CREDENTIALS` environment variable points to a valid service account key file.',
158 | '3. If on a Google Cloud environment (e.g., GCE, Cloud Run), verify the associated service account has necessary permissions.',
159 | `\nOriginal error message from Google Auth Library: ${httpErrorMessage}`,
160 | `Error stack: ${errorWithStack.stack}`,
161 | ];
162 | assert.deepStrictEqual(
163 | capturedConsoleOutput,
164 | expectedOutput,
165 | 'Console output should match HTTP error messages'
166 | );
167 | });
168 |
169 | it('should return false and log TypeError when google-auth-library throws a TypeError', async () => {
170 | const typeErrorMessage = 'Unexpected token in JSON at position 0';
171 | const typeError = new TypeError(typeErrorMessage);
172 | const errorWithStack = typeError;
173 |
174 | getClientMock.mock.mockImplementation(async () => {
175 | throw typeError;
176 | });
177 |
178 | const result = await ensureGCPCredentials();
179 | assert.strictEqual(result, false, 'Should return false on failure');
180 |
181 | const expectedOutput = [
182 | 'Checking for Google Cloud Application Default Credentials...',
183 | 'ERROR: Google Cloud Application Default Credentials are not set up.',
184 | 'An unexpected error occurred during credential verification (e.g., malformed response or invalid type).',
185 | '\nFor more details or alternative setup methods, consider:',
186 | '1. If running locally, run: gcloud auth application-default login.',
187 | '2. Ensuring the `GOOGLE_APPLICATION_CREDENTIALS` environment variable points to a valid service account key file.',
188 | '3. If on a Google Cloud environment (e.g., GCE, Cloud Run), verify the associated service account has necessary permissions.',
189 | `\nOriginal error message from Google Auth Library: ${typeErrorMessage}`,
190 | `Error stack: ${errorWithStack.stack}`,
191 | ];
192 | assert.deepStrictEqual(
193 | capturedConsoleOutput,
194 | expectedOutput,
195 | 'Console output should match TypeError messages'
196 | );
197 | });
198 |
199 | it('should return false and log general unexpected error for other errors', async () => {
200 | const unknownErrorMessage = 'Something unexpected happened.';
201 | const unknownError = new Error(unknownErrorMessage);
202 | const errorWithStack = unknownError;
203 |
204 | getClientMock.mock.mockImplementation(async () => {
205 | throw unknownError;
206 | });
207 |
208 | const result = await ensureGCPCredentials();
209 | assert.strictEqual(result, false, 'Should return false on failure');
210 |
211 | const expectedOutput = [
212 | 'Checking for Google Cloud Application Default Credentials...',
213 | 'ERROR: Google Cloud Application Default Credentials are not set up.',
214 | 'An unexpected error occurred during credential verification.',
215 | '\nFor more details or alternative setup methods, consider:',
216 | '1. If running locally, run: gcloud auth application-default login.',
217 | '2. Ensuring the `GOOGLE_APPLICATION_CREDENTIALS` environment variable points to a valid service account key file.',
218 | '3. If on a Google Cloud environment (e.g., GCE, Cloud Run), verify the associated service account has necessary permissions.',
219 | `\nOriginal error message from Google Auth Library: ${unknownErrorMessage}`,
220 | `Error stack: ${errorWithStack.stack}`,
221 | ];
222 | assert.deepStrictEqual(
223 | capturedConsoleOutput,
224 | expectedOutput,
225 | 'Console output should match general error messages'
226 | );
227 | });
228 | });
229 |
```
--------------------------------------------------------------------------------
/lib/cloud-api/run.js:
--------------------------------------------------------------------------------
```javascript
1 | /*
2 | Copyright 2025 Google LLC
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | https://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 | */
16 |
17 | import protofiles from 'google-proto-files';
18 | import { callWithRetry, ensureApisEnabled } from './helpers.js';
19 | import { logAndProgress } from '../util/helpers.js';
20 |
21 | let runClient;
22 | let serviceUsageClient;
23 | let loggingClient;
24 |
25 | async function listCloudRunLocations(projectId) {
26 | const listLocationsRequest = {
27 | name: `projects/${projectId}`,
28 | };
29 |
30 | const availableLocations = [];
31 | try {
32 | console.log('Listing Cloud Run supported locations:');
33 | const iterable = runClient.listLocationsAsync(listLocationsRequest);
34 | for await (const location of iterable) {
35 | if (location.labels.initialized) {
36 | console.log(`${location.locationId}: ${location.name}`);
37 | availableLocations.push(location.locationId);
38 | }
39 | }
40 | } catch (err) {
41 | console.error('Error listing locations:', err);
42 | throw err;
43 | }
44 | return availableLocations;
45 | }
46 |
47 | /**
48 | * Lists all Cloud Run services in a given project.
49 | * @param {string} projectId - The Google Cloud project ID.
50 | * @returns {Promise<object>} - A promise that resolves to an object mapping region to list of service objects in that region.
51 | */
52 | export async function listServices(projectId) {
53 | if (!runClient) {
54 | const { v2 } = await import('@google-cloud/run');
55 | const { ServicesClient } = v2;
56 | const { ServiceUsageClient } = await import('@google-cloud/service-usage');
57 | runClient = new ServicesClient({ projectId });
58 | serviceUsageClient = new ServiceUsageClient({ projectId });
59 | }
60 | const context = {
61 | runClient: runClient,
62 | serviceUsageClient: serviceUsageClient,
63 | };
64 | await ensureApisEnabled(context, projectId, ['run.googleapis.com']);
65 | const locations = await listCloudRunLocations(projectId);
66 |
67 | const allServices = {};
68 | for (const location of locations) {
69 | const parent = runClient.locationPath(projectId, location);
70 |
71 | try {
72 | console.log(
73 | `Listing Cloud Run services in project ${projectId}, location ${location}...`
74 | );
75 | const [services] = await callWithRetry(
76 | () => runClient.listServices({ parent }),
77 | 'listServices'
78 | );
79 | allServices[location] = services;
80 | } catch (error) {
81 | console.error(`Error listing Cloud Run services:`, error);
82 | throw error;
83 | }
84 | }
85 | return allServices;
86 | }
87 |
88 | /**
89 | * Gets details for a specific Cloud Run service.
90 | * @param {string} projectId - The Google Cloud project ID.
91 | * @param {string} location - The Google Cloud location (e.g., 'europe-west1').
92 | * @param {string} serviceId - The ID of the Cloud Run service.
93 | * @returns {Promise<object>} - A promise that resolves to the service object.
94 | */
95 | export async function getService(projectId, location, serviceId) {
96 | if (!runClient) {
97 | const { v2 } = await import('@google-cloud/run');
98 | const { ServicesClient } = v2;
99 | runClient = new ServicesClient({ projectId });
100 | }
101 |
102 | const servicePath = runClient.servicePath(projectId, location, serviceId);
103 |
104 | try {
105 | console.log(
106 | `Getting details for Cloud Run service ${serviceId} in project ${projectId}, location ${location}...`
107 | );
108 | const [service] = await callWithRetry(
109 | () => runClient.getService({ name: servicePath }),
110 | 'getService'
111 | );
112 | return service;
113 | } catch (error) {
114 | console.error(
115 | `Error getting details for Cloud Run service ${serviceId}:`,
116 | error
117 | );
118 | // Check if the error is a "not found" error (gRPC code 5)
119 | if (error.code === 5) {
120 | console.log(`Cloud Run service ${serviceId} not found.`);
121 | return null; // Or throw a custom error, or handle as needed
122 | }
123 | throw error; // Re-throw other errors
124 | }
125 | }
126 |
127 | /**
128 | * Fetches a paginated list of logs for a specific Cloud Run service.
129 | * @param {string} projectId - The Google Cloud project ID.
130 | * @param {string} location - The Google Cloud location (e.g., 'europe-west1').
131 | * @param {string} serviceId - The ID of the Cloud Run service.
132 | * @param {string} [requestOptions] - The token for the next page of results.
133 | * @returns {Promise<{logs: string, requestOptions: object | undefined }>} - A promise that resolves to an object with log entries and a token for the next page.
134 | */
135 | export async function getServiceLogs(
136 | projectId,
137 | location,
138 | serviceId,
139 | requestOptions
140 | ) {
141 | if (!loggingClient) {
142 | const { Logging } = await import('@google-cloud/logging');
143 | loggingClient = new Logging({ projectId });
144 | }
145 |
146 | try {
147 | const LOG_SEVERITY = 'DEFAULT'; // e.g., 'DEFAULT', 'INFO', 'WARNING', 'ERROR'
148 | const PAGE_SIZE = 100; // Number of log entries to retrieve per page
149 |
150 | const filter = `resource.type="cloud_run_revision"
151 | resource.labels.service_name="${serviceId}"
152 | resource.labels.location="${location}"
153 | severity>=${LOG_SEVERITY}`;
154 |
155 | console.log(
156 | `Fetching logs for Cloud Run service ${serviceId} in project ${projectId}, location ${location}...`
157 | );
158 |
159 | // Options for the getEntries API call
160 | const options = requestOptions || {
161 | filter: filter,
162 | orderBy: 'timestamp desc', // Get the latest logs first
163 | pageSize: PAGE_SIZE,
164 | };
165 | console.log(`Request options: ${JSON.stringify(options)}`);
166 |
167 | // getEntries returns the entries and the full API response
168 | const [entries, nextRequestOptions, apiResponse] = await callWithRetry(
169 | () => loggingClient.getEntries(options),
170 | 'getEntries'
171 | );
172 |
173 | const formattedLogLines = entries
174 | .map((entry) => formatLogEntry(entry))
175 | .join('\n');
176 |
177 | // The nextPageToken is available in the apiResponse object
178 | const nextOptions = apiResponse?.nextPageToken
179 | ? nextRequestOptions
180 | : undefined;
181 |
182 | return {
183 | logs: formattedLogLines,
184 | requestOptions: nextOptions,
185 | };
186 | } catch (error) {
187 | console.error(
188 | `Error fetching logs for Cloud Run service ${serviceId}:`,
189 | error
190 | );
191 | throw error;
192 | }
193 | }
194 |
195 | /**
196 | * Formats a single log entry for display.
197 | * @param {object} entry - A log entry object from the Cloud Logging API.
198 | * @returns {string} - A formatted string representation of the log entry.
199 | */
200 | function formatLogEntry(entry) {
201 | const timestampStr = entry.metadata.timestamp.toISOString() || 'N/A';
202 | const severity = entry.metadata.severity || 'N/A';
203 | let responseData = '';
204 | if (entry.metadata.httpRequest) {
205 | const responseMethod = entry.metadata.httpRequest.requestMethod;
206 | const responseCode = entry.metadata.httpRequest.status;
207 | const requestUrl = entry.metadata.httpRequest.requestUrl;
208 | const responseSize = entry.metadata.httpRequest.responseSize;
209 | responseData = `HTTP Request: ${responseMethod} StatusCode: ${responseCode} ResponseSize: ${responseSize} Byte - ${requestUrl}`;
210 | }
211 |
212 | let data = '';
213 | if (entry.data && entry.data.value) {
214 | const protopath = protofiles.getProtoPath(
215 | '../google/cloud/audit/audit_log.proto'
216 | );
217 | const root = protofiles.loadSync(protopath);
218 | const type = root.lookupType('google.cloud.audit.AuditLog');
219 | const value = type.decode(entry.data.value);
220 | data = `${value.methodName}: ${value.status?.message || ''}${value.authenticationInfo?.principalEmail || ''}`;
221 | } else if (entry.data) {
222 | data = entry.data;
223 | }
224 | return `[${timestampStr}] [${severity}] ${responseData} ${data}`;
225 | }
226 |
227 | /**
228 | * Checks if a Cloud Run service already exists.
229 | *
230 | * @async
231 | * @param {object} context - The context object containing clients and other parameters.
232 | * @param {string} projectId - The Google Cloud project ID.
233 | * @param {string} location - The Google Cloud region where the service is located.
234 | * @param {string} serviceId - The ID of the Cloud Run service.
235 | * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
236 | * @returns {Promise<boolean>} A promise that resolves to true if the service exists, false otherwise.
237 | * @throws {Error} If there's an error checking the service (other than not found).
238 | */
239 | export async function checkCloudRunServiceExists(
240 | context,
241 | projectId,
242 | location,
243 | serviceId,
244 | progressCallback
245 | ) {
246 | const parent = context.runClient.locationPath(projectId, location);
247 | const servicePath = context.runClient.servicePath(
248 | projectId,
249 | location,
250 | serviceId
251 | );
252 | try {
253 | await callWithRetry(
254 | () => context.runClient.getService({ name: servicePath }),
255 | `getService ${serviceId}`
256 | );
257 | await logAndProgress(
258 | `Cloud Run service ${serviceId} already exists.`,
259 | progressCallback
260 | );
261 | return true;
262 | } catch (error) {
263 | if (error.code === 5) {
264 | await logAndProgress(
265 | `Cloud Run service ${serviceId} does not exist.`,
266 | progressCallback
267 | );
268 | return false;
269 | }
270 | const errorMessage = `Error checking Cloud Run service ${serviceId}: ${error.message}`;
271 | console.error(`Error checking Cloud Run service ${serviceId}:`, error);
272 | await logAndProgress(errorMessage, progressCallback, 'error');
273 | throw error;
274 | }
275 | }
276 |
```
--------------------------------------------------------------------------------
/test/local/tools.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import assert from 'node:assert/strict';
2 | import { describe, it, mock } from 'node:test';
3 | import esmock from 'esmock';
4 |
5 | describe('registerTools', () => {
6 | it('should register all tools', async () => {
7 | const server = {
8 | registerTool: mock.fn(),
9 | };
10 |
11 | const { registerTools } = await esmock('../../tools/tools.js', {});
12 |
13 | registerTools(server);
14 |
15 | assert.strictEqual(server.registerTool.mock.callCount(), 8);
16 | const toolNames = server.registerTool.mock.calls.map(
17 | (call) => call.arguments[0]
18 | );
19 | assert.deepStrictEqual(
20 | toolNames.sort(),
21 | [
22 | 'create_project',
23 | 'deploy_container_image',
24 | 'deploy_file_contents',
25 | 'deploy_local_folder',
26 | 'get_service',
27 | 'get_service_log',
28 | 'list_projects',
29 | 'list_services',
30 | ].sort()
31 | );
32 | });
33 |
34 | describe('list_projects', () => {
35 | it('should list projects', async () => {
36 | const server = {
37 | registerTool: mock.fn(),
38 | };
39 |
40 | const { registerTools } = await esmock(
41 | '../../tools/tools.js',
42 | {},
43 | {
44 | '../../lib/cloud-api/projects.js': {
45 | listProjects: () =>
46 | Promise.resolve([{ id: 'project1' }, { id: 'project2' }]),
47 | },
48 | }
49 | );
50 |
51 | registerTools(server, { gcpCredentialsAvailable: true });
52 |
53 | const handler = server.registerTool.mock.calls.find(
54 | (call) => call.arguments[0] === 'list_projects'
55 | ).arguments[2];
56 | const result = await handler({});
57 |
58 | assert.deepStrictEqual(result, {
59 | content: [
60 | {
61 | type: 'text',
62 | text: 'Available GCP Projects:\n- project1\n- project2',
63 | },
64 | ],
65 | });
66 | });
67 | });
68 |
69 | describe('create_project', () => {
70 | it('should create a project with a provided id', async () => {
71 | const server = {
72 | registerTool: mock.fn(),
73 | };
74 |
75 | const { registerTools } = await esmock(
76 | '../../tools/tools.js',
77 | {},
78 | {
79 | '../../lib/cloud-api/projects.js': {
80 | createProjectAndAttachBilling: (projectId) =>
81 | Promise.resolve({
82 | projectId: projectId,
83 | billingMessage: `Project ${projectId} created successfully. Billing attached.`,
84 | }),
85 | },
86 | }
87 | );
88 |
89 | registerTools(server, { gcpCredentialsAvailable: true });
90 |
91 | const handler = server.registerTool.mock.calls.find(
92 | (call) => call.arguments[0] === 'create_project'
93 | ).arguments[2];
94 | const result = await handler({ projectId: 'my-project' });
95 |
96 | assert.deepStrictEqual(result, {
97 | content: [
98 | {
99 | type: 'text',
100 | text: 'Project my-project created successfully. Billing attached.',
101 | },
102 | ],
103 | });
104 | });
105 |
106 | it('should create a project with a generated id', async () => {
107 | const server = {
108 | registerTool: mock.fn(),
109 | };
110 |
111 | const { registerTools } = await esmock(
112 | '../../tools/tools.js',
113 | {},
114 | {
115 | '../../lib/cloud-api/projects.js': {
116 | createProjectAndAttachBilling: () =>
117 | Promise.resolve({
118 | projectId: 'generated-project',
119 | billingMessage:
120 | 'Project generated-project created successfully. Billing attached.',
121 | }),
122 | },
123 | }
124 | );
125 |
126 | registerTools(server, { gcpCredentialsAvailable: true });
127 |
128 | const handler = server.registerTool.mock.calls.find(
129 | (call) => call.arguments[0] === 'create_project'
130 | ).arguments[2];
131 | const result = await handler({});
132 |
133 | assert.deepStrictEqual(result, {
134 | content: [
135 | {
136 | type: 'text',
137 | text: 'Project generated-project created successfully. Billing attached.',
138 | },
139 | ],
140 | });
141 | });
142 | });
143 |
144 | describe('list_services', () => {
145 | it('should list services', async () => {
146 | const server = {
147 | registerTool: mock.fn(),
148 | };
149 |
150 | const { registerTools } = await esmock(
151 | '../../tools/tools.js',
152 | {},
153 | {
154 | '../../lib/cloud-api/run.js': {
155 | listServices: () =>
156 | Promise.resolve({
157 | 'my-region1': [
158 | { name: 'service1', uri: 'uri1' },
159 | { name: 'service2', uri: 'uri2' },
160 | ],
161 | 'my-region2': [
162 | { name: 'service3', uri: 'uri3' },
163 | { name: 'service4', uri: 'uri4' },
164 | ],
165 | }),
166 | },
167 | }
168 | );
169 |
170 | registerTools(server, { gcpCredentialsAvailable: true });
171 |
172 | const handler = server.registerTool.mock.calls.find(
173 | (call) => call.arguments[0] === 'list_services'
174 | ).arguments[2];
175 | const result = await handler({
176 | project: 'my-project',
177 | });
178 |
179 | assert.deepStrictEqual(result, {
180 | content: [
181 | {
182 | type: 'text',
183 | text: 'Services in project my-project (location my-region1):\n- service1 (URL: uri1)\n- service2 (URL: uri2)',
184 | },
185 | {
186 | type: 'text',
187 | text: 'Services in project my-project (location my-region2):\n- service3 (URL: uri3)\n- service4 (URL: uri4)',
188 | },
189 | ],
190 | });
191 | });
192 | });
193 |
194 | describe('get_service', () => {
195 | it('should get a service', async () => {
196 | const server = {
197 | registerTool: mock.fn(),
198 | };
199 |
200 | const { registerTools } = await esmock(
201 | '../../tools/tools.js',
202 | {},
203 | {
204 | '../../lib/cloud-api/run.js': {
205 | getService: () =>
206 | Promise.resolve({
207 | name: 'my-service',
208 | uri: 'my-uri',
209 | lastModifier: 'me',
210 | }),
211 | },
212 | }
213 | );
214 |
215 | registerTools(server, { gcpCredentialsAvailable: true });
216 |
217 | const handler = server.registerTool.mock.calls.find(
218 | (call) => call.arguments[0] === 'get_service'
219 | ).arguments[2];
220 | const result = await handler({
221 | project: 'my-project',
222 | region: 'my-region',
223 | service: 'my-service',
224 | });
225 |
226 | assert.deepStrictEqual(result, {
227 | content: [
228 | {
229 | type: 'text',
230 | text: 'Name: my-service\nRegion: my-region\nProject: my-project\nURL: my-uri\nLast deployed by: me',
231 | },
232 | ],
233 | });
234 | });
235 | });
236 |
237 | describe('get_service_log', () => {
238 | it('should get service logs', async () => {
239 | const server = {
240 | registerTool: mock.fn(),
241 | };
242 |
243 | let callCount = 0;
244 | const getServiceLogs = () => {
245 | callCount++;
246 | if (callCount === 1) {
247 | return Promise.resolve({
248 | logs: 'log1\nlog2',
249 | requestOptions: { pageToken: 'nextPage' },
250 | });
251 | }
252 | return Promise.resolve({ logs: 'log3\nlog4', requestOptions: null });
253 | };
254 |
255 | const { registerTools } = await esmock(
256 | '../../tools/tools.js',
257 | {},
258 | {
259 | '../../lib/cloud-api/run.js': {
260 | getServiceLogs: getServiceLogs,
261 | },
262 | }
263 | );
264 |
265 | registerTools(server, { gcpCredentialsAvailable: true });
266 |
267 | const handler = server.registerTool.mock.calls.find(
268 | (call) => call.arguments[0] === 'get_service_log'
269 | ).arguments[2];
270 | const result = await handler({
271 | project: 'my-project',
272 | region: 'my-region',
273 | service: 'my-service',
274 | });
275 |
276 | assert.deepStrictEqual(result, {
277 | content: [
278 | {
279 | type: 'text',
280 | text: 'log1\nlog2\nlog3\nlog4',
281 | },
282 | ],
283 | });
284 | });
285 | });
286 |
287 | describe('deploy_local_folder', () => {
288 | it('should deploy local folder', async () => {
289 | const server = {
290 | registerTool: mock.fn(),
291 | };
292 |
293 | const { registerTools } = await esmock(
294 | '../../tools/tools.js',
295 | {},
296 | {
297 | '../../lib/deployment/deployer.js': {
298 | deploy: () => Promise.resolve({ uri: 'my-uri' }),
299 | },
300 | }
301 | );
302 |
303 | registerTools(server, { gcpCredentialsAvailable: true });
304 |
305 | const handler = server.registerTool.mock.calls.find(
306 | (call) => call.arguments[0] === 'deploy_local_folder'
307 | ).arguments[2];
308 | const result = await handler(
309 | {
310 | project: 'my-project',
311 | region: 'my-region',
312 | service: 'my-service',
313 | folderPath: '/my/folder',
314 | },
315 | { sendNotification: mock.fn() }
316 | );
317 |
318 | assert.deepStrictEqual(result, {
319 | content: [
320 | {
321 | type: 'text',
322 | text: 'Cloud Run service my-service deployed from folder /my/folder in project my-project\nCloud Console: https://console.cloud.google.com/run/detail/my-region/my-service?project=my-project\nService URL: my-uri',
323 | },
324 | ],
325 | });
326 | });
327 | });
328 |
329 | describe('deploy_file_contents', () => {
330 | it('should deploy file contents', async () => {
331 | const server = {
332 | registerTool: mock.fn(),
333 | };
334 |
335 | const { registerTools } = await esmock(
336 | '../../tools/tools.js',
337 | {},
338 | {
339 | '../../lib/deployment/deployer.js': {
340 | deploy: () => Promise.resolve({ uri: 'my-uri' }),
341 | },
342 | }
343 | );
344 |
345 | registerTools(server, { gcpCredentialsAvailable: true });
346 |
347 | const handler = server.registerTool.mock.calls.find(
348 | (call) => call.arguments[0] === 'deploy_file_contents'
349 | ).arguments[2];
350 | const result = await handler(
351 | {
352 | project: 'my-project',
353 | region: 'my-region',
354 | service: 'my-service',
355 | files: [{ filename: 'file1', content: 'content1' }],
356 | },
357 | { sendNotification: mock.fn() }
358 | );
359 |
360 | assert.deepStrictEqual(result, {
361 | content: [
362 | {
363 | type: 'text',
364 | text: 'Cloud Run service my-service deployed in project my-project\nCloud Console: https://console.cloud.google.com/run/detail/my-region/my-service?project=my-project\nService URL: my-uri',
365 | },
366 | ],
367 | });
368 | });
369 | });
370 |
371 | describe('deploy_container_image', () => {
372 | it('should deploy container image', async () => {
373 | const server = {
374 | registerTool: mock.fn(),
375 | };
376 |
377 | const { registerTools } = await esmock(
378 | '../../tools/tools.js',
379 | {},
380 | {
381 | '../../lib/deployment/deployer.js': {
382 | deployImage: () => Promise.resolve({ uri: 'my-uri' }),
383 | },
384 | }
385 | );
386 |
387 | registerTools(server, { gcpCredentialsAvailable: true });
388 |
389 | const handler = server.registerTool.mock.calls.find(
390 | (call) => call.arguments[0] === 'deploy_container_image'
391 | ).arguments[2];
392 | const result = await handler(
393 | {
394 | project: 'my-project',
395 | region: 'my-region',
396 | service: 'my-service',
397 | imageUrl: 'gcr.io/my-project/my-image',
398 | },
399 | { sendNotification: mock.fn() }
400 | );
401 |
402 | assert.deepStrictEqual(result, {
403 | content: [
404 | {
405 | type: 'text',
406 | text: 'Cloud Run service my-service deployed in project my-project\nCloud Console: https://console.cloud.google.com/run/detail/my-region/my-service?project=my-project\nService URL: my-uri',
407 | },
408 | ],
409 | });
410 | });
411 | });
412 |
413 | describe('when gcp credentials are not available', () => {
414 | it('should return an error for all tools', async () => {
415 | const server = {
416 | registerTool: mock.fn(),
417 | };
418 |
419 | const { registerTools } = await esmock('../../tools/tools.js', {});
420 |
421 | registerTools(server, { gcpCredentialsAvailable: false });
422 |
423 | const toolNames = server.registerTool.mock.calls.map(
424 | (call) => call.arguments[0]
425 | );
426 |
427 | for (const toolName of toolNames) {
428 | const handler = server.registerTool.mock.calls.find(
429 | (call) => call.arguments[0] === toolName
430 | ).arguments[2];
431 | const result = await handler({});
432 | assert.deepStrictEqual(result, {
433 | content: [
434 | {
435 | type: 'text',
436 | text: 'GCP credentials are not available. Please configure your environment.',
437 | },
438 | ],
439 | });
440 | }
441 | });
442 | });
443 | });
444 |
```
--------------------------------------------------------------------------------
/test/local/cloud-api/build.test.js:
--------------------------------------------------------------------------------
```javascript
1 | import assert from 'node:assert/strict';
2 | import { describe, it, mock } from 'node:test';
3 | import esmock from 'esmock';
4 |
5 | describe('triggerCloudBuild', () => {
6 | it('should return a successful build and log correct messages', async () => {
7 | const mockBuildId = 'mock-build-id';
8 | const mockSuccessResult = {
9 | id: mockBuildId,
10 | status: 'SUCCESS',
11 | results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
12 | };
13 |
14 | const getBuildMock = mock.fn(() => Promise.resolve([mockSuccessResult]));
15 | const logAndProgressMock = mock.fn();
16 |
17 | const { triggerCloudBuild } = await esmock(
18 | '../../../lib/cloud-api/build.js',
19 | {
20 | '../../../lib/cloud-api/helpers.js': {
21 | callWithRetry: (fn) => fn(), // Directly execute the function
22 | },
23 | '../../../lib/util/helpers.js': {
24 | logAndProgress: logAndProgressMock,
25 | },
26 | }
27 | );
28 |
29 | const context = {
30 | cloudBuildClient: {
31 | createBuild: mock.fn(() =>
32 | Promise.resolve([
33 | {
34 | metadata: {
35 | build: {
36 | id: mockBuildId,
37 | },
38 | },
39 | },
40 | ])
41 | ),
42 | getBuild: getBuildMock,
43 | },
44 | };
45 |
46 | const result = await triggerCloudBuild(
47 | context,
48 | 'mock-project',
49 | 'mock-location',
50 | 'mock-bucket',
51 | 'mock-blob',
52 | 'mock-repo',
53 | 'gcr.io/mock-project/mock-image',
54 | true,
55 | () => {}
56 | );
57 |
58 | assert.deepStrictEqual(result, mockSuccessResult);
59 | assert.strictEqual(
60 | context.cloudBuildClient.createBuild.mock.callCount(),
61 | 1
62 | );
63 | assert.strictEqual(context.cloudBuildClient.getBuild.mock.callCount(), 1);
64 |
65 | const { calls: logCalls } = logAndProgressMock.mock;
66 | assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
67 | assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
68 | assert.match(logCalls[2].arguments[0], /completed successfully/);
69 | assert.match(logCalls[3].arguments[0], /Image built/);
70 | });
71 |
72 | it('should throw an error for a failed build and log correct messages', async () => {
73 | const mockBuildId = 'mock-build-id-failure';
74 | const mockFailureResult = {
75 | id: mockBuildId,
76 | status: 'FAILURE',
77 | logUrl: 'http://mock-log-url.com',
78 | };
79 |
80 | const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
81 | const logAndProgressMock = mock.fn();
82 | const setTimeoutMock = mock.fn((resolve) => resolve());
83 | mock.method(global, 'setTimeout', setTimeoutMock);
84 |
85 | const { triggerCloudBuild } = await esmock(
86 | '../../../lib/cloud-api/build.js',
87 | {
88 | '../../../lib/cloud-api/helpers.js': {
89 | callWithRetry: (fn) => fn(),
90 | },
91 | '../../../lib/util/helpers.js': {
92 | logAndProgress: logAndProgressMock,
93 | },
94 | }
95 | );
96 |
97 | const context = {
98 | cloudBuildClient: {
99 | createBuild: mock.fn(() =>
100 | Promise.resolve([
101 | {
102 | metadata: {
103 | build: {
104 | id: mockBuildId,
105 | },
106 | },
107 | },
108 | ])
109 | ),
110 | getBuild: getBuildMock,
111 | },
112 | loggingClient: {
113 | getEntries: mock.fn(() =>
114 | Promise.resolve([[{ data: 'log line 1' }, { data: 'log line 2' }]])
115 | ),
116 | },
117 | };
118 |
119 | await assert.rejects(
120 | () =>
121 | triggerCloudBuild(
122 | context,
123 | 'mock-project',
124 | 'mock-location',
125 | 'mock-bucket',
126 | 'mock-blob',
127 | 'mock-repo',
128 | 'gcr.io/mock-project/mock-image',
129 | true,
130 | () => {}
131 | ),
132 | (err) => {
133 | assert.match(err.message, /Build mock-build-id-failure failed/);
134 | assert.match(err.message, /log line 1/);
135 | assert.match(err.message, /log line 2/);
136 | return true;
137 | }
138 | );
139 |
140 | assert.strictEqual(
141 | context.cloudBuildClient.createBuild.mock.callCount(),
142 | 1
143 | );
144 | assert.strictEqual(context.cloudBuildClient.getBuild.mock.callCount(), 1);
145 | assert.strictEqual(context.loggingClient.getEntries.mock.callCount(), 1);
146 | assert.strictEqual(setTimeoutMock.mock.callCount(), 1);
147 | assert.strictEqual(setTimeoutMock.mock.calls[0].arguments[1], 10000);
148 |
149 | const { calls: logCalls } = logAndProgressMock.mock;
150 | assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
151 | assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
152 | assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
153 | assert.match(logCalls[3].arguments[0], /Build logs:/);
154 | assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
155 | assert.match(logCalls[5].arguments[0], /Successfully fetched snippet/);
156 | });
157 |
158 | it('should use buildpacks when no Dockerfile is present', async () => {
159 | const mockBuildId = 'mock-build-id-buildpacks';
160 | const mockSuccessResult = {
161 | id: mockBuildId,
162 | status: 'SUCCESS',
163 | results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
164 | };
165 |
166 | const getBuildMock = mock.fn(() => Promise.resolve([mockSuccessResult]));
167 | const createBuildMock = mock.fn(() =>
168 | Promise.resolve([
169 | {
170 | metadata: {
171 | build: {
172 | id: mockBuildId,
173 | },
174 | },
175 | },
176 | ])
177 | );
178 |
179 | const { triggerCloudBuild } = await esmock(
180 | '../../../lib/cloud-api/build.js',
181 | {
182 | '../../../lib/cloud-api/helpers.js': {
183 | callWithRetry: (fn) => fn(),
184 | },
185 | '../../../lib/util/helpers.js': {
186 | logAndProgress: () => {},
187 | },
188 | }
189 | );
190 |
191 | const context = {
192 | cloudBuildClient: {
193 | createBuild: createBuildMock,
194 | getBuild: getBuildMock,
195 | },
196 | };
197 |
198 | await triggerCloudBuild(
199 | context,
200 | 'mock-project',
201 | 'mock-location',
202 | 'mock-bucket',
203 | 'mock-blob',
204 | 'mock-repo',
205 | 'gcr.io/mock-project/mock-image',
206 | false, // hasDockerfile = false
207 | () => {}
208 | );
209 |
210 | assert.strictEqual(createBuildMock.mock.callCount(), 1);
211 | const buildArg = createBuildMock.mock.calls[0].arguments[0].build;
212 | const buildStep = buildArg.steps[0];
213 | assert.strictEqual(buildStep.name, 'gcr.io/k8s-skaffold/pack');
214 | });
215 |
216 | it('should poll for build status until completion', async () => {
217 | const mockBuildId = 'mock-build-id-polling';
218 | const mockWorkingResult = { id: mockBuildId, status: 'WORKING' };
219 | const mockSuccessResult = {
220 | id: mockBuildId,
221 | status: 'SUCCESS',
222 | results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
223 | };
224 |
225 | let getBuildCallCount = 0;
226 | const getBuildMock = mock.fn(() => {
227 | getBuildCallCount++;
228 | if (getBuildCallCount === 1) {
229 | return Promise.resolve([mockWorkingResult]);
230 | }
231 | return Promise.resolve([mockSuccessResult]);
232 | });
233 |
234 | const logAndProgressMock = mock.fn();
235 | const setTimeoutMock = mock.fn((resolve) => resolve());
236 | mock.method(global, 'setTimeout', setTimeoutMock);
237 |
238 | const { triggerCloudBuild } = await esmock(
239 | '../../../lib/cloud-api/build.js',
240 | {
241 | '../../../lib/cloud-api/helpers.js': {
242 | callWithRetry: (fn) => fn(),
243 | },
244 | '../../../lib/util/helpers.js': {
245 | logAndProgress: logAndProgressMock,
246 | },
247 | }
248 | );
249 |
250 | const context = {
251 | cloudBuildClient: {
252 | createBuild: mock.fn(() =>
253 | Promise.resolve([
254 | {
255 | metadata: {
256 | build: {
257 | id: mockBuildId,
258 | },
259 | },
260 | },
261 | ])
262 | ),
263 | getBuild: getBuildMock,
264 | },
265 | };
266 |
267 | await triggerCloudBuild(
268 | context,
269 | 'mock-project',
270 | 'mock-location',
271 | 'mock-bucket',
272 | 'mock-blob',
273 | 'mock-repo',
274 | 'gcr.io/mock-project/mock-image',
275 | true,
276 | () => {}
277 | );
278 |
279 | assert.strictEqual(getBuildMock.mock.callCount(), 2);
280 | assert.strictEqual(setTimeoutMock.mock.callCount(), 1);
281 | assert.strictEqual(setTimeoutMock.mock.calls[0].arguments[1], 5000);
282 | const { calls: logCalls } = logAndProgressMock.mock;
283 | assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
284 | assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
285 | assert.match(logCalls[2].arguments[0], /Build status: WORKING/);
286 | assert.match(logCalls[3].arguments[0], /completed successfully/);
287 | assert.match(logCalls[4].arguments[0], /Image built/);
288 | });
289 |
290 | it('should handle failed build when no logs are found', async () => {
291 | const mockBuildId = 'mock-build-id-no-logs';
292 | const mockFailureResult = {
293 | id: mockBuildId,
294 | status: 'FAILURE',
295 | logUrl: 'http://mock-log-url.com',
296 | };
297 |
298 | const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
299 | const logAndProgressMock = mock.fn();
300 |
301 | const { triggerCloudBuild } = await esmock(
302 | '../../../lib/cloud-api/build.js',
303 | {
304 | '../../../lib/cloud-api/helpers.js': {
305 | callWithRetry: (fn) => fn(),
306 | },
307 | '../../../lib/util/helpers.js': {
308 | logAndProgress: logAndProgressMock,
309 | },
310 | }
311 | );
312 |
313 | const context = {
314 | cloudBuildClient: {
315 | createBuild: mock.fn(() =>
316 | Promise.resolve([
317 | {
318 | metadata: {
319 | build: {
320 | id: mockBuildId,
321 | },
322 | },
323 | },
324 | ])
325 | ),
326 | getBuild: getBuildMock,
327 | },
328 | loggingClient: {
329 | getEntries: mock.fn(() => Promise.resolve([[]])), // No log entries
330 | },
331 | };
332 |
333 | await assert.rejects(
334 | () =>
335 | triggerCloudBuild(
336 | context,
337 | 'mock-project',
338 | 'mock-location',
339 | 'mock-bucket',
340 | 'mock-blob',
341 | 'mock-repo',
342 | 'gcr.io/mock-project/mock-image',
343 | true,
344 | () => {}
345 | ),
346 | (err) => {
347 | assert.match(err.message, /Build mock-build-id-no-logs failed/);
348 | assert.doesNotMatch(err.message, /Last log lines/);
349 | return true;
350 | }
351 | );
352 |
353 | const { calls: logCalls } = logAndProgressMock.mock;
354 | assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
355 | assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
356 | assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
357 | assert.match(logCalls[3].arguments[0], /Build logs:/);
358 | assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
359 | assert.match(logCalls[5].arguments[0], /No specific log entries retrieved/);
360 | });
361 |
362 | it('should handle error when fetching logs for a failed build', async () => {
363 | const mockBuildId = 'mock-build-id-log-error';
364 | const mockFailureResult = {
365 | id: mockBuildId,
366 | status: 'FAILURE',
367 | logUrl: 'http://mock-log-url.com',
368 | };
369 |
370 | const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
371 | const logAndProgressMock = mock.fn();
372 |
373 | const { triggerCloudBuild } = await esmock(
374 | '../../../lib/cloud-api/build.js',
375 | {
376 | '../../../lib/cloud-api/helpers.js': {
377 | callWithRetry: (fn) => fn(),
378 | },
379 | '../../../lib/util/helpers.js': {
380 | logAndProgress: logAndProgressMock,
381 | },
382 | }
383 | );
384 |
385 | const context = {
386 | cloudBuildClient: {
387 | createBuild: mock.fn(() =>
388 | Promise.resolve([
389 | {
390 | metadata: {
391 | build: {
392 | id: mockBuildId,
393 | },
394 | },
395 | },
396 | ])
397 | ),
398 | getBuild: getBuildMock,
399 | },
400 | loggingClient: {
401 | getEntries: mock.fn(() => Promise.reject(new Error('Log fetch error'))),
402 | },
403 | };
404 |
405 | await assert.rejects(
406 | () =>
407 | triggerCloudBuild(
408 | context,
409 | 'mock-project',
410 | 'mock-location',
411 | 'mock-bucket',
412 | 'mock-blob',
413 | 'mock-repo',
414 | 'gcr.io/mock-project/mock-image',
415 | true,
416 | () => {}
417 | ),
418 | (err) => {
419 | assert.match(err.message, /Build mock-build-id-log-error failed/);
420 | assert.doesNotMatch(err.message, /Last log lines/);
421 | return true;
422 | }
423 | );
424 |
425 | const { calls: logCalls } = logAndProgressMock.mock;
426 | assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
427 | assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
428 | assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
429 | assert.match(logCalls[3].arguments[0], /Build logs:/);
430 | assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
431 | assert.match(
432 | logCalls[5].arguments[0],
433 | /Failed to fetch build logs snippet/
434 | );
435 | });
436 | });
437 |
```