@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
@@ -17,6 +17,7 @@ describe('oauthMiddleware', () => {
17
17
  };
18
18
  res = {
19
19
  headersSent: false,
20
+ set: mock.fn(),
20
21
  status: mock.fn(() => res),
21
22
  json: mock.fn(),
22
23
  };
@@ -41,12 +42,10 @@ describe('oauthMiddleware', () => {
41
42
  assert.strictEqual(res.status.mock.callCount(), 0);
42
43
  });
43
44
 
44
- it('should call next() if method is not tools/call', async () => {
45
+ it('should return 401 if Authorization header is missing even when method is not tools/call', async () => {
45
46
  process.env.OAUTH_ENABLED = 'true';
46
47
  req.body.method = 'other/method';
47
48
 
48
- // We don't verify token if it's not a tool call (based on current implementation)
49
- // So we don't need to mock successful verification here
50
49
  const { oauthMiddleware } = await esmock(
51
50
  '../../../lib/middleware/oauth.js',
52
51
  {}
@@ -54,7 +53,61 @@ describe('oauthMiddleware', () => {
54
53
 
55
54
  await oauthMiddleware(req, res, next);
56
55
 
57
- assert.strictEqual(next.mock.callCount(), 1);
56
+ assert.strictEqual(next.mock.callCount(), 0);
57
+ assert.strictEqual(res.status.mock.callCount(), 1);
58
+ assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
59
+ assert.strictEqual(res.json.mock.callCount(), 1);
60
+ });
61
+
62
+ it('should return 401 if Authorization header is missing when req.body is a JSON array', async () => {
63
+ process.env.OAUTH_ENABLED = 'true';
64
+ req.body = [{ jsonrpc: '2.0', method: 'tools/call', id: 1 }];
65
+
66
+ const { oauthMiddleware } = await esmock(
67
+ '../../../lib/middleware/oauth.js',
68
+ {}
69
+ );
70
+
71
+ await oauthMiddleware(req, res, next);
72
+
73
+ assert.strictEqual(next.mock.callCount(), 0);
74
+ assert.strictEqual(res.status.mock.callCount(), 1);
75
+ assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
76
+ assert.strictEqual(res.json.mock.callCount(), 1);
77
+ });
78
+
79
+ it('should return 401 if Authorization header is missing when req.body is undefined (e.g. GET /sse)', async () => {
80
+ process.env.OAUTH_ENABLED = 'true';
81
+ req.body = undefined;
82
+
83
+ const { oauthMiddleware } = await esmock(
84
+ '../../../lib/middleware/oauth.js',
85
+ {}
86
+ );
87
+
88
+ await oauthMiddleware(req, res, next);
89
+
90
+ assert.strictEqual(next.mock.callCount(), 0);
91
+ assert.strictEqual(res.status.mock.callCount(), 1);
92
+ assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
93
+ assert.strictEqual(res.json.mock.callCount(), 1);
94
+ });
95
+
96
+ it('should return 401 if Authorization header has no token value', async () => {
97
+ process.env.OAUTH_ENABLED = 'true';
98
+ req.headers.authorization = 'Bearer';
99
+
100
+ const { oauthMiddleware } = await esmock(
101
+ '../../../lib/middleware/oauth.js',
102
+ {}
103
+ );
104
+
105
+ await oauthMiddleware(req, res, next);
106
+
107
+ assert.strictEqual(next.mock.callCount(), 0);
108
+ assert.strictEqual(res.status.mock.callCount(), 1);
109
+ assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
110
+ assert.strictEqual(res.json.mock.callCount(), 1);
58
111
  });
59
112
 
60
113
  it('should return 401 if Authorization header is missing for tool call', async () => {
@@ -74,8 +127,27 @@ describe('oauthMiddleware', () => {
74
127
  assert.strictEqual(res.json.mock.callCount(), 1);
75
128
  });
76
129
 
130
+ it('should return 401 if GOOGLE_OAUTH_AUDIENCE is missing when OAUTH_ENABLED is "true"', async () => {
131
+ process.env.OAUTH_ENABLED = 'true';
132
+ delete process.env.GOOGLE_OAUTH_AUDIENCE;
133
+ req.body.method = 'tools/call';
134
+ req.headers.authorization = 'Bearer valid-token';
135
+
136
+ const { oauthMiddleware } = await esmock(
137
+ '../../../lib/middleware/oauth.js',
138
+ {}
139
+ );
140
+
141
+ await oauthMiddleware(req, res, next);
142
+
143
+ assert.strictEqual(next.mock.callCount(), 0);
144
+ assert.strictEqual(res.status.mock.callCount(), 1);
145
+ assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
146
+ });
147
+
77
148
  it('should verify token and call next() for valid tool call', async () => {
78
149
  process.env.OAUTH_ENABLED = 'true';
150
+ process.env.GOOGLE_OAUTH_AUDIENCE = 'valid-audience';
79
151
  req.body.method = 'tools/call';
80
152
  req.headers.authorization = 'Bearer valid-token';
81
153
 
@@ -102,6 +174,7 @@ describe('oauthMiddleware', () => {
102
174
 
103
175
  it('should return 401 if token verification fails', async () => {
104
176
  process.env.OAUTH_ENABLED = 'true';
177
+ process.env.GOOGLE_OAUTH_AUDIENCE = 'valid-audience';
105
178
  req.body.method = 'tools/call';
106
179
  req.headers.authorization = 'Bearer invalid-token';
107
180
 
@@ -127,12 +200,13 @@ describe('oauthMiddleware', () => {
127
200
  assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
128
201
  });
129
202
 
130
- it('should return 401 if audience does not match', async () => {
203
+ it('should return 401 if audience does not match and remove token from oauth cache', async () => {
131
204
  process.env.OAUTH_ENABLED = 'true';
132
205
  process.env.GOOGLE_OAUTH_AUDIENCE = 'expected-audience';
133
206
  req.body.method = 'tools/call';
134
207
  req.headers.authorization = 'Bearer valid-token-wrong-audience';
135
208
 
209
+ const mockOAuthMap = new Map([['valid-token-wrong-audience', {}]]);
136
210
  const mockGetTokenInfo = mock.fn(async () => ({ aud: 'wrong-audience' }));
137
211
  const mockGetOAuthClient = mock.fn(async () => ({
138
212
  getTokenInfo: mockGetTokenInfo,
@@ -142,6 +216,7 @@ describe('oauthMiddleware', () => {
142
216
  '../../../lib/middleware/oauth.js',
143
217
  {
144
218
  '../../../lib/clients.js': {
219
+ clients: { oauth: mockOAuthMap },
145
220
  getOAuthClient: mockGetOAuthClient,
146
221
  },
147
222
  }
@@ -152,5 +227,31 @@ describe('oauthMiddleware', () => {
152
227
  assert.strictEqual(next.mock.callCount(), 0);
153
228
  assert.strictEqual(res.status.mock.callCount(), 1);
154
229
  assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
230
+ assert.strictEqual(mockOAuthMap.has('valid-token-wrong-audience'), false);
231
+ assert.strictEqual(
232
+ res.json.mock.calls[0].arguments[0].error.data,
233
+ 'Invalid or missing access token'
234
+ );
235
+ });
236
+
237
+ it('should set WWW-Authenticate header on 401 when OAUTH_PROTECTED_RESOURCE is set', async () => {
238
+ process.env.OAUTH_ENABLED = 'true';
239
+ process.env.OAUTH_PROTECTED_RESOURCE = 'https://example.com/resource';
240
+
241
+ const { oauthMiddleware } = await esmock(
242
+ '../../../lib/middleware/oauth.js',
243
+ {}
244
+ );
245
+
246
+ await oauthMiddleware(req, res, next);
247
+
248
+ assert.strictEqual(next.mock.callCount(), 0);
249
+ assert.strictEqual(res.set.mock.callCount(), 1);
250
+ assert.deepStrictEqual(res.set.mock.calls[0].arguments, [
251
+ 'WWW-Authenticate',
252
+ 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"',
253
+ ]);
254
+ assert.strictEqual(res.status.mock.callCount(), 1);
255
+ assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
155
256
  });
156
257
  });
@@ -1,19 +1,3 @@
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
1
  /**
18
2
  * Waits for a specific string to appear in a stream's output.
19
3
  * This is useful for synchronizing tests with asynchronous child processes,
@@ -22,11 +6,11 @@ limitations under the License.
22
6
  *
23
7
  * @param {ReadableStream} stream - The stream to listen to (e.g., process.stdout or process.stderr).
24
8
  * @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.
9
+ * @param {number} [timeoutMs=10000] - The maximum time in milliseconds to wait before rejecting.
26
10
  * @returns {Promise<string>} A promise that resolves with the accumulated data
27
11
  * when the string is found, or rejects if the timeout is reached.
28
12
  */
29
- export async function waitForString(stream, str, timeoutMs = 7000) {
13
+ export async function waitForString(stream, str, timeoutMs = 10000) {
30
14
  let accumulatedData = '';
31
15
  return new Promise((resolve, reject) => {
32
16
  const timeout = setTimeout(() => {
@@ -207,6 +207,7 @@ describe('registerTools', () => {
207
207
  name: 'my-service',
208
208
  uri: 'my-uri',
209
209
  lastModifier: 'me',
210
+ updateTime: { seconds: '1735732800', nanos: 0 }, // 2025-01-01T12:00:00Z
210
211
  }),
211
212
  },
212
213
  }
@@ -223,11 +224,12 @@ describe('registerTools', () => {
223
224
  service: 'my-service',
224
225
  });
225
226
 
227
+ const expectedDate = new Date('2025-01-01T12:00:00Z').toUTCString();
226
228
  assert.deepStrictEqual(result, {
227
229
  content: [
228
230
  {
229
231
  type: 'text',
230
- text: 'Name: my-service\nRegion: my-region\nProject: my-project\nURL: my-uri\nLast deployed by: me',
232
+ text: `Name: my-service\nRegion: my-region\nProject: my-project\nURL: my-uri\nConsole URL: https://console.cloud.google.com/run/detail/my-region/my-service?project=my-project\nLast deployed by: me\nLast deployed date: ${expectedDate}`,
231
233
  },
232
234
  ],
233
235
  });
@@ -324,6 +326,46 @@ describe('registerTools', () => {
324
326
  ],
325
327
  });
326
328
  });
329
+
330
+ it('should deploy local folder with ingress', async () => {
331
+ const server = {
332
+ registerTool: mock.fn(),
333
+ };
334
+ const deployMock = mock.fn(() => Promise.resolve({ uri: 'my-uri' }));
335
+
336
+ const { registerTools } = await esmock(
337
+ '../../tools/tools.js',
338
+ {},
339
+ {
340
+ '../../lib/deployment/deployer.js': {
341
+ deploy: deployMock,
342
+ },
343
+ }
344
+ );
345
+
346
+ registerTools(server, {
347
+ gcpCredentialsAvailable: true,
348
+ ingress: 'INGRESS_TRAFFIC_INTERNAL_ONLY',
349
+ });
350
+
351
+ const handler = server.registerTool.mock.calls.find(
352
+ (call) => call.arguments[0] === 'deploy_local_folder'
353
+ ).arguments[2];
354
+ await handler(
355
+ {
356
+ project: 'my-project',
357
+ region: 'my-region',
358
+ service: 'my-service',
359
+ folderPath: '/my/folder',
360
+ },
361
+ { sendNotification: mock.fn() }
362
+ );
363
+
364
+ assert.strictEqual(
365
+ deployMock.mock.calls[0].arguments[0].ingress,
366
+ 'INGRESS_TRAFFIC_INTERNAL_ONLY'
367
+ );
368
+ });
327
369
  });
328
370
 
329
371
  describe('deploy_file_contents', () => {
@@ -22,15 +22,8 @@ describe('Universal Maker', () => {
22
22
  const fsMock = {
23
23
  existsSync: mock.fn(),
24
24
  mkdirSync: mock.fn(),
25
- chmodSync: mock.fn(),
26
25
  readFileSync: mock.fn(),
27
26
  rmSync: mock.fn(),
28
- unlinkSync: mock.fn(),
29
- createWriteStream: mock.fn(),
30
- };
31
-
32
- const cryptoMock = {
33
- createHash: mock.fn(),
34
27
  };
35
28
 
36
29
  const childProcessMock = {
@@ -46,34 +39,27 @@ describe('Universal Maker', () => {
46
39
  logAndProgress: mock.fn(),
47
40
  };
48
41
 
49
- const clientsMock = {
50
- getArtifactRegistryClient: mock.fn(),
42
+ const artifactsMock = {
43
+ ensureRepositoryDownloaded: mock.fn(),
51
44
  };
52
45
 
53
46
  test('runUniversalMaker skips download if binary exists and runs successfully', async () => {
54
47
  fsMock.existsSync.mock.resetCalls();
55
48
  fsMock.readFileSync.mock.resetCalls();
56
49
  childProcessMock.exec.mock.resetCalls();
50
+ artifactsMock.ensureRepositoryDownloaded.mock.resetCalls();
57
51
 
58
- const localContent = 'local content';
59
- const sha256 =
60
- 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
61
-
62
- cryptoMock.createHash.mock.mockImplementation(() => ({
63
- update: mock.fn(() => ({
64
- digest: mock.fn(() => sha256),
65
- })),
66
- }));
52
+ // Mock binary exists and is up to date
53
+ artifactsMock.ensureRepositoryDownloaded.mock.mockImplementation(
54
+ () => '/home/user/.cloud-run-mcp/bin/universal_maker'
55
+ );
67
56
 
68
- // Mock binary exists
69
57
  fsMock.existsSync.mock.mockImplementation((p) => {
70
- if (p.includes('universal_maker')) return true;
71
58
  if (p.includes('build_output.json')) return true;
72
59
  return false;
73
60
  });
74
61
 
75
62
  fsMock.readFileSync.mock.mockImplementation((p) => {
76
- if (p.includes('universal_maker')) return localContent;
77
63
  if (p.includes('build_output.json')) {
78
64
  return JSON.stringify({
79
65
  command: 'node',
@@ -85,34 +71,12 @@ describe('Universal Maker', () => {
85
71
  return '';
86
72
  });
87
73
 
88
- // Mock Artifact Registry client
89
- const artifactRegistryClientMock = {
90
- filePath: mock.fn(
91
- (p, l, r, f) =>
92
- `projects/${p}/locations/${l}/repositories/${r}/files/${f}`
93
- ),
94
- getFile: mock.fn(() => [
95
- {
96
- hashes: [
97
- {
98
- type: 'SHA256',
99
- value: Buffer.from(sha256, 'hex').toString('base64'),
100
- },
101
- ],
102
- },
103
- ]),
104
- };
105
- clientsMock.getArtifactRegistryClient.mock.mockImplementation(
106
- () => artifactRegistryClientMock
107
- );
108
-
109
74
  const um = await esmock('../../lib/deployment/universal-maker.js', {
110
75
  fs: fsMock,
111
76
  child_process: childProcessMock,
112
77
  os: osMock,
113
- crypto: cryptoMock,
114
78
  '../../lib/util/helpers.js': helpersMock,
115
- '../../lib/clients.js': clientsMock,
79
+ '../../lib/util/artifacts.js': artifactsMock,
116
80
  });
117
81
 
118
82
  // Mock exec only for binary execution
@@ -132,92 +96,38 @@ describe('Universal Maker', () => {
132
96
  assert.deepEqual(result.args, ['index.js']);
133
97
  assert.equal(result.runtime, 'nodejs20');
134
98
 
135
- // Verify bin check was performed
136
- const binExistsCall = fsMock.existsSync.mock.calls.find((c) =>
137
- c.arguments[0].includes('universal_maker')
99
+ // Verify ensureRepositoryDownloaded was called
100
+ assert.strictEqual(
101
+ artifactsMock.ensureRepositoryDownloaded.mock.callCount(),
102
+ 1
138
103
  );
139
- assert.ok(binExistsCall);
140
104
  });
141
105
 
142
- test('runUniversalMaker downloads binary if missing', async () => {
143
- fsMock.existsSync.mock.resetCalls();
144
- fsMock.readFileSync.mock.resetCalls();
145
- childProcessMock.exec.mock.resetCalls();
146
-
147
- let downloaded = false;
148
-
149
- // Mock binary missing first, then exists after "download"
150
- fsMock.existsSync.mock.mockImplementation((p) => {
151
- if (p.includes('universal_maker')) return downloaded;
152
- if (p.includes('build_output.json')) return true;
153
- return true; // for dir checks
154
- });
155
-
156
- // Mock Artifact Registry client for download
157
- const artifactRegistryClientMock = {
158
- filePath: mock.fn(
159
- (p, l, r, f) =>
160
- `projects/${p}/locations/${l}/repositories/${r}/files/${f}`
161
- ),
162
- getFile: mock.fn(() => [{ hashes: [] }]), // trigger download
163
- auth: {
164
- request: mock.fn(() => {
165
- downloaded = true;
166
- return {
167
- data: {
168
- pipe: (dest) => {
169
- setTimeout(() => dest.emit('finish'), 10);
170
- return dest;
171
- },
172
- },
173
- };
174
- }),
175
- },
176
- };
177
- clientsMock.getArtifactRegistryClient.mock.mockImplementation(
178
- () => artifactRegistryClientMock
179
- );
180
-
181
- const writeStreamMock = {
182
- on: mock.fn(function (event, cb) {
183
- if (event === 'finish') this.finishCb = cb;
184
- return this;
185
- }),
186
- emit: mock.fn(function (event) {
187
- if (event === 'finish' && this.finishCb) this.finishCb();
188
- }),
189
- pipeFrom: mock.fn(),
190
- };
191
- fsMock.createWriteStream = mock.fn(() => writeStreamMock);
192
-
193
- fsMock.readFileSync.mock.mockImplementation(() =>
194
- JSON.stringify({ command: 'npm', args: ['start'] })
106
+ test('runUniversalMaker behaves correctly when binary fails to download', async () => {
107
+ artifactsMock.ensureRepositoryDownloaded.mock.resetCalls();
108
+ artifactsMock.ensureRepositoryDownloaded.mock.mockImplementation(
109
+ () => null
195
110
  );
196
111
 
197
112
  const um = await esmock('../../lib/deployment/universal-maker.js', {
198
113
  fs: fsMock,
199
114
  child_process: childProcessMock,
200
115
  os: osMock,
201
- crypto: cryptoMock,
202
116
  '../../lib/util/helpers.js': helpersMock,
203
- '../../lib/clients.js': clientsMock,
204
- });
205
-
206
- // Mock exec only for binary execution
207
- childProcessMock.exec.mock.mockImplementation((cmd, cb) => {
208
- cb(null, { stdout: '', stderr: '' });
117
+ '../../lib/util/artifacts.js': artifactsMock,
209
118
  });
210
119
 
211
- // runUniversalMaker(appDir, accessToken, progressCallback)
212
120
  const result = await um.runUniversalMaker(
213
121
  '/app/dir',
214
122
  'fake-token',
215
123
  mock.fn()
216
124
  );
217
125
 
218
- assert.ok(result);
219
- assert.equal(result.command, 'npm');
220
- assert.ok(downloaded);
126
+ assert.equal(result, null);
127
+ assert.strictEqual(
128
+ artifactsMock.ensureRepositoryDownloaded.mock.callCount(),
129
+ 1
130
+ );
221
131
  });
222
132
 
223
133
  test('runUniversalMaker returns null if binary not supported on platform', async () => {
@@ -230,9 +140,8 @@ describe('Universal Maker', () => {
230
140
  fs: fsMock,
231
141
  child_process: childProcessMock,
232
142
  os: osMock,
233
- crypto: cryptoMock,
234
143
  '../../lib/util/helpers.js': helpersMock,
235
- '../../lib/clients.js': clientsMock,
144
+ '../../lib/util/artifacts.js': artifactsMock,
236
145
  });
237
146
 
238
147
  const result = await um.runUniversalMaker(