@google-cloud/cloud-run-mcp 1.10.0 → 1.12.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 (52) hide show
  1. package/.github/workflows/npm-publish.yml +10 -6
  2. package/README.md +26 -0
  3. package/SECURITY.md +7 -0
  4. package/cloudbuild.yaml +37 -0
  5. package/example-sources-to-deploy/java/pom.xml +41 -0
  6. package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
  7. package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
  8. package/example-sources-to-deploy/nodejs/index.js +15 -0
  9. package/example-sources-to-deploy/nodejs/package.json +12 -0
  10. package/example-sources-to-deploy/python/pip-project/main.py +11 -0
  11. package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
  12. package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
  13. package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
  14. package/lib/clients.js +142 -12
  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 +8 -0
  21. package/lib/deployment/deployer.js +283 -28
  22. package/lib/deployment/helpers.js +58 -1
  23. package/lib/deployment/universal-maker.js +21 -148
  24. package/lib/middleware/oauth.js +26 -15
  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 +83 -20
  29. package/package.json +7 -4
  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 +307 -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 +100 -1
  40. package/test/local/mcp-server-streamable-http.test.js +132 -12
  41. package/test/local/middleware/oauth.test.js +106 -5
  42. package/test/local/test-utils.js +2 -18
  43. package/test/local/tools.test.js +43 -1
  44. package/test/local/universal-maker.test.js +23 -114
  45. package/test/local/util.test.js +361 -0
  46. package/test/need-gcp/deployer.test.js +1 -1
  47. package/test/need-gcp/test-helpers.js +5 -1
  48. package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
  49. package/tools/register-tools.js +98 -78
  50. /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
  51. /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
  52. /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
package/mcp-server.js CHANGED
@@ -29,11 +29,11 @@ import { registerPrompts } from './prompts.js';
29
29
  import { checkGCP } from './lib/cloud-api/metadata.js';
30
30
  import { ensureGCPCredentials } from './lib/cloud-api/auth.js';
31
31
  import { extractAccessToken } from './lib/util/helpers.js';
32
+ import { getDefaultAccessToken, validateAccessToken } from './lib/clients.js';
32
33
  import { oauthMiddleware } from './lib/middleware/oauth.js';
33
34
  import { config } from '@dotenvx/dotenvx';
34
35
  import {
35
36
  SCOPES,
36
- GCLOUD_AUTH,
37
37
  BEARER_METHODS_SUPPORTED,
38
38
  RESPONSE_TYPES_SUPPORTED,
39
39
  } from './constants.js';
