@google-cloud/cloud-run-mcp 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +24 -0
  2. package/SECURITY.md +7 -0
  3. package/cloudbuild.yaml +37 -0
  4. package/example-sources-to-deploy/java/pom.xml +41 -0
  5. package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
  6. package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
  7. package/example-sources-to-deploy/nodejs/index.js +15 -0
  8. package/example-sources-to-deploy/nodejs/package.json +12 -0
  9. package/example-sources-to-deploy/python/pip-project/main.py +11 -0
  10. package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
  11. package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
  12. package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
  13. package/gemini-extension.json +12 -0
  14. package/lib/clients.js +104 -3
  15. package/lib/cloud-api/build.js +192 -28
  16. package/lib/cloud-api/run.js +62 -0
  17. package/lib/cloud-api/secrets.js +196 -0
  18. package/lib/cloud-api/storage.js +164 -16
  19. package/lib/deployment/compose.js +517 -0
  20. package/lib/deployment/constants.js +11 -0
  21. package/lib/deployment/deployer.js +334 -30
  22. package/lib/deployment/helpers.js +99 -49
  23. package/lib/deployment/source-processor.js +0 -48
  24. package/lib/deployment/universal-maker.js +147 -0
  25. package/lib/util/archive.js +3 -3
  26. package/lib/util/artifacts.js +189 -0
  27. package/lib/util/helpers.js +109 -0
  28. package/mcp-server.js +4 -0
  29. package/package.json +6 -3
  30. package/skills/cloud-run/SKILL.md +109 -0
  31. package/test/local/archive.test.js +88 -0
  32. package/test/local/clients.test.js +224 -2
  33. package/test/local/cloud-api/build.test.js +330 -25
  34. package/test/local/cloud-api/run.test.js +129 -0
  35. package/test/local/cloud-api/secrets.test.js +291 -0
  36. package/test/local/cloud-api/storage.test.js +405 -0
  37. package/test/local/compose.test.js +618 -0
  38. package/test/local/deploy.test.js +408 -0
  39. package/test/local/deployment-helpers.test.js +126 -79
  40. package/test/local/source-processor.test.js +0 -165
  41. package/test/local/test-utils.js +2 -18
  42. package/test/local/tools.test.js +43 -1
  43. package/test/local/universal-maker.test.js +157 -0
  44. package/test/local/util.test.js +361 -0
  45. package/test/need-gcp/deployer.test.js +1 -1
  46. package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
  47. package/tools/register-tools.js +19 -3
  48. /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
  49. /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
  50. /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
