@google-cloud/cloud-run-mcp 1.2.0 → 1.3.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.
- package/CONTRIBUTING.md +2 -0
- package/README.md +3 -3
- package/gemini-extension.json +1 -1
- package/lib/cloud-run-deploy.js +86 -159
- package/lib/cloud-run-services.js +16 -4
- package/lib/gcp-api-helpers.js +143 -0
- package/lib/gcp-projects.js +1 -1
- package/package.json +1 -1
- package/test/local/gemini-extension.test.js +35 -0
- package/test/local/npx.test.js +36 -0
- /package/gemini-extension/{Gemini.md → GEMINI.md} +0 -0
package/CONTRIBUTING.md
CHANGED
|
@@ -112,8 +112,10 @@ To update the [npm package]https://www.npmjs.com/package/@google-cloud/cloud-run
|
|
|
112
112
|
|
|
113
113
|
Run the following:
|
|
114
114
|
|
|
115
|
+
* `git checkout -b new-release`
|
|
115
116
|
* `npm test`
|
|
116
117
|
* `npm version minor`
|
|
117
118
|
* `git push --follow-tags`
|
|
119
|
+
* On GitHub, [create a new release](https://github.com/GoogleCloudPlatform/cloud-run-mcp/releases/new) for this tag.
|
|
118
120
|
|
|
119
121
|
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.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Enable MCP-compatible AI agents to deploy apps to Cloud Run.
|
|
|
6
6
|
"mcpServers":{
|
|
7
7
|
"cloud-run": {
|
|
8
8
|
"command": "npx",
|
|
9
|
-
"args": ["-y", "
|
|
9
|
+
"args": ["-y", "@google-cloud/cloud-run-mcp"]
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
```
|
|
@@ -72,7 +72,7 @@ Then configure the MCP server using either Node.js or Docker:
|
|
|
72
72
|
```json
|
|
73
73
|
"cloud-run": {
|
|
74
74
|
"command": "npx",
|
|
75
|
-
"args": ["-y", "
|
|
75
|
+
"args": ["-y", "@google-cloud/cloud-run-mcp"]
|
|
76
76
|
}
|
|
77
77
|
```
|
|
78
78
|
|
|
@@ -81,7 +81,7 @@ Then configure the MCP server using either Node.js or Docker:
|
|
|
81
81
|
```json
|
|
82
82
|
"cloud-run": {
|
|
83
83
|
"command": "npx",
|
|
84
|
-
"args": ["-y", "
|
|
84
|
+
"args": ["-y", "@google-cloud/cloud-run-mcp"],
|
|
85
85
|
"env": {
|
|
86
86
|
"GOOGLE_CLOUD_PROJECT": "PROJECT_NAME",
|
|
87
87
|
"GOOGLE_CLOUD_REGION": "PROJECT_REGION",
|
package/gemini-extension.json
CHANGED
package/lib/cloud-run-deploy.js
CHANGED
|
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { callWithRetry, ensureApisEnabled } from './gcp-api-helpers.js';
|
|
18
|
+
|
|
17
19
|
// Configuration
|
|
18
20
|
const REPO_NAME = 'mcp-cloud-run-deployments';
|
|
19
21
|
const ZIP_FILE_NAME = 'source.zip';
|
|
@@ -63,91 +65,6 @@ function logAndProgress(message, progressCallback, severity = 'info') {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
/**
|
|
67
|
-
* Checks if a single Google Cloud API is enabled and enables it if not.
|
|
68
|
-
*
|
|
69
|
-
* @param {object} serviceUsageClient - The Service Usage client.
|
|
70
|
-
* @param {string} serviceName - The full name of the service (e.g., 'projects/my-project/services/run.googleapis.com').
|
|
71
|
-
* @param {string} api - The API identifier (e.g., 'run.googleapis.com').
|
|
72
|
-
* @param {function(string, string=): void} progressCallback - A function to call with progress updates.
|
|
73
|
-
* @returns {Promise<void>} A promise that resolves when the API is enabled.
|
|
74
|
-
* @throws {Error} If the API fails to enable or if there's an issue checking its status.
|
|
75
|
-
*/
|
|
76
|
-
async function checkAndEnableApi(
|
|
77
|
-
serviceUsageClient,
|
|
78
|
-
serviceName,
|
|
79
|
-
api,
|
|
80
|
-
progressCallback
|
|
81
|
-
) {
|
|
82
|
-
const [service] = await serviceUsageClient.getService({
|
|
83
|
-
name: serviceName,
|
|
84
|
-
});
|
|
85
|
-
if (service.state !== 'ENABLED') {
|
|
86
|
-
logAndProgress(
|
|
87
|
-
`API [${api}] is not enabled. Enabling...`,
|
|
88
|
-
progressCallback
|
|
89
|
-
);
|
|
90
|
-
const [operation] = await serviceUsageClient.enableService({
|
|
91
|
-
name: serviceName,
|
|
92
|
-
});
|
|
93
|
-
await operation.promise();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Ensures that the specified Google Cloud APIs are enabled for the given project.
|
|
99
|
-
* If an API is not enabled, it attempts to enable it. Retries any failure once after 1s.
|
|
100
|
-
* Throws an error if an API cannot be enabled.
|
|
101
|
-
*
|
|
102
|
-
* @async
|
|
103
|
-
* @param {string} projectId - The Google Cloud project ID.
|
|
104
|
-
* @param {string[]} apis - An array of API identifiers to check and enable (e.g., 'run.googleapis.com').
|
|
105
|
-
* @param {function(string, string=): void} progressCallback - A function to call with progress updates.
|
|
106
|
-
* The first argument is the message, the optional second argument is the type ('error', 'warning', 'info').
|
|
107
|
-
* @throws {Error} If an API fails to enable or if there's an issue checking its status.
|
|
108
|
-
* @returns {Promise<void>} A promise that resolves when all specified APIs are enabled.
|
|
109
|
-
*/
|
|
110
|
-
async function ensureApisEnabled(projectId, apis, progressCallback) {
|
|
111
|
-
const { ServiceUsageClient } = await import('@google-cloud/service-usage');
|
|
112
|
-
const serviceUsageClient = new ServiceUsageClient({ projectId });
|
|
113
|
-
logAndProgress('Checking and enabling required APIs...', progressCallback);
|
|
114
|
-
|
|
115
|
-
for (const api of apis) {
|
|
116
|
-
const serviceName = `projects/${projectId}/services/${api}`;
|
|
117
|
-
try {
|
|
118
|
-
await checkAndEnableApi(
|
|
119
|
-
serviceUsageClient,
|
|
120
|
-
serviceName,
|
|
121
|
-
api,
|
|
122
|
-
progressCallback
|
|
123
|
-
);
|
|
124
|
-
} catch (error) {
|
|
125
|
-
// First attempt failed, log a warning and retry once after a delay.
|
|
126
|
-
logAndProgress(
|
|
127
|
-
`Failed to check/enable ${api}, retrying in 1s...`,
|
|
128
|
-
progressCallback,
|
|
129
|
-
'warn'
|
|
130
|
-
);
|
|
131
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
132
|
-
try {
|
|
133
|
-
await checkAndEnableApi(
|
|
134
|
-
serviceUsageClient,
|
|
135
|
-
serviceName,
|
|
136
|
-
api,
|
|
137
|
-
progressCallback
|
|
138
|
-
);
|
|
139
|
-
} catch (retryError) {
|
|
140
|
-
// If the retry also fails, throw an error.
|
|
141
|
-
const errorMessage = `Failed to ensure API [${api}] is enabled after retry. Please check manually.`;
|
|
142
|
-
console.error(errorMessage, retryError);
|
|
143
|
-
logAndProgress(errorMessage, progressCallback, 'error');
|
|
144
|
-
throw new Error(errorMessage);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
logAndProgress('All required APIs are enabled.', progressCallback);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
68
|
/**
|
|
152
69
|
* Checks if a Cloud Run service already exists.
|
|
153
70
|
*
|
|
@@ -168,7 +85,10 @@ async function checkCloudRunServiceExists(
|
|
|
168
85
|
const parent = runClient.locationPath(projectId, location);
|
|
169
86
|
const servicePath = runClient.servicePath(projectId, location, serviceId);
|
|
170
87
|
try {
|
|
171
|
-
await
|
|
88
|
+
await callWithRetry(
|
|
89
|
+
() => runClient.getService({ name: servicePath }),
|
|
90
|
+
`getService ${serviceId}`
|
|
91
|
+
);
|
|
172
92
|
logAndProgress(
|
|
173
93
|
`Cloud Run service ${serviceId} already exists.`,
|
|
174
94
|
progressCallback
|
|
@@ -249,17 +169,25 @@ async function deployToCloudRun(
|
|
|
249
169
|
|
|
250
170
|
if (exists) {
|
|
251
171
|
dryRunServiceConfig.name = servicePath;
|
|
252
|
-
await
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
172
|
+
await callWithRetry(
|
|
173
|
+
() =>
|
|
174
|
+
runClient.updateService({
|
|
175
|
+
service: dryRunServiceConfig,
|
|
176
|
+
validateOnly: true,
|
|
177
|
+
}),
|
|
178
|
+
`updateService (dry run) ${serviceId}`
|
|
179
|
+
);
|
|
256
180
|
} else {
|
|
257
|
-
await
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
181
|
+
await callWithRetry(
|
|
182
|
+
() =>
|
|
183
|
+
runClient.createService({
|
|
184
|
+
parent: parent,
|
|
185
|
+
service: dryRunServiceConfig,
|
|
186
|
+
serviceId: serviceId,
|
|
187
|
+
validateOnly: true,
|
|
188
|
+
}),
|
|
189
|
+
`createService (dry run) ${serviceId}`
|
|
190
|
+
);
|
|
263
191
|
}
|
|
264
192
|
logAndProgress(
|
|
265
193
|
`Dry run successful for ${serviceId} with current configuration.`,
|
|
@@ -302,14 +230,21 @@ async function deployToCloudRun(
|
|
|
302
230
|
progressCallback
|
|
303
231
|
);
|
|
304
232
|
service.name = servicePath;
|
|
305
|
-
[operation] = await
|
|
233
|
+
[operation] = await callWithRetry(
|
|
234
|
+
() => runClient.updateService({ service }),
|
|
235
|
+
`updateService ${serviceId}`
|
|
236
|
+
);
|
|
306
237
|
} else {
|
|
307
238
|
logAndProgress(`Creating new service ${serviceId}...`, progressCallback);
|
|
308
|
-
[operation] = await
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
239
|
+
[operation] = await callWithRetry(
|
|
240
|
+
() =>
|
|
241
|
+
runClient.createService({
|
|
242
|
+
parent: parent,
|
|
243
|
+
service: service, // 'service' object might have invokerIamDisabled removed
|
|
244
|
+
serviceId: serviceId,
|
|
245
|
+
}),
|
|
246
|
+
`createService ${serviceId}`
|
|
247
|
+
);
|
|
313
248
|
}
|
|
314
249
|
|
|
315
250
|
logAndProgress(`Deploying ${serviceId} to Cloud Run...`, progressCallback);
|
|
@@ -346,7 +281,10 @@ async function ensureStorageBucketExists(
|
|
|
346
281
|
) {
|
|
347
282
|
const bucket = storage.bucket(bucketName);
|
|
348
283
|
try {
|
|
349
|
-
const [exists] = await
|
|
284
|
+
const [exists] = await callWithRetry(
|
|
285
|
+
() => bucket.exists(),
|
|
286
|
+
`storage.bucket.exists ${bucketName}`
|
|
287
|
+
);
|
|
350
288
|
if (exists) {
|
|
351
289
|
logAndProgress(`Bucket ${bucketName} already exists.`, progressCallback);
|
|
352
290
|
return bucket;
|
|
@@ -356,9 +294,10 @@ async function ensureStorageBucketExists(
|
|
|
356
294
|
progressCallback
|
|
357
295
|
);
|
|
358
296
|
try {
|
|
359
|
-
const [createdBucket] = await
|
|
360
|
-
location: location,
|
|
361
|
-
|
|
297
|
+
const [createdBucket] = await callWithRetry(
|
|
298
|
+
() => storage.createBucket(bucketName, { location: location }),
|
|
299
|
+
`storage.createBucket ${bucketName}`
|
|
300
|
+
);
|
|
362
301
|
logAndProgress(
|
|
363
302
|
`Storage bucket ${createdBucket.name} created successfully in ${location}.`,
|
|
364
303
|
progressCallback
|
|
@@ -482,7 +421,10 @@ async function uploadToStorageBucket(
|
|
|
482
421
|
`Uploading buffer to gs://${bucket.name}/${destinationBlobName}...`,
|
|
483
422
|
progressCallback
|
|
484
423
|
);
|
|
485
|
-
await
|
|
424
|
+
await callWithRetry(
|
|
425
|
+
() => bucket.file(destinationBlobName).save(buffer),
|
|
426
|
+
`storage.bucket.file.save ${destinationBlobName}`
|
|
427
|
+
);
|
|
486
428
|
logAndProgress(
|
|
487
429
|
`File ${destinationBlobName} uploaded successfully to gs://${bucket.name}/${destinationBlobName}.`,
|
|
488
430
|
progressCallback
|
|
@@ -524,9 +466,10 @@ async function ensureArtifactRegistryRepoExists(
|
|
|
524
466
|
);
|
|
525
467
|
|
|
526
468
|
try {
|
|
527
|
-
const [repository] = await
|
|
528
|
-
name: repoPath,
|
|
529
|
-
|
|
469
|
+
const [repository] = await callWithRetry(
|
|
470
|
+
() => artifactRegistryClient.getRepository({ name: repoPath }),
|
|
471
|
+
`artifactRegistry.getRepository ${repositoryId}`
|
|
472
|
+
);
|
|
530
473
|
logAndProgress(
|
|
531
474
|
`Repository ${repositoryId} already exists in ${location}.`,
|
|
532
475
|
progressCallback
|
|
@@ -541,46 +484,26 @@ async function ensureArtifactRegistryRepoExists(
|
|
|
541
484
|
const repositoryToCreate = {
|
|
542
485
|
format: format,
|
|
543
486
|
};
|
|
544
|
-
const maxRetries = 3;
|
|
545
|
-
const retryDelay = 10000; // 10s
|
|
546
487
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
488
|
+
const [operation] = await callWithRetry(
|
|
489
|
+
() =>
|
|
490
|
+
artifactRegistryClient.createRepository({
|
|
550
491
|
parent: parent,
|
|
551
492
|
repository: repositoryToCreate,
|
|
552
493
|
repositoryId: repositoryId,
|
|
553
|
-
})
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (createError.code === 7 && attempt < maxRetries) {
|
|
567
|
-
logAndProgress(
|
|
568
|
-
`Attempt ${attempt} to create repository failed. This can happen on new projects. Retrying in 10s...`,
|
|
569
|
-
progressCallback,
|
|
570
|
-
'warn'
|
|
571
|
-
);
|
|
572
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
573
|
-
} else {
|
|
574
|
-
const errorMessage = `Failed to create Artifact Registry repository ${repositoryId}. Error details: ${createError.message}`;
|
|
575
|
-
console.error(
|
|
576
|
-
`Failed to create Artifact Registry repository ${repositoryId}. Error details:`,
|
|
577
|
-
createError
|
|
578
|
-
);
|
|
579
|
-
logAndProgress(errorMessage, progressCallback, 'error');
|
|
580
|
-
throw createError;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
494
|
+
}),
|
|
495
|
+
`artifactRegistry.createRepository ${repositoryId}`
|
|
496
|
+
);
|
|
497
|
+
logAndProgress(
|
|
498
|
+
`Creating Artifact Registry repository ${repositoryId}...`,
|
|
499
|
+
progressCallback
|
|
500
|
+
);
|
|
501
|
+
const [result] = await operation.promise();
|
|
502
|
+
logAndProgress(
|
|
503
|
+
`Artifact Registry repository ${result.name} created successfully.`,
|
|
504
|
+
progressCallback
|
|
505
|
+
);
|
|
506
|
+
return result;
|
|
584
507
|
} else {
|
|
585
508
|
const errorMessage = `Error checking/creating repository ${repositoryId}: ${error.message}`;
|
|
586
509
|
console.error(
|
|
@@ -661,19 +584,19 @@ async function triggerCloudBuild(
|
|
|
661
584
|
`Initiating Cloud Build for gs://${sourceBucketName}/${sourceBlobName} in ${location}...`,
|
|
662
585
|
progressCallback
|
|
663
586
|
);
|
|
664
|
-
const [operation] = await
|
|
665
|
-
projectId: projectId,
|
|
666
|
-
|
|
667
|
-
|
|
587
|
+
const [operation] = await callWithRetry(
|
|
588
|
+
() => cloudBuildClient.createBuild({ projectId: projectId, build: build }),
|
|
589
|
+
'cloudBuild.createBuild'
|
|
590
|
+
);
|
|
668
591
|
|
|
669
592
|
logAndProgress(`Cloud Build job started...`, progressCallback);
|
|
670
593
|
const buildId = operation.metadata.build.id;
|
|
671
594
|
let completedBuild;
|
|
672
595
|
while (true) {
|
|
673
|
-
const [getBuildOperation] = await
|
|
674
|
-
projectId: projectId,
|
|
675
|
-
|
|
676
|
-
|
|
596
|
+
const [getBuildOperation] = await callWithRetry(
|
|
597
|
+
() => cloudBuildClient.getBuild({ projectId: projectId, id: buildId }),
|
|
598
|
+
`cloudBuild.getBuild ${buildId}`
|
|
599
|
+
);
|
|
677
600
|
if (
|
|
678
601
|
['SUCCESS', 'FAILURE', 'INTERNAL_ERROR', 'TIMEOUT', 'CANCELLED'].includes(
|
|
679
602
|
getBuildOperation.status
|
|
@@ -724,11 +647,15 @@ async function triggerCloudBuild(
|
|
|
724
647
|
);
|
|
725
648
|
|
|
726
649
|
// Fetch the most recent N log entries
|
|
727
|
-
const [entries] = await
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
650
|
+
const [entries] = await callWithRetry(
|
|
651
|
+
() =>
|
|
652
|
+
loggingClient.getEntries({
|
|
653
|
+
filter: logFilter,
|
|
654
|
+
orderBy: 'timestamp desc', // Get latest logs first
|
|
655
|
+
pageSize: BUILD_LOGS_LINES_TO_FETCH,
|
|
656
|
+
}),
|
|
657
|
+
`logging.getEntries for build ${buildId}`
|
|
658
|
+
);
|
|
732
659
|
|
|
733
660
|
if (entries && entries.length > 0) {
|
|
734
661
|
// Entries are newest first, reverse for chronological order of the snippet
|
|
@@ -15,6 +15,7 @@ limitations under the License.
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import protofiles from 'google-proto-files';
|
|
18
|
+
import { callWithRetry, ensureApisEnabled } from './gcp-api-helpers.js';
|
|
18
19
|
|
|
19
20
|
let runClient;
|
|
20
21
|
let loggingClient;
|
|
@@ -32,13 +33,19 @@ export async function listServices(projectId, location) {
|
|
|
32
33
|
const { ServicesClient } = v2;
|
|
33
34
|
runClient = new ServicesClient({ projectId });
|
|
34
35
|
}
|
|
36
|
+
|
|
37
|
+
await ensureApisEnabled(projectId, ['run.googleapis.com']);
|
|
38
|
+
|
|
35
39
|
const parent = runClient.locationPath(projectId, location);
|
|
36
40
|
|
|
37
41
|
try {
|
|
38
42
|
console.log(
|
|
39
43
|
`Listing Cloud Run services in project ${projectId}, location ${location}...`
|
|
40
44
|
);
|
|
41
|
-
const [services] = await
|
|
45
|
+
const [services] = await callWithRetry(
|
|
46
|
+
() => runClient.listServices({ parent }),
|
|
47
|
+
'listServices'
|
|
48
|
+
);
|
|
42
49
|
return services;
|
|
43
50
|
} catch (error) {
|
|
44
51
|
console.error(`Error listing Cloud Run services:`, error);
|
|
@@ -66,7 +73,10 @@ export async function getService(projectId, location, serviceId) {
|
|
|
66
73
|
console.log(
|
|
67
74
|
`Getting details for Cloud Run service ${serviceId} in project ${projectId}, location ${location}...`
|
|
68
75
|
);
|
|
69
|
-
const [service] = await
|
|
76
|
+
const [service] = await callWithRetry(
|
|
77
|
+
() => runClient.getService({ name: servicePath }),
|
|
78
|
+
'getService'
|
|
79
|
+
);
|
|
70
80
|
return service;
|
|
71
81
|
} catch (error) {
|
|
72
82
|
console.error(
|
|
@@ -123,8 +133,10 @@ export async function getServiceLogs(
|
|
|
123
133
|
console.log(`Request options: ${JSON.stringify(options)}`);
|
|
124
134
|
|
|
125
135
|
// getEntries returns the entries and the full API response
|
|
126
|
-
const [entries, nextRequestOptions, apiResponse] =
|
|
127
|
-
|
|
136
|
+
const [entries, nextRequestOptions, apiResponse] = await callWithRetry(
|
|
137
|
+
() => loggingClient.getEntries(options),
|
|
138
|
+
'getEntries'
|
|
139
|
+
);
|
|
128
140
|
|
|
129
141
|
const formattedLogLines = entries
|
|
130
142
|
.map((entry) => formatLogEntry(entry))
|
|
@@ -0,0 +1,143 @@
|
|
|
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 {string} projectId - The Google Cloud project ID.
|
|
94
|
+
* @param {string[]} apis - An array of API identifiers to check and enable (e.g., 'run.googleapis.com').
|
|
95
|
+
* @param {function(string, string=): void} progressCallback - A function to call with progress updates.
|
|
96
|
+
* The first argument is the message, the optional second argument is the type ('error', 'warning', 'info').
|
|
97
|
+
* @throws {Error} If an API fails to enable or if there's an issue checking its status.
|
|
98
|
+
* @returns {Promise<void>} A promise that resolves when all specified APIs are enabled.
|
|
99
|
+
*/
|
|
100
|
+
export async function ensureApisEnabled(projectId, apis, progressCallback) {
|
|
101
|
+
const { ServiceUsageClient } = await import('@google-cloud/service-usage');
|
|
102
|
+
const serviceUsageClient = new ServiceUsageClient({ projectId });
|
|
103
|
+
const message = 'Checking and enabling required APIs...';
|
|
104
|
+
console.log(message);
|
|
105
|
+
if (progressCallback) progressCallback({ level: 'info', data: message });
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
for (const api of apis) {
|
|
109
|
+
const serviceName = `projects/${projectId}/services/${api}`;
|
|
110
|
+
try {
|
|
111
|
+
await checkAndEnableApi(
|
|
112
|
+
serviceUsageClient,
|
|
113
|
+
serviceName,
|
|
114
|
+
api,
|
|
115
|
+
progressCallback
|
|
116
|
+
);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// First attempt failed, log a warning and retry once after a delay.
|
|
119
|
+
const warnMsg = `Failed to check/enable ${api}, retrying in 1s...`;
|
|
120
|
+
console.warn(warnMsg);
|
|
121
|
+
if (progressCallback) progressCallback({ level: 'warn', data: warnMsg });
|
|
122
|
+
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
124
|
+
try {
|
|
125
|
+
await checkAndEnableApi(
|
|
126
|
+
serviceUsageClient,
|
|
127
|
+
serviceName,
|
|
128
|
+
api,
|
|
129
|
+
progressCallback
|
|
130
|
+
);
|
|
131
|
+
} catch (retryError) {
|
|
132
|
+
// If the retry also fails, throw an error.
|
|
133
|
+
const errorMessage = `Failed to ensure API [${api}] is enabled after retry. Please check manually.`;
|
|
134
|
+
console.error(errorMessage, retryError);
|
|
135
|
+
if (progressCallback) progressCallback({ level: 'error', data: errorMessage });
|
|
136
|
+
throw new Error(errorMessage);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const successMsg = 'All required APIs are enabled.';
|
|
141
|
+
console.log(successMsg);
|
|
142
|
+
if (progressCallback) progressCallback({ level: 'info', data: successMsg });
|
|
143
|
+
}
|
package/lib/gcp-projects.js
CHANGED
|
@@ -72,7 +72,7 @@ export async function createProject(projectId) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
try {
|
|
75
|
-
const projectPayload = { projectId: projectIdToUse
|
|
75
|
+
const projectPayload = { projectId: projectIdToUse };
|
|
76
76
|
|
|
77
77
|
console.log(`Attempting to create project with ID: ${projectIdToUse}`);
|
|
78
78
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
});
|
|
File without changes
|