@@ -41,6 +41,16 @@ import {
41
41
  //Suppress the warning related to missing .env file in case of non-OAuth mode
42
42
  config({ quiet: true, ignore: ['MISSING_ENV_FILE'] });
43
43
 
44
+ if (
45
+ process.env.OAUTH_ENABLED === 'true' &&
46
+ !process.env.GOOGLE_OAUTH_AUDIENCE
47
+ ) {
48
+ console.error(
49
+ 'GOOGLE_OAUTH_AUDIENCE environment variable is required when OAuth is enabled.'
50
+ );
51
+ process.exit(1);
52
+ }
53
+
44
54
  const gcpInfo = await checkGCP();
45
55
  let gcpCredentialsAvailable = false;
46
56
 
@@ -54,7 +64,11 @@ function makeLoggingCompatibleWithStdio() {
54
64
  }
55
65
 
56
66
  function shouldStartStdio() {
57
- if (process.env.GCP_STDIO === 'false' || (gcpInfo && gcpInfo.project)) {
67
+ if (
68
+ process.env.GCP_STDIO === 'false' ||
69
+ process.env.OAUTH_ENABLED === 'true' ||
70
+ (gcpInfo && gcpInfo.project)
71
+ ) {
58
72
  return false;
59
73
  }
60
74
  return true;
@@ -69,12 +83,15 @@ const envProjectId = process.env.GOOGLE_CLOUD_PROJECT || undefined;
69
83
  const envRegion = process.env.GOOGLE_CLOUD_REGION;
70
84
  const defaultServiceName = process.env.DEFAULT_SERVICE_NAME;
71
85
  const skipIamCheck = process.env.SKIP_IAM_CHECK !== 'false';
86
+ // Values for RUN_INGRESS_POLICY could be: "INGRESS_TRAFFIC_ALL", "INGRESS_TRAFFIC_INTERNAL_ONLY", "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
87
+ const ingress = process.env.RUN_INGRESS_POLICY || undefined;
72
88
  const enableHostValidation = process.env.ENABLE_HOST_VALIDATION === 'true';
73
89
  const allowedHosts = process.env.ALLOWED_HOSTS
74
90
  ? process.env.ALLOWED_HOSTS.split(',')
75
91
  : undefined;
76
92
 
77
- async function getServer(accessToken = GCLOUD_AUTH) {
93
+ async function getServer(accessToken = getDefaultAccessToken()) {
94
+ validateAccessToken(accessToken);
78
95
  // Create an MCP server with implementation details
79
96
  const server = new McpServer(
80
97
  {
@@ -107,6 +124,7 @@ async function getServer(accessToken = GCLOUD_AUTH) {
107
124
  defaultRegion: effectiveRegion,
108
125
  defaultServiceName,
109
126
  skipIamCheck,
127
+ ingress,
110
128
  gcpCredentialsAvailable,
111
129
  accessToken,
112
130
  });
@@ -120,6 +138,7 @@ async function getServer(accessToken = GCLOUD_AUTH) {
120
138
  defaultRegion: effectiveRegion,
121
139
  defaultServiceName,
122
140
  skipIamCheck,
141
+ ingress,
123
142
  gcpCredentialsAvailable,
124
143
  accessToken,
125
144
  });
@@ -185,9 +204,9 @@ if (shouldStartStdio()) {
185
204
 
186
205
  app.post('/mcp', oauthMiddleware, async (req, res) => {
187
206
  console.log('/mcp Received:', req.body);
188
- const accessToken = extractAccessToken(req.headers.authorization);
189
- const server = await getServer(accessToken);
190
207
  try {
208
+ const accessToken = extractAccessToken(req.headers.authorization);
209
+ const server = await getServer(accessToken);
191
210
  const transport = new StreamableHTTPServerTransport({
192
211
  sessionIdGenerator: undefined,
193
212
  });
@@ -245,28 +264,72 @@ if (shouldStartStdio()) {
245
264
  const sseTransports = {};
246
265
 
247
266
  // Legacy SSE endpoint for older clients
248
- app.get('/sse', async (req, res) => {
267
+ app.get('/sse', oauthMiddleware, async (req, res) => {
249
268
  console.log('/sse Received:', req.body);
250
- const accessToken = extractAccessToken(req.headers.authorization);
251
- const server = await getServer(accessToken);
252
- // Create SSE transport for legacy clients
253
- const transport = new SSEServerTransport('/messages', res);
254
- sseTransports[transport.sessionId] = transport;
255
-
256
- res.on('close', () => {
257
- delete sseTransports[transport.sessionId];
258
- });
269
+ try {
270
+ const accessToken = extractAccessToken(req.headers.authorization);
271
+ const server = await getServer(accessToken);
272
+ // Create SSE transport for legacy clients
273
+ const transport = new SSEServerTransport('/messages', res);
274
+ sseTransports[transport.sessionId] = { transport, accessToken };
275
+
276
+ res.on('close', () => {
277
+ delete sseTransports[transport.sessionId];
278
+ });
259
279
 
260
- await server.connect(transport);
280
+ await server.connect(transport);
281
+ } catch (error) {
282
+ console.error('Error handling SSE request:', error);
283
+ if (!res.headersSent) {
284
+ res.status(500).json({
285
+ jsonrpc: '2.0',
286
+ error: {
287
+ code: -32603,
288
+ message: 'Internal server error',
289
+ },
290
+ id: null,
291
+ });
292
+ }
293
+ }
261
294
  });
262
295
 
263
296
  // Legacy message endpoint for older clients
264
- app.post('/messages', async (req, res) => {
297
+ app.post('/messages', oauthMiddleware, async (req, res) => {
265
298
  console.log('/messages Received:', req.body);
266
299
  const sessionId = req.query.sessionId;
267
- const transport = sseTransports[sessionId];
268
- if (transport) {
269
- await transport.handlePostMessage(req, res, req.body);
300
+ const session = sseTransports[sessionId];
301
+ if (session) {
302
+ const accessToken = extractAccessToken(req.headers.authorization);
303
+ // Session is intentionally bound to the /sse access token; clients must reconnect to /sse on token change.
304
+ if (
305
+ process.env.OAUTH_ENABLED === 'true' &&
306
+ session.accessToken !== accessToken
307
+ ) {
308
+ return res.status(403).json({
309
+ jsonrpc: '2.0',
310
+ error: {
311
+ code: -32001,
312
+ message:
313
+ 'Forbidden: session is bound to a different access token; reconnect to /sse',
314
+ },
315
+ id: null,
316
+ });
317
+ }
318
+ try {
319
+ await session.transport.handlePostMessage(req, res, req.body);
320
+ } catch (error) {
321
+ console.error('Error handling message request:', error);
322
+ if (!res.headersSent) {
323
+ res.status(500).json({
324
+ jsonrpc: '2.0',
325
+ error: {
326
+ code: -32603,
327
+ message: 'Internal server error',
328
+ },
329
+ id: null,
330
+ });
331
+ }
332
+ }
270
333
  } else {
271
334
  res.status(400).send('No transport found for sessionId');
272
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google-cloud/cloud-run-mcp",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "description": "Cloud Run MCP deployment tool",
6
6
  "main": "mcp-server.js",
@@ -40,20 +40,23 @@
40
40
  },
41
41
  "homepage": "https://github.com/GoogleCloudPlatform/cloud-run-mcp#readme",
42
42
  "dependencies": {
43
+ "@dotenvx/dotenvx": "^1.52.0",
43
44
  "@google-cloud/artifact-registry": "^4.0.1",
44
45
  "@google-cloud/billing": "^5.0.1",
45
46
  "@google-cloud/cloudbuild": "^5.0.1",
46
47
  "@google-cloud/logging": "^11.2.0",
47
48
  "@google-cloud/resource-manager": "^6.0.1",
48
49
  "@google-cloud/run": "^3.1.0",
50
+ "@google-cloud/secret-manager": "^6.1.1",
49
51
  "@google-cloud/service-usage": "^4.1.0",
50
52
  "@google-cloud/storage": "^7.16.0",
51
53
  "@modelcontextprotocol/sdk": "^1.24.3",
52
- "archiver": "^7.0.1",
53
- "@dotenvx/dotenvx": "^1.52.0",
54
+ "archiver": "^8.0.0",
54
55
  "express": "^5.1.0",
55
56
  "google-proto-files": "^5.0.0",
56
- "zod": "^3.25.76"
57
+ "googleapis": "^171.4.0",
58
+ "js-yaml": "^4.1.1",
59
+ "zod": "^4.6.7"
57
60
  },
58
61
  "devDependencies": {
59
62
  "c8": "^10.1.3",
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: cloud-run
3
+ description: 'Manage Cloud Run services and jobs.'
4
+ metadata:
5
+ version: 0.1.0
6
+ openclaw:
7
+ category: 'compute'
8
+ requires:
9
+ bins:
10
+ - gcloud
11
+ cliHelp: 'gcloud run --help'
12
+ ---
13
+
14
+ # run (v1)
15
+
16
+ > **PREREQUISITE:** Ensure you have the `gcloud` CLI installed and authenticated with `gcloud auth login`. Set your project with `gcloud config set project [PROJECT_ID]`.
17
+
18
+ ```bash
19
+ gcloud run <resource> <method> [flags]
20
+ ```
21
+
22
+ ## Helper Commands
23
+
24
+ | Command | Description |
25
+ | -------- | -------------------------------------------- |
26
+ | `deploy` | Create or update a Cloud Run service or job. |
27
+
28
+ ## API Resources
29
+
30
+ ### services
31
+
32
+ - `list` — List available services in the specified region.
33
+ - `describe` — Obtain details about a given service, such as its URL and configuration.
34
+ - `update` — Update Cloud Run environment variables, concurrency settings, and other configuration.
35
+ - `delete` — Delete a service and its associated revisions.
36
+ - `update-traffic` — Adjust the traffic assignments for a Cloud Run service.
37
+ - `proxy` — Proxy a service to localhost authenticating as the active account.
38
+ - `logs read` — Read logs for a Cloud Run service.
39
+ - `replace` - Create or replace a service from a YAML service specification.
40
+ - `add-iam-policy-binding` — Add IAM policy binding to a Cloud Run service (e.g., to make it public).
41
+ - `remove-iam-policy-binding` - Remove IAM policy binding of a Cloud Run service.
42
+ - `get-iam-policy` - Get the IAM policy for a Cloud Run service.
43
+ - `set-iam-policy` - Set the IAM policy for a Cloud Run service.
44
+
45
+ ### jobs
46
+
47
+ - `create` — Create a Cloud Run job.
48
+ - `execute` — Start an execution of a Cloud Run job.
49
+ - `list` — List available jobs.
50
+ - `describe` — Obtain details about a given job.
51
+ - `deploy` - Create or update a Cloud Run job
52
+ - `update` — Update a Cloud Run job configuration.
53
+ - `delete` — Delete a Cloud Run job.
54
+ - `replace` - Create or replace a Cloud Run job from a YAML job specification.
55
+ - `executions list` — List executions of a Cloud Run job.
56
+ - `logs read` — Read logs for a Cloud Run job.
57
+ - `add-iam-policy-binding` — Add IAM policy binding to a Cloud Run job.
58
+ - `remove-iam-policy-binding` - Remove IAM policy binding of a Cloud Run job.
59
+ - `get-iam-policy` - Get the IAM policy for a Cloud Run job.
60
+ - `set-iam-policy` - Set the IAM policy for a Cloud Run job.
61
+
62
+ ### domain-mappings
63
+
64
+ - `list` — List domain mappings.
65
+ - `create` — Create a new domain mapping.
66
+ - `describe` — Obtain details about a domain mapping.
67
+ - `delete` — Delete a domain mapping.
68
+
69
+ ### multi-region-services
70
+
71
+ - `list` — List multi-region services.
72
+ - `describe` — Obtain details about a multi-region service.
73
+ - `update` — Update settings for multi-region services.
74
+ - `delete` — Delete a multi-region service.
75
+ - `replace` - Create or Update multi-region service from YAML specification.
76
+
77
+ ### revisions
78
+
79
+ - `list` — List available revisions for a service.
80
+ - `describe` — Obtain details about a specific revision.
81
+ - `delete` — Delete a specific revision.
82
+
83
+ ### regions
84
+
85
+ - `list` — View available Cloud Run (fully managed) regions.
86
+
87
+ ### compose
88
+
89
+ - `up` — Deploy to Cloud Run from a compose specification.
90
+
91
+ ## Discovering Commands
92
+
93
+ Before calling any command, inspect it for help:
94
+
95
+ ```bash
96
+ # Browse resources and methods
97
+ gcloud run --help
98
+
99
+ # Inspect a specific resource methods
100
+ gcloud run services --help
101
+
102
+ # Inspect a specific resource's sub-group methods
103
+ gcloud run jobs executions --help
104
+
105
+ # Inspect a method's specific flags and arguments
106
+ gcloud run deploy --help
107
+ ```
108
+
109
+ Use the output of `--help` to discover available flags like `--image`, `--env-vars`, `--memory`, etc.
@@ -0,0 +1,88 @@
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, beforeEach, afterEach } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import os from 'os';
22
+ import { zipFiles } from '../../lib/util/archive.js';
23
+
24
+ describe('Archive Utility (zipFiles)', () => {
25
+ let tempDir;
26
+
27
+ beforeEach(() => {
28
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'archive-test-'));
29
+ });
30
+
31
+ afterEach(() => {
32
+ if (tempDir && fs.existsSync(tempDir)) {
33
+ fs.rmSync(tempDir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test('creates a valid zip archive in memory from buffer objects', async () => {
38
+ const files = [
39
+ { filename: 'file1.txt', content: 'Hello World' },
40
+ { filename: 'file2.json', content: JSON.stringify({ key: 'value' }) },
41
+ ];
42
+
43
+ const zipBuffer = await zipFiles(files, false);
44
+ assert.ok(Buffer.isBuffer(zipBuffer));
45
+ assert.ok(zipBuffer.length > 0);
46
+ });
47
+
48
+ test('creates a valid tar.gz archive in memory from buffer objects', async () => {
49
+ const files = [{ filename: 'file1.txt', content: 'Hello World Tar' }];
50
+
51
+ const tarBuffer = await zipFiles(files, true);
52
+ assert.ok(Buffer.isBuffer(tarBuffer));
53
+ assert.ok(tarBuffer.length > 0);
54
+ });
55
+
56
+ test('creates archive from actual file paths on disk', async () => {
57
+ const testFilePath = path.join(tempDir, 'sample.txt');
58
+ fs.writeFileSync(testFilePath, 'Sample file content');
59
+
60
+ const zipBuffer = await zipFiles([testFilePath], false);
61
+ assert.ok(Buffer.isBuffer(zipBuffer));
62
+ assert.ok(zipBuffer.length > 0);
63
+ });
64
+
65
+ test('creates archive from directory path on disk', async () => {
66
+ const subDir = path.join(tempDir, 'subdir');
67
+ fs.mkdirSync(subDir);
68
+ fs.writeFileSync(path.join(subDir, 'nested.txt'), 'Nested content');
69
+
70
+ const zipBuffer = await zipFiles([subDir], false);
71
+ assert.ok(Buffer.isBuffer(zipBuffer));
72
+ assert.ok(zipBuffer.length > 0);
73
+ });
74
+
75
+ test('throws error if file path does not exist', async () => {
76
+ const nonExistentPath = path.join(tempDir, 'does-not-exist.txt');
77
+ await assert.rejects(
78
+ async () => {
79
+ await zipFiles([nonExistentPath], false);
80
+ },
81
+ {
82
+ message: new RegExp(
83
+ `File or directory not found: ${nonExistentPath.replace(/\\/g, '\\\\')}`
84
+ ),
85
+ }
86
+ );
87
+ });
88
+ });