@google-cloud/cloud-run-mcp 1.6.0 → 1.8.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.
@@ -0,0 +1,194 @@
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 path from 'path';
18
+ import fs from 'fs';
19
+ import { DEPLOYMENT_CONFIG, RUNTIMES } from './constants.js';
20
+
21
+ /**
22
+ * Checks if the input is a single folder path.
23
+ * @param {Array<string|Object>} files - Array of file paths or file objects.
24
+ * @returns {boolean} - true if files contains exactly one string which is a directory.
25
+ */
26
+ function isFolder(files) {
27
+ if (files.length !== 1 || typeof files[0] !== 'string') return false;
28
+ try {
29
+ return fs.statSync(files[0]).isDirectory();
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Checks if a Dockerfile exists in the provided files array.
37
+ * @param {Array<string|Object>} files - An array of file paths or objects with filename properties.
38
+ * @returns {boolean} - Returns true if a Dockerfile is found, false otherwise.
39
+ */
40
+ function checkIfDockerFileExists(files) {
41
+ if (isFolder(files)) {
42
+ // Handle folder deployment: check for Dockerfile inside the folder
43
+ return (
44
+ fs.existsSync(path.join(files[0], 'Dockerfile')) ||
45
+ fs.existsSync(path.join(files[0], 'dockerfile'))
46
+ );
47
+ }
48
+
49
+ // Handle file list deployment or file content deployment
50
+ for (const file of files) {
51
+ if (typeof file === 'string') {
52
+ if (path.basename(file).toLowerCase() === 'dockerfile') {
53
+ return true;
54
+ }
55
+ } else if (typeof file === 'object' && file.filename) {
56
+ if (path.basename(file.filename).toLowerCase() === 'dockerfile') {
57
+ return true;
58
+ }
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Checks if the provided folder appears to be a Node.js project.
66
+ * It looks for a `package.json` file in the root of the first path in the `files` array.
67
+ * @param {string[]} files - Array of file paths
68
+ * @returns {boolean}
69
+ */
70
+ function checkIfNodeJsRuntime(files) {
71
+ const packageJsonPath = path.join(files[0], 'package.json');
72
+ return fs.existsSync(packageJsonPath);
73
+ }
74
+
75
+ /**
76
+ * Returns an empty deployment attributes object.
77
+ * This is used as a default or when attributes cannot be determined.
78
+ * @returns {{runtime: undefined, cmd: undefined, args: undefined, baseImage: undefined}}
79
+ */
80
+ function getEmptyDeploymentAttrs() {
81
+ return {
82
+ runtime: undefined,
83
+ cmd: undefined,
84
+ args: undefined,
85
+ baseImage: undefined,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Extracts deployment attributes (runtime, command, args, baseImage) from a Node.js project's `package.json`.
91
+ * This function is used for direct source deployments, bypassing Cloud Build.
92
+ * It returns empty attributes if a `start` script is missing, invalid, or if `engines.node` is specified (indicating a need for Cloud Build).
93
+ * @param {string[]} files - Array of file paths
94
+ * @returns {{runtime: string|undefined, cmd: string[]|undefined, args: string[]|undefined, baseImage: string|undefined}} Deployment attributes object.
95
+ */
96
+ function getNodeJsDeploymentAttrs(files) {
97
+ const packageJsonPath = path.join(files[0], 'package.json');
98
+ let packageJson;
99
+
100
+ try {
101
+ packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
102
+ } catch (error) {
103
+ // If package.json exists but is invalid, we can't extract attributes.
104
+ return getEmptyDeploymentAttrs();
105
+ }
106
+
107
+ const startScript = packageJson?.scripts?.start;
108
+ const hasNodeEngine = packageJson?.engines?.node;
109
+
110
+ if (typeof startScript !== 'string' || !startScript.trim() || hasNodeEngine) {
111
+ return getEmptyDeploymentAttrs();
112
+ }
113
+
114
+ // Handle multiple spaces properly
115
+ const parts = startScript.trim().split(/\s+/);
116
+
117
+ return {
118
+ runtime: RUNTIMES.NODEJS,
119
+ cmd: [parts[0]],
120
+ args: parts.slice(1),
121
+ baseImage: DEPLOYMENT_CONFIG.DEFAULT_NODE_BASE_IMAGE, // latest default base image
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Determines deployment attributes based on the file list/folder content.
127
+ * @param {Array<string|Object>} files - Array of file paths or file objects.
128
+ * @returns {{runtime: string|undefined, cmd: string[]|undefined, args: string[]|undefined, baseImage: string|undefined}} Deployment attributes object.
129
+ */
130
+ function getDeploymentAttrs(files) {
131
+ // Currently only support detection of NodeJs runtime
132
+ if (!isFolder(files) || !checkIfNodeJsRuntime(files)) {
133
+ // TODO: support file list deployment runtime detection
134
+ return getEmptyDeploymentAttrs();
135
+ }
136
+
137
+ return getNodeJsDeploymentAttrs(files);
138
+ }
139
+
140
+ /**
141
+ * Creates metadata for the file deployment, including Dockerfile presence and runtime attributes.
142
+ * @param {Array<string|Object>} files - Array of file paths or file objects to be deployed.
143
+ * @returns {{hasDockerfile: boolean, deploymentAttrs: {runtime: string|undefined, cmd: string[]|undefined, args: string[]|undefined, baseImage: string|undefined}}} Metadata object.
144
+ */
145
+ export function makeFileDeploymentMetadata(files) {
146
+ return {
147
+ hasDockerfile: checkIfDockerFileExists(files),
148
+ deploymentAttrs: getDeploymentAttrs(files),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Checks if a zip-based source deployment (no-build) is feasible.
154
+ * @param {object} metadata - The deployment metadata.
155
+ * @param {boolean} metadata.hasDockerfile - Whether a Dockerfile is present.
156
+ * @param {{runtime: string|undefined, cmd: string[]|undefined, args: string[]|undefined}} metadata.deploymentAttrs - Deployment attributes.
157
+ * @returns {boolean} True if zip deployment is feasible.
158
+ */
159
+ export function canDeployWithoutBuild({ hasDockerfile, deploymentAttrs }) {
160
+ return !!(
161
+ !hasDockerfile &&
162
+ deploymentAttrs &&
163
+ deploymentAttrs.runtime &&
164
+ deploymentAttrs.cmd &&
165
+ deploymentAttrs.args
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Creates the container spec for a direct source deployment (no Cloud Build).
171
+ * @param {object} params - The parameters for creating the container spec.
172
+ * @param {string} params.bucketName - The GCS bucket name where the source is uploaded.
173
+ * @param {string} params.fileName - The GCS object name (eg., source.tar.gz).
174
+ * @param {{cmd: string[], args: string[], baseImage: string}} params.deploymentAttrs - Deployment attributes including command, args, and the base image to use.
175
+ * @returns {object} The container specification object for the Cloud Run service.
176
+ */
177
+ export function createDirectSourceDeploymentContainer({
178
+ bucketName,
179
+ fileName,
180
+ deploymentAttrs,
181
+ }) {
182
+ return {
183
+ image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
184
+ baseImageUri: deploymentAttrs.baseImage,
185
+ sourceCode: {
186
+ cloudStorageSource: {
187
+ bucket: bucketName,
188
+ object: fileName,
189
+ },
190
+ },
191
+ command: deploymentAttrs.cmd,
192
+ args: deploymentAttrs.args,
193
+ };
194
+ }
@@ -0,0 +1,124 @@
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 path from 'node:path';
18
+ import fs from 'node:fs';
19
+ import fsp from 'node:fs/promises';
20
+ import os from 'node:os';
21
+ import util from 'node:util';
22
+ import { exec } from 'node:child_process';
23
+ import { TEMP_PATHS, RUNTIMES } from './constants.js';
24
+ import { logAndProgress } from '../util/helpers.js';
25
+
26
+ const execAsync = util.promisify(exec);
27
+
28
+ /**
29
+ * Prepares a temporary directory with the source code for deployment.
30
+ * Handles both file paths and content objects.
31
+ *
32
+ * @param {Array<string|{filename: string, content: string|Buffer}>} files - Files to deploy.
33
+ * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
34
+ * @returns {Promise<string>} The path to the prepared temporary directory.
35
+ */
36
+ export async function prepareSourceDirectory(files, progressCallback) {
37
+ const homeDir = os.homedir();
38
+ const tempBaseDir = path.join(homeDir, TEMP_PATHS.BASE, TEMP_PATHS.SUBDIR);
39
+ const sourceName = path.basename(files[0]);
40
+
41
+ const tempDir = path.join(tempBaseDir, `${sourceName}-${Date.now()}`);
42
+
43
+ // Creates a temporary directory to store the source code.
44
+ await fsp.mkdir(tempDir, { recursive: true });
45
+
46
+ // Copies the source code to the temporary directory.
47
+ for (const file of files) {
48
+ const stats = await fsp.stat(file);
49
+ if (stats.isDirectory()) {
50
+ // Copy directory contents
51
+ await execAsync(`cp -R "${file}/." "${tempDir}/"`);
52
+ } else {
53
+ const destPath = path.join(tempDir, path.basename(file));
54
+ await fsp.copyFile(file, destPath);
55
+ }
56
+ }
57
+
58
+ return tempDir;
59
+ }
60
+
61
+ /**
62
+ * Installs Node.js dependencies in the target directory if a package.json exists.
63
+ * Skips if node_modules already exists (assumed pre-installed or copied).
64
+ *
65
+ * @param {string} targetDir - The directory containing the source code.
66
+ */
67
+ async function installNodeDependencies(targetDir) {
68
+ const nodeModulesPath = path.join(targetDir, 'node_modules');
69
+
70
+ if (fs.existsSync(nodeModulesPath)) {
71
+ console.log(
72
+ `Existing node_modules detected. Skipping dependency installation.`
73
+ );
74
+ return;
75
+ }
76
+
77
+ // Check for lock file to decide between install and ci
78
+ const hasLockFile = fs.existsSync(path.join(targetDir, 'package-lock.json'));
79
+ const npmCommand = hasLockFile
80
+ ? 'npm ci --omit=dev'
81
+ : 'npm install --omit=dev';
82
+
83
+ try {
84
+ await execAsync(npmCommand, { cwd: targetDir });
85
+ console.log(`${npmCommand} completed successfully`);
86
+ } catch (e) {
87
+ console.log(`Dependency installation failed: ${e.message}`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Generic wrapper to install dependencies for the detected runtime.
93
+ *
94
+ * @param {string} targetDir - The directory containing the source code.
95
+ * @param {Object} deploymentAttrs - Deployment attributes containing runtime info.
96
+ */
97
+ export async function installDependencies(targetDir, deploymentAttrs) {
98
+ switch (deploymentAttrs?.runtime) {
99
+ case RUNTIMES.NODEJS:
100
+ await installNodeDependencies(targetDir);
101
+ break;
102
+ default:
103
+ console.log(
104
+ `No specific dependency installation logic for runtime: ${deploymentAttrs?.runtime}`
105
+ );
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Cleans up the temporary directory.
111
+ *
112
+ * @param {string} dirPath - Path to the directory to remove.
113
+ */
114
+ export async function cleanupTempDirectory(dirPath) {
115
+ if (!dirPath) return;
116
+
117
+ try {
118
+ if (fs.existsSync(dirPath)) {
119
+ await fsp.rm(dirPath, { recursive: true, force: true });
120
+ }
121
+ } catch (cleanupError) {
122
+ console.error(`Failed to cleanup temp directory ${dirPath}:`, cleanupError);
123
+ }
124
+ }
@@ -23,26 +23,35 @@ import { logAndProgress } from './helpers.js';
23
23
  * @param {Array<string|{filename: string, content: Buffer|string}>} files - An array of items to zip.
24
24
  * Each item can be a string representing a file/directory path, or an object
25
25
  * with `filename` and `content` properties for in-memory files.
26
+ * @param {boolean} [useTarGz] - If true, creates a tar.gz archive instead of a zip.
26
27
  * @param {function(object): void} [progressCallback] - Optional callback for progress updates.
27
28
  * @returns {Promise<Buffer>} A promise that resolves with a Buffer containing the zip data.
28
29
  * @throws {Error} If an input file path is not found, an input item has an invalid format, or an archiver error occurs.
29
30
  */
30
- export async function zipFiles(files, progressCallback) {
31
+ export async function zipFiles(files, useTarGz, progressCallback) {
31
32
  const path = await import('path');
32
33
  const fs = await import('fs');
33
34
  const archiver = (await import('archiver')).default;
34
35
 
35
36
  return new Promise((resolve, reject) => {
36
- logAndProgress('Creating in-memory zip archive...', progressCallback);
37
+ logAndProgress('Creating in-memory archive...', progressCallback);
37
38
  const chunks = [];
38
- const archive = archiver('zip', {
39
- zlib: { level: 9 },
40
- });
39
+
40
+ let archive;
41
+ if (useTarGz) {
42
+ archive = archiver('tar', {
43
+ gzip: { level: 9 },
44
+ });
45
+ } else {
46
+ archive = archiver('zip', {
47
+ zlib: { level: 9 },
48
+ });
49
+ }
41
50
 
42
51
  archive.on('data', (chunk) => chunks.push(chunk));
43
52
  archive.on('end', () => {
44
53
  logAndProgress(
45
- `Files zipped successfully. Total size: ${archive.pointer()} bytes`,
54
+ `Archive created successfully. Total size: ${archive.pointer()} bytes`,
46
55
  progressCallback
47
56
  );
48
57
  resolve(Buffer.concat(chunks));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google-cloud/cloud-run-mcp",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "description": "Cloud Run MCP deployment tool",
6
6
  "main": "mcp-server.js",
@@ -45,7 +45,7 @@
45
45
  "@google-cloud/cloudbuild": "^5.0.1",
46
46
  "@google-cloud/logging": "^11.2.0",
47
47
  "@google-cloud/resource-manager": "^6.0.1",
48
- "@google-cloud/run": "^2.0.1",
48
+ "@google-cloud/run": "^3.1.0",
49
49
  "@google-cloud/service-usage": "^4.1.0",
50
50
  "@google-cloud/storage": "^7.16.0",
51
51
  "@modelcontextprotocol/sdk": "^1.24.3",
@@ -58,6 +58,6 @@
58
58
  "devDependencies": {
59
59
  "c8": "^10.1.3",
60
60
  "esmock": "^2.7.1",
61
- "prettier": "3.6.2"
61
+ "prettier": "^3.8.0"
62
62
  }
63
63
  }
@@ -0,0 +1,146 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { getClient } from '../../lib/clients.js';
4
+
5
+ describe('getClient Helper', () => {
6
+ class MockClient {
7
+ constructor(options) {
8
+ this.options = options;
9
+ }
10
+ }
11
+
12
+ test('creates new client instance with authClient when access token provided', async () => {
13
+ const projectId = 'test-project-1';
14
+ const accessToken = 'fake-token-1';
15
+ const service = 'run'; // Must match keys in lib/clients.js clients object
16
+ const key = projectId + accessToken;
17
+
18
+ const client = await getClient(
19
+ service,
20
+ key,
21
+ async () => MockClient,
22
+ { projectId },
23
+ accessToken
24
+ );
25
+
26
+ assert.ok(client instanceof MockClient);
27
+ assert.strictEqual(client.options.projectId, projectId);
28
+ assert.ok(client.options.authClient);
29
+
30
+ const headers = client.options.authClient.getRequestHeaders();
31
+ assert.strictEqual(headers.get('Authorization'), 'Bearer fake-token-1');
32
+ });
33
+
34
+ test('creates new client instance WITHOUT authClient when NO access token provided', async () => {
35
+ const projectId = 'test-project-2';
36
+ const service = 'run';
37
+ const key = projectId;
38
+
39
+ const client = await getClient(
40
+ service,
41
+ key,
42
+ async () => MockClient,
43
+ { projectId },
44
+ null
45
+ );
46
+
47
+ assert.ok(client instanceof MockClient);
48
+ assert.strictEqual(client.options.projectId, projectId);
49
+ assert.strictEqual(client.options.authClient, undefined);
50
+ });
51
+
52
+ test('caches client instances by key', async () => {
53
+ const projectId = 'test-project-3';
54
+ const accessToken = 'token-A';
55
+ const service = 'run';
56
+ const key = projectId + accessToken;
57
+
58
+ const client1 = await getClient(
59
+ service,
60
+ key,
61
+ async () => MockClient,
62
+ { projectId },
63
+ accessToken
64
+ );
65
+
66
+ const client2 = await getClient(
67
+ service,
68
+ key,
69
+ async () => MockClient,
70
+ { projectId },
71
+ accessToken
72
+ );
73
+
74
+ assert.strictEqual(client1, client2);
75
+ });
76
+
77
+ test('creates DIFFERENT client instances for different keys', async () => {
78
+ const projectId = 'test-project-4';
79
+ const service = 'run';
80
+
81
+ const client1 = await getClient(
82
+ service,
83
+ projectId + 'token-A',
84
+ async () => MockClient,
85
+ { projectId },
86
+ 'token-A'
87
+ );
88
+
89
+ const client2 = await getClient(
90
+ service,
91
+ projectId + 'token-B',
92
+ async () => MockClient,
93
+ { projectId },
94
+ 'token-B'
95
+ );
96
+
97
+ assert.notStrictEqual(client1, client2);
98
+
99
+ const h1 = client1.options.authClient.getRequestHeaders();
100
+ assert.strictEqual(h1.get('Authorization'), 'Bearer token-A');
101
+
102
+ const h2 = client2.options.authClient.getRequestHeaders();
103
+ assert.strictEqual(h2.get('Authorization'), 'Bearer token-B');
104
+ });
105
+
106
+ test('supports different services', async () => {
107
+ const projectId = 'test-project-5';
108
+ const accessToken = 'token-C';
109
+
110
+ const runClient = await getClient(
111
+ 'run',
112
+ projectId + accessToken,
113
+ async () => MockClient,
114
+ { projectId },
115
+ accessToken
116
+ );
117
+
118
+ const storageClient = await getClient(
119
+ 'storage',
120
+ projectId + accessToken,
121
+ async () => MockClient,
122
+ { projectId },
123
+ accessToken
124
+ );
125
+
126
+ assert.notStrictEqual(runClient, storageClient); // Different maps
127
+ assert.ok(runClient.options.authClient);
128
+ assert.ok(storageClient.options.authClient);
129
+ });
130
+
131
+ test('passes additional options correctly', async () => {
132
+ const projectId = 'test-project-6';
133
+ const service = 'run';
134
+ const extraOpt = 'foo';
135
+
136
+ const client = await getClient(
137
+ service,
138
+ projectId,
139
+ async () => MockClient,
140
+ { projectId, extraOpt },
141
+ null
142
+ );
143
+
144
+ assert.strictEqual(client.options.extraOpt, extraOpt);
145
+ });
146
+ });