@@ -0,0 +1,361 @@
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, describe, mock } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import esmock from 'esmock';
20
+ import path from 'path';
21
+ import fs from 'fs';
22
+
23
+ describe('Artifact Utilities', () => {
24
+ const fsMock = {
25
+ existsSync: mock.fn(),
26
+ mkdirSync: mock.fn(),
27
+ chmodSync: mock.fn(),
28
+ readFileSync: mock.fn(),
29
+ unlinkSync: mock.fn(),
30
+ createWriteStream: mock.fn(),
31
+ };
32
+
33
+ const cryptoMock = {
34
+ createHash: mock.fn(),
35
+ };
36
+
37
+ const helpersMock = {
38
+ logAndProgress: mock.fn(),
39
+ };
40
+
41
+ const clientsMock = {
42
+ getArtifactRegistryClient: mock.fn(),
43
+ };
44
+
45
+ const artifactParams = {
46
+ project: 'test-project',
47
+ location: 'test-location',
48
+ repository: 'test-repo',
49
+ artifactPath: 'test-path:1.0:bin',
50
+ displayName: 'Test Binary',
51
+ };
52
+
53
+ const sha256 =
54
+ 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
55
+
56
+ test('isBinaryUpToDate returns true if hashes match', async () => {
57
+ fsMock.existsSync.mock.mockImplementation(() => true);
58
+ fsMock.readFileSync.mock.mockImplementation(() => 'local-content');
59
+ cryptoMock.createHash.mock.mockImplementation(() => ({
60
+ update: mock.fn(() => ({
61
+ digest: mock.fn(() => sha256),
62
+ })),
63
+ }));
64
+
65
+ const artifactRegistryClientMock = {
66
+ filePath: mock.fn(() => 'resource-name'),
67
+ getFile: mock.fn(() => [
68
+ {
69
+ hashes: [
70
+ {
71
+ type: 'SHA256',
72
+ value: Buffer.from(sha256, 'hex').toString('base64'),
73
+ },
74
+ ],
75
+ },
76
+ ]),
77
+ };
78
+ clientsMock.getArtifactRegistryClient.mock.mockImplementation(
79
+ () => artifactRegistryClientMock
80
+ );
81
+
82
+ const { isBinaryUpToDate } = await esmock('../../lib/util/artifacts.js', {
83
+ fs: fsMock,
84
+ crypto: cryptoMock,
85
+ '../../lib/util/helpers.js': helpersMock,
86
+ '../../lib/clients.js': clientsMock,
87
+ });
88
+
89
+ const result = await isBinaryUpToDate(
90
+ '/bin/path',
91
+ artifactParams,
92
+ 'token',
93
+ mock.fn()
94
+ );
95
+ assert.strictEqual(result, true);
96
+ });
97
+
98
+ test('isBinaryUpToDate returns false and unlinks if hashes do not match', async () => {
99
+ fsMock.existsSync.mock.mockImplementation(() => true);
100
+ fsMock.readFileSync.mock.mockImplementation(() => 'local-content');
101
+ fsMock.unlinkSync.mock.resetCalls();
102
+
103
+ cryptoMock.createHash.mock.mockImplementation(() => ({
104
+ update: mock.fn(() => ({
105
+ digest: mock.fn(() => 'different-hash'),
106
+ })),
107
+ }));
108
+
109
+ const artifactRegistryClientMock = {
110
+ filePath: mock.fn(() => 'resource-name'),
111
+ getFile: mock.fn(() => [
112
+ {
113
+ hashes: [
114
+ {
115
+ type: 'SHA256',
116
+ value: Buffer.from(sha256, 'hex').toString('base64'),
117
+ },
118
+ ],
119
+ },
120
+ ]),
121
+ };
122
+ clientsMock.getArtifactRegistryClient.mock.mockImplementation(
123
+ () => artifactRegistryClientMock
124
+ );
125
+
126
+ const { isBinaryUpToDate } = await esmock('../../lib/util/artifacts.js', {
127
+ fs: fsMock,
128
+ crypto: cryptoMock,
129
+ '../../lib/util/helpers.js': helpersMock,
130
+ '../../lib/clients.js': clientsMock,
131
+ });
132
+
133
+ const result = await isBinaryUpToDate(
134
+ '/bin/path',
135
+ artifactParams,
136
+ 'token',
137
+ mock.fn()
138
+ );
139
+ assert.strictEqual(result, false);
140
+ assert.strictEqual(fsMock.unlinkSync.mock.callCount(), 1);
141
+ });
142
+
143
+ test('ensureRepositoryDownloaded downloads binary if it does not exist', async () => {
144
+ fsMock.existsSync.mock.mockImplementation((p) => {
145
+ if (p === '/bin/path') return false;
146
+ return true;
147
+ });
148
+ fsMock.createWriteStream.mock.resetCalls();
149
+
150
+ const artifactRegistryClientMock = {
151
+ filePath: mock.fn(() => 'resource-name'),
152
+ getFile: mock.fn(() => [{ hashes: [] }]),
153
+ auth: {
154
+ request: mock.fn(() => ({
155
+ data: {
156
+ pipe: (dest) => {
157
+ setTimeout(() => dest.emit('finish'), 10);
158
+ return dest;
159
+ },
160
+ },
161
+ })),
162
+ },
163
+ };
164
+ clientsMock.getArtifactRegistryClient.mock.mockImplementation(
165
+ () => artifactRegistryClientMock
166
+ );
167
+
168
+ const writeStreamMock = {
169
+ on: mock.fn(function (event, cb) {
170
+ if (event === 'finish') this.finishCb = cb;
171
+ return this;
172
+ }),
173
+ emit: mock.fn(function (event) {
174
+ if (event === 'finish' && this.finishCb) this.finishCb();
175
+ }),
176
+ };
177
+ fsMock.createWriteStream.mock.mockImplementation(() => writeStreamMock);
178
+
179
+ const { ensureRepositoryDownloaded } = await esmock(
180
+ '../../lib/util/artifacts.js',
181
+ {
182
+ fs: fsMock,
183
+ crypto: cryptoMock,
184
+ '../../lib/util/helpers.js': helpersMock,
185
+ '../../lib/clients.js': clientsMock,
186
+ }
187
+ );
188
+
189
+ const result = await ensureRepositoryDownloaded(
190
+ '/bin/path',
191
+ artifactParams,
192
+ 'token',
193
+ mock.fn()
194
+ );
195
+ assert.strictEqual(result, '/bin/path');
196
+ assert.strictEqual(fsMock.createWriteStream.mock.callCount(), 1);
197
+ });
198
+ });
199
+
200
+ describe('Utility Helpers', () => {
201
+ test('extractAccessToken correctly extracts token from Bearer header', async () => {
202
+ const { extractAccessToken } = await import('../../lib/util/helpers.js');
203
+
204
+ assert.equal(extractAccessToken('Bearer my-token'), 'my-token');
205
+ assert.equal(extractAccessToken(''), undefined);
206
+ assert.equal(extractAccessToken(null), undefined);
207
+ });
208
+
209
+ describe('getProjectNumber', () => {
210
+ test('extracts project number from name in projects/NNNN format', async () => {
211
+ const getProjectMock = mock.fn(async () => [
212
+ { name: 'projects/123456789' },
213
+ ]);
214
+ const getProjectsClientMock = mock.fn(async () => ({
215
+ getProject: getProjectMock,
216
+ }));
217
+
218
+ const { getProjectNumber } = await esmock('../../lib/util/helpers.js', {
219
+ '../../lib/clients.js': {
220
+ getProjectsClient: getProjectsClientMock,
221
+ },
222
+ });
223
+
224
+ const projectId = 'test-project';
225
+ const accessToken = 'test-token';
226
+ const result = await getProjectNumber(projectId, accessToken);
227
+
228
+ assert.equal(result, '123456789');
229
+ assert.equal(getProjectsClientMock.mock.callCount(), 1);
230
+ assert.equal(
231
+ getProjectMock.mock.calls[0].arguments[0].name,
232
+ 'projects/test-project'
233
+ );
234
+ });
235
+
236
+ test('falls back to projectNumber property if name structure is unexpected', async () => {
237
+ const getProjectMock = mock.fn(async () => [
238
+ { name: 'unexpected', projectNumber: '987654321' },
239
+ ]);
240
+ const getProjectsClientMock = mock.fn(async () => ({
241
+ getProject: getProjectMock,
242
+ }));
243
+
244
+ const { getProjectNumber } = await esmock('../../lib/util/helpers.js', {
245
+ '../../lib/clients.js': {
246
+ getProjectsClient: getProjectsClientMock,
247
+ },
248
+ });
249
+
250
+ const result = await getProjectNumber('test-project', 'test-token');
251
+
252
+ assert.equal(result, '987654321');
253
+ });
254
+ });
255
+
256
+ describe('calculateSourceFingerprint', () => {
257
+ test('calculates SHA256 of directory content deterministically', async () => {
258
+ const fsMock = {
259
+ promises: {
260
+ readdir: mock.fn(async (dir) => {
261
+ if (dir === '/src') {
262
+ return [
263
+ {
264
+ name: 'b.txt',
265
+ isFile: () => true,
266
+ isDirectory: () => false,
267
+ },
268
+ {
269
+ name: 'a.txt',
270
+ isFile: () => true,
271
+ isDirectory: () => false,
272
+ },
273
+ {
274
+ name: 'subdir',
275
+ isFile: () => false,
276
+ isDirectory: () => true,
277
+ },
278
+ ];
279
+ }
280
+ if (dir === '/src/subdir') {
281
+ return [
282
+ {
283
+ name: 'c.txt',
284
+ isFile: () => true,
285
+ isDirectory: () => false,
286
+ },
287
+ ];
288
+ }
289
+ return [];
290
+ }),
291
+ readFile: mock.fn(
292
+ async (file) => `content of ${path.basename(file)}`
293
+ ),
294
+ },
295
+ };
296
+
297
+ const { calculateSourceFingerprint } = await esmock(
298
+ '../../lib/util/helpers.js',
299
+ {
300
+ 'node:fs': { promises: fsMock.promises },
301
+ }
302
+ );
303
+
304
+ const result = await calculateSourceFingerprint('/src');
305
+ assert.ok(result);
306
+ assert.equal(typeof result, 'string');
307
+ assert.equal(result.length, 64); // SHA256 hex length
308
+
309
+ // Verify readdir calls (recursive)
310
+ assert.equal(fsMock.promises.readdir.mock.callCount(), 2);
311
+ });
312
+ });
313
+
314
+ describe('sanitizeCloudRunServiceName', () => {
315
+ test('sanitizes various inputs correctly', async () => {
316
+ const { sanitizeCloudRunServiceName } =
317
+ await import('../../lib/util/helpers.js');
318
+
319
+ assert.equal(
320
+ sanitizeCloudRunServiceName('My-Service_Name'),
321
+ 'my-service-name'
322
+ );
323
+ assert.equal(sanitizeCloudRunServiceName('123service'), 's-123service');
324
+ assert.equal(sanitizeCloudRunServiceName('-start-end-'), 'start-end');
325
+ assert.equal(
326
+ sanitizeCloudRunServiceName('a'.repeat(100)),
327
+ 'a'.repeat(49)
328
+ );
329
+ assert.equal(
330
+ sanitizeCloudRunServiceName('Special!@#$%Chars'),
331
+ 'special-chars'
332
+ );
333
+ });
334
+ });
335
+
336
+ describe('sanitizeLabelValue', () => {
337
+ test('sanitizes various inputs correctly', async () => {
338
+ const { sanitizeLabelValue } = await import('../../lib/util/helpers.js');
339
+
340
+ assert.equal(sanitizeLabelValue(''), '');
341
+ assert.equal(sanitizeLabelValue(null), '');
342
+ assert.equal(sanitizeLabelValue(undefined), '');
343
+ assert.equal(sanitizeLabelValue('valid-label'), 'valid-label');
344
+ assert.equal(sanitizeLabelValue('Upper-Case'), 'upper-case');
345
+ assert.equal(sanitizeLabelValue('invalid@char'), 'invalid-char');
346
+ assert.equal(
347
+ sanitizeLabelValue('1starts-with-number'),
348
+ '1starts-with-number'
349
+ );
350
+ assert.equal(
351
+ sanitizeLabelValue('-starts-with-dash'),
352
+ '-starts-with-dash'
353
+ );
354
+ assert.equal(sanitizeLabelValue('a'.repeat(70)), 'a'.repeat(63));
355
+ assert.equal(sanitizeLabelValue(12345), '12345');
356
+ assert.equal(sanitizeLabelValue(0), '0');
357
+ assert.equal(sanitizeLabelValue(true), 'true');
358
+ assert.equal(sanitizeLabelValue(false), 'false');
359
+ });
360
+ });
361
+ });
@@ -51,7 +51,7 @@ describe('Cloud Run Deployments', () => {
51
51
  const config = {
52
52
  serviceName: 'hello-from-image',
53
53
  region: 'europe-west1',
54
- files: ['example-sources-to-deploy/main.go'],
54
+ files: ['example-sources-to-deploy/golang/main.go'],
55
55
  };
56
56
  await assert.rejects(deploy(config), {
57
57
  message: 'Error: projectId is required in the configuration object.',
@@ -26,6 +26,27 @@ import {
26
26
  setupProject,
27
27
  } from '../test-helpers.js';
28
28
 
29
+ const GCP_REGION = 'asia-southeast1';
30
+ const RUN_INGRESS_POLICY = process.env.RUN_INGRESS_POLICY || undefined;
31
+ const ZIP_DEPLOY_SUCCESS_MESSAGE =
32
+ 'Deployment completed successfully with zip deploy';
33
+ const SOURCE_BUILD_DEPLOY_SUCCESS_MESSAGE =
34
+ 'Deployment completed successfully with source build deploy';
35
+
36
+ async function assertDeploymentSuccess(config, expectedMessage) {
37
+ let successMessage = '';
38
+ config.progressCallback = (p) => {
39
+ if (p.data === expectedMessage) {
40
+ successMessage = p.data;
41
+ }
42
+ };
43
+ if (RUN_INGRESS_POLICY) {
44
+ config.ingress = RUN_INGRESS_POLICY;
45
+ }
46
+ await deploy(config);
47
+ assert.strictEqual(successMessage, expectedMessage);
48
+ }
49
+
29
50
  describe('Deployment workflows', () => {
30
51
  let projectId;
31
52
 
@@ -33,6 +54,8 @@ describe('Deployment workflows', () => {
33
54
  try {
34
55
  projectId = await setupProject();
35
56
  await setSourceDeployProjectPermissions(projectId);
57
+ console.log('Waiting 2 minutes for IAM propagation...');
58
+ await new Promise((resolve) => setTimeout(resolve, 120000));
36
59
  } catch (err) {
37
60
  console.error('Error during project creation and setup:', err);
38
61
  throw err;
@@ -43,9 +66,12 @@ describe('Deployment workflows', () => {
43
66
  const configImageDeploy = {
44
67
  projectId: projectId,
45
68
  serviceName: 'hello-scenario',
46
- region: 'us-central1',
69
+ region: GCP_REGION,
47
70
  imageUrl: 'gcr.io/cloudrun/hello',
48
71
  };
72
+ if (RUN_INGRESS_POLICY) {
73
+ configImageDeploy.ingress = RUN_INGRESS_POLICY;
74
+ }
49
75
  await deployImage(configImageDeploy);
50
76
 
51
77
  console.log('Scenario-1: Deployment completed.');
@@ -55,7 +81,7 @@ describe('Deployment workflows', () => {
55
81
  const configFailingBuild = {
56
82
  projectId: projectId,
57
83
  serviceName: 'example-failing-app',
58
- region: 'us-central1',
84
+ region: GCP_REGION,
59
85
  files: [
60
86
  {
61
87
  filename: 'main.txt',
@@ -64,6 +90,9 @@ describe('Deployment workflows', () => {
64
90
  },
65
91
  ],
66
92
  };
93
+ if (RUN_INGRESS_POLICY) {
94
+ configFailingBuild.ingress = RUN_INGRESS_POLICY;
95
+ }
67
96
  await assert.rejects(
68
97
  deploy(configFailingBuild),
69
98
  { message: /ERROR: failed to detect: no buildpacks participating/ },
@@ -73,26 +102,163 @@ describe('Deployment workflows', () => {
73
102
 
74
103
  test('Scenario-3: Starting deployment of Go app with file content...', async () => {
75
104
  const mainGoContent = await fs.readFile(
76
- path.resolve('example-sources-to-deploy/main.go'),
105
+ path.resolve('example-sources-to-deploy/golang/main.go'),
77
106
  'utf-8'
78
107
  );
79
108
  const goModContent = await fs.readFile(
80
- path.resolve('example-sources-to-deploy/go.mod'),
109
+ path.resolve('example-sources-to-deploy/golang/go.mod'),
81
110
  'utf-8'
82
111
  );
83
112
  const configGoWithContent = {
84
113
  projectId: projectId,
85
114
  serviceName: 'example-go-app-content',
86
- region: 'us-central1',
115
+ region: GCP_REGION,
87
116
  files: [
88
117
  { filename: 'main.go', content: mainGoContent },
89
118
  { filename: 'go.mod', content: goModContent },
90
119
  ],
91
120
  };
92
- await deploy(configGoWithContent);
121
+ await assertDeploymentSuccess(
122
+ configGoWithContent,
123
+ SOURCE_BUILD_DEPLOY_SUCCESS_MESSAGE
124
+ );
93
125
  console.log('Scenario-3: Deployment completed.');
94
126
  });
95
127
 
128
+ test('Scenario-4: Starting deployment of pip-based Python app with folder path... uses zip deploy', async () => {
129
+ const configPipProject = {
130
+ projectId: projectId,
131
+ serviceName: 'example-pip-project-folder-path',
132
+ region: GCP_REGION,
133
+ files: ['example-sources-to-deploy/python/pip-project'],
134
+ };
135
+ await assertDeploymentSuccess(configPipProject, ZIP_DEPLOY_SUCCESS_MESSAGE);
136
+ console.log('Scenario-4: Deployment completed.');
137
+ });
138
+
139
+ test('Scenario-5: Starting deployment of pip-based Python app with file-based content... uses source build deploy', async () => {
140
+ const mainPyContent = await fs.readFile(
141
+ path.resolve('example-sources-to-deploy/python/pip-project/main.py'),
142
+ 'utf-8'
143
+ );
144
+ const requirementsTxtContent = await fs.readFile(
145
+ path.resolve(
146
+ 'example-sources-to-deploy/python/pip-project/requirements.txt'
147
+ ),
148
+ 'utf-8'
149
+ );
150
+ const configPipProject = {
151
+ projectId: projectId,
152
+ serviceName: 'example-pip-project-file-content',
153
+ region: GCP_REGION,
154
+ files: [
155
+ { filename: 'main.py', content: mainPyContent },
156
+ { filename: 'requirements.txt', content: requirementsTxtContent },
157
+ ],
158
+ };
159
+ await assertDeploymentSuccess(
160
+ configPipProject,
161
+ SOURCE_BUILD_DEPLOY_SUCCESS_MESSAGE
162
+ );
163
+ console.log('Scenario-5: Deployment completed.');
164
+ });
165
+
166
+ test('Scenario-6: Starting deployment of pyproject-based Python app with folder path... uses zip deploy', async () => {
167
+ const configPyprojectProject = {
168
+ projectId: projectId,
169
+ serviceName: 'example-pyproject-project-folder-path',
170
+ region: GCP_REGION,
171
+ files: ['example-sources-to-deploy/python/pyproject-project'],
172
+ };
173
+ await assertDeploymentSuccess(
174
+ configPyprojectProject,
175
+ ZIP_DEPLOY_SUCCESS_MESSAGE
176
+ );
177
+ console.log('Scenario-6: Deployment completed.');
178
+ });
179
+
180
+ test('Scenario-7: Starting deployment of pyproject-based Python app with file-based content... uses source build deploy', async () => {
181
+ const mainPyContent = await fs.readFile(
182
+ path.resolve(
183
+ 'example-sources-to-deploy/python/pyproject-project/main.py'
184
+ ),
185
+ 'utf-8'
186
+ );
187
+ const pyprojectContent = await fs.readFile(
188
+ path.resolve(
189
+ 'example-sources-to-deploy/python/pyproject-project/pyproject.toml'
190
+ ),
191
+ 'utf-8'
192
+ );
193
+ const configPyprojectProject = {
194
+ projectId: projectId,
195
+ serviceName: 'example-pyproject-project-file-content',
196
+ region: GCP_REGION,
197
+ files: [
198
+ { filename: 'main.py', content: mainPyContent },
199
+ { filename: 'pyproject.toml', content: pyprojectContent },
200
+ ],
201
+ };
202
+ await assertDeploymentSuccess(
203
+ configPyprojectProject,
204
+ SOURCE_BUILD_DEPLOY_SUCCESS_MESSAGE
205
+ );
206
+ console.log('Scenario-7: Deployment completed.');
207
+ });
208
+
209
+ test('Scenario-8: Starting deployment of Node.js app with folder path... uses zip deploy', async () => {
210
+ const configNodeProject = {
211
+ projectId: projectId,
212
+ serviceName: 'example-node-project-folder-path',
213
+ region: GCP_REGION,
214
+ files: ['example-sources-to-deploy/nodejs'],
215
+ };
216
+ await assertDeploymentSuccess(
217
+ configNodeProject,
218
+ ZIP_DEPLOY_SUCCESS_MESSAGE
219
+ );
220
+ console.log('Scenario-8: Deployment completed.');
221
+ });
222
+
223
+ test('Scenario-9: Starting deployment of Node.js app with file content... uses build deploy', async () => {
224
+ const packageJsonContent = await fs.readFile(
225
+ path.resolve('example-sources-to-deploy/nodejs/package.json'),
226
+ 'utf-8'
227
+ );
228
+ const indexJsContent = await fs.readFile(
229
+ path.resolve('example-sources-to-deploy/nodejs/index.js'),
230
+ 'utf-8'
231
+ );
232
+ const configNodeWithContent = {
233
+ projectId: projectId,
234
+ serviceName: 'example-node-project-file-content',
235
+ region: GCP_REGION,
236
+ files: [
237
+ { filename: 'package.json', content: packageJsonContent },
238
+ { filename: 'index.js', content: indexJsContent },
239
+ ],
240
+ };
241
+ await assertDeploymentSuccess(
242
+ configNodeWithContent,
243
+ SOURCE_BUILD_DEPLOY_SUCCESS_MESSAGE
244
+ );
245
+ console.log('Scenario-9: Deployment completed.');
246
+ });
247
+
248
+ test('Scenario-10: Starting deployment of Java app with folder path... uses source build deploy', async () => {
249
+ const configJavaProject = {
250
+ projectId: projectId,
251
+ serviceName: 'example-java-project-folder-path',
252
+ region: GCP_REGION,
253
+ files: ['example-sources-to-deploy/java'],
254
+ };
255
+ await assertDeploymentSuccess(
256
+ configJavaProject,
257
+ SOURCE_BUILD_DEPLOY_SUCCESS_MESSAGE
258
+ );
259
+ console.log('Scenario-10: Deployment completed.');
260
+ });
261
+
96
262
  after(async () => {
97
263
  // Clean up: delete the project created for tests
98
264
  cleanupProject(projectId);
@@ -241,11 +241,21 @@ function registerGetServiceTool(server, options) {
241
241
  options.accessToken
242
242
  );
243
243
  if (serviceDetails) {
244
+ // Format last deployment date, handling both Protobuf Timestamp (with
245
+ // seconds as string or number) and standard Date/ISO strings.
246
+ const lastDeployedDate = serviceDetails.updateTime
247
+ ? new Date(
248
+ serviceDetails.updateTime.seconds
249
+ ? Number(serviceDetails.updateTime.seconds) * 1000
250
+ : serviceDetails.updateTime
251
+ ).toUTCString()
252
+ : 'N/A';
253
+ const consoleUrl = `https://console.cloud.google.com/run/detail/${region}/${service}?project=${project}`;
244
254
  return {
245
255
  content: [
246
256
  {
247
257
  type: 'text',
248
- text: `Name: ${service}\nRegion: ${region}\nProject: ${project}\nURL: ${serviceDetails.uri}\nLast deployed by: ${serviceDetails.lastModifier}`,
258
+ text: `Name: ${service}\nRegion: ${region}\nProject: ${project}\nURL: ${serviceDetails.uri}\nConsole URL: ${consoleUrl}\nLast deployed by: ${serviceDetails.lastModifier}\nLast deployed date: ${lastDeployedDate}`,
249
259
  },
250
260
  ],
251
261
  };
@@ -366,7 +376,9 @@ function registerDeployLocalFolderTool(server, options) {
366
376
  .string()
367
377
  .optional()
368
378
  .default(options.defaultServiceName)
369
- .describe('Name of the Cloud Run service to deploy to'),
379
+ .describe(
380
+ 'Name of the Cloud Run service to deploy to. If not provided, it will be inferred.'
381
+ ),
370
382
  folderPath: z
371
383
  .string()
372
384
  .describe(
@@ -405,13 +417,15 @@ function registerDeployLocalFolderTool(server, options) {
405
417
  files: [folderPath],
406
418
  skipIamCheck: options.skipIamCheck, // Pass the new flag
407
419
  accessToken: options.accessToken,
420
+ ingress: options.ingress,
408
421
  progressCallback,
409
422
  });
423
+ const serviceName = service || response.service;
410
424
  return {
411
425
  content: [
412
426
  {
413
427
  type: 'text',
414
- text: `Cloud Run service ${service} deployed from folder ${folderPath} in project ${project}\nCloud Console: https://console.cloud.google.com/run/detail/${region}/${service}?project=${project}\nService URL: ${response.uri}`,
428
+ text: `Cloud Run service ${serviceName} deployed from folder ${folderPath} in project ${project}\nCloud Console: https://console.cloud.google.com/run/detail/${region}/${serviceName}?project=${project}\nService URL: ${response.uri}`,
415
429
  },
416
430
  ],
417
431
  };
@@ -507,6 +521,7 @@ function registerDeployFileContentsTool(server, options) {
507
521
  files: files,
508
522
  skipIamCheck: options.skipIamCheck, // Pass the new flag
509
523
  accessToken: options.accessToken,
524
+ ingress: options.ingress,
510
525
  progressCallback,
511
526
  });
512
527
  return {
@@ -588,6 +603,7 @@ function registerDeployContainerImageTool(server, options) {
588
603
  imageUrl: imageUrl,
589
604
  skipIamCheck: options.skipIamCheck,
590
605
  accessToken: options.accessToken,
606
+ ingress: options.ingress,
591
607
  progressCallback,
592
608
  });
593
609
  return {