@google-cloud/cloud-run-mcp 1.5.0 → 1.6.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 (37) hide show
  1. package/.c8rc.json +9 -0
  2. package/.github/workflows/lint-checker.yml +17 -0
  3. package/.kokoro/kokoro_build.sh +22 -0
  4. package/.kokoro/presubmit.cfg +21 -0
  5. package/.kokoro/run_tests.sh +31 -0
  6. package/CONTRIBUTING.md +23 -8
  7. package/README.md +52 -11
  8. package/REVIEWING.md +20 -0
  9. package/gemini-extension/GEMINI.md +1 -1
  10. package/lib/cloud-api/build.js +200 -0
  11. package/lib/{gcp-api-helpers.js → cloud-api/helpers.js} +12 -8
  12. package/lib/{gcp-projects.js → cloud-api/projects.js} +58 -28
  13. package/lib/cloud-api/registry.js +98 -0
  14. package/lib/{cloud-run-services.js → cloud-api/run.js} +103 -21
  15. package/lib/cloud-api/storage.js +123 -0
  16. package/lib/deployment/deployer.js +474 -0
  17. package/lib/util/archive.js +95 -0
  18. package/lib/util/helpers.js +42 -0
  19. package/mcp-server.js +21 -10
  20. package/package.json +7 -4
  21. package/test/local/cloud-api/build.test.js +436 -0
  22. package/test/local/cloud-api/projects.test.js +18 -0
  23. package/test/local/mcp-server-streamable-http.test.js +57 -0
  24. package/test/local/notifications.test.js +9 -6
  25. package/test/local/tools.test.js +114 -70
  26. package/test/need-gcp/cloud-run-services.test.js +44 -44
  27. package/test/need-gcp/{cloud-run-deploy.test.js → deployer.test.js} +3 -56
  28. package/test/need-gcp/gcp-auth-check.test.js +1 -1
  29. package/test/need-gcp/gcp-projects.test.js +3 -15
  30. package/test/need-gcp/test-helpers.js +175 -0
  31. package/test/need-gcp/workflows/deployment-workflows.test.js +100 -0
  32. package/tools/register-tools.js +106 -97
  33. package/tools/tools.js +2 -9
  34. package/lib/cloud-run-deploy.js +0 -937
  35. /package/lib/{gcp-auth-check.js → cloud-api/auth.js} +0 -0
  36. /package/lib/{gcp-billing-accounts.js → cloud-api/billing.js} +0 -0
  37. /package/lib/{gcp-metadata.js → cloud-api/metadata.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google-cloud/cloud-run-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Cloud Run MCP deployment tool",
6
6
  "main": "mcp-server.js",
@@ -9,14 +9,16 @@
9
9
  },
10
10
  "scripts": {
11
11
  "deploy": "gcloud run deploy cloud-run-mcp --source . --no-invoker-iam-check",
12
- "test:deploy": "node test/need-gcp/cloud-run-deploy.test.js",
12
+ "test:workflows": "node --test test/need-gcp/workflows/*.test.js",
13
+ "test:deploy": "node test/need-gcp/deployer.test.js",
13
14
  "test:projects": "node test/need-gcp/gcp-projects.test.js",
14
15
  "test:services": "node test/need-gcp/cloud-run-services.test.js",
15
16
  "test:gcp-auth": "node test/need-gcp/gcp-auth-check.test.js",
16
17
  "test:prompts": "node --test test/local/prompts.test.js",
17
18
  "test:mcp": "node --test test/local/mcp-server.test.js",
18
19
  "test:tools": "node --test test/local/tools.test.js",
19
- "test": "node --test test/local/*.test.js",
20
+ "test:local": "node --test test/local/*.test.js test/local/**/*.test.js",
21
+ "test": "c8 --check-coverage npm run test:local",
20
22
  "mcp-inspector": "npx @modelcontextprotocol/inspector node mcp-server.js",
21
23
  "start": "node mcp-server.js",
22
24
  "lint:check": "prettier --check .",
@@ -46,7 +48,7 @@
46
48
  "@google-cloud/run": "^2.0.1",
47
49
  "@google-cloud/service-usage": "^4.1.0",
48
50
  "@google-cloud/storage": "^7.16.0",
49
- "@modelcontextprotocol/sdk": "^1.11.0",
51
+ "@modelcontextprotocol/sdk": "^1.24.3",
50
52
  "archiver": "^7.0.1",
51
53
  "dotenv": "^17.2.1",
52
54
  "express": "^5.1.0",
@@ -54,6 +56,7 @@
54
56
  "zod": "^3.25.76"
55
57
  },
56
58
  "devDependencies": {
59
+ "c8": "^10.1.3",
57
60
  "esmock": "^2.7.1",
58
61
  "prettier": "3.6.2"
59
62
  }
@@ -0,0 +1,436 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it, mock } from 'node:test';
3
+ import esmock from 'esmock';
4
+
5
+ describe('triggerCloudBuild', () => {
6
+ it('should return a successful build and log correct messages', async () => {
7
+ const mockBuildId = 'mock-build-id';
8
+ const mockSuccessResult = {
9
+ id: mockBuildId,
10
+ status: 'SUCCESS',
11
+ results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
12
+ };
13
+
14
+ const getBuildMock = mock.fn(() => Promise.resolve([mockSuccessResult]));
15
+ const logAndProgressMock = mock.fn();
16
+
17
+ const { triggerCloudBuild } = await esmock(
18
+ '../../../lib/cloud-api/build.js',
19
+ {
20
+ '../../../lib/cloud-api/helpers.js': {
21
+ callWithRetry: (fn) => fn(), // Directly execute the function
22
+ },
23
+ '../../../lib/util/helpers.js': {
24
+ logAndProgress: logAndProgressMock,
25
+ },
26
+ }
27
+ );
28
+
29
+ const context = {
30
+ cloudBuildClient: {
31
+ createBuild: mock.fn(() =>
32
+ Promise.resolve([
33
+ {
34
+ metadata: {
35
+ build: {
36
+ id: mockBuildId,
37
+ },
38
+ },
39
+ },
40
+ ])
41
+ ),
42
+ getBuild: getBuildMock,
43
+ },
44
+ };
45
+
46
+ const result = await triggerCloudBuild(
47
+ context,
48
+ 'mock-project',
49
+ 'mock-location',
50
+ 'mock-bucket',
51
+ 'mock-blob',
52
+ 'mock-repo',
53
+ 'gcr.io/mock-project/mock-image',
54
+ true,
55
+ () => {}
56
+ );
57
+
58
+ assert.deepStrictEqual(result, mockSuccessResult);
59
+ assert.strictEqual(
60
+ context.cloudBuildClient.createBuild.mock.callCount(),
61
+ 1
62
+ );
63
+ assert.strictEqual(context.cloudBuildClient.getBuild.mock.callCount(), 1);
64
+
65
+ const { calls: logCalls } = logAndProgressMock.mock;
66
+ assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
67
+ assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
68
+ assert.match(logCalls[2].arguments[0], /completed successfully/);
69
+ assert.match(logCalls[3].arguments[0], /Image built/);
70
+ });
71
+
72
+ it('should throw an error for a failed build and log correct messages', async () => {
73
+ const mockBuildId = 'mock-build-id-failure';
74
+ const mockFailureResult = {
75
+ id: mockBuildId,
76
+ status: 'FAILURE',
77
+ logUrl: 'http://mock-log-url.com',
78
+ };
79
+
80
+ const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
81
+ const logAndProgressMock = mock.fn();
82
+ const setTimeoutMock = mock.fn((resolve) => resolve());
83
+ mock.method(global, 'setTimeout', setTimeoutMock);
84
+
85
+ const { triggerCloudBuild } = await esmock(
86
+ '../../../lib/cloud-api/build.js',
87
+ {
88
+ '../../../lib/cloud-api/helpers.js': {
89
+ callWithRetry: (fn) => fn(),
90
+ },
91
+ '../../../lib/util/helpers.js': {
92
+ logAndProgress: logAndProgressMock,
93
+ },
94
+ }
95
+ );
96
+
97
+ const context = {
98
+ cloudBuildClient: {
99
+ createBuild: mock.fn(() =>
100
+ Promise.resolve([
101
+ {
102
+ metadata: {
103
+ build: {
104
+ id: mockBuildId,
105
+ },
106
+ },
107
+ },
108
+ ])
109
+ ),
110
+ getBuild: getBuildMock,
111
+ },
112
+ loggingClient: {
113
+ getEntries: mock.fn(() =>
114
+ Promise.resolve([[{ data: 'log line 1' }, { data: 'log line 2' }]])
115
+ ),
116
+ },
117
+ };
118
+
119
+ await assert.rejects(
120
+ () =>
121
+ triggerCloudBuild(
122
+ context,
123
+ 'mock-project',
124
+ 'mock-location',
125
+ 'mock-bucket',
126
+ 'mock-blob',
127
+ 'mock-repo',
128
+ 'gcr.io/mock-project/mock-image',
129
+ true,
130
+ () => {}
131
+ ),
132
+ (err) => {
133
+ assert.match(err.message, /Build mock-build-id-failure failed/);
134
+ assert.match(err.message, /log line 1/);
135
+ assert.match(err.message, /log line 2/);
136
+ return true;
137
+ }
138
+ );
139
+
140
+ assert.strictEqual(
141
+ context.cloudBuildClient.createBuild.mock.callCount(),
142
+ 1
143
+ );
144
+ assert.strictEqual(context.cloudBuildClient.getBuild.mock.callCount(), 1);
145
+ assert.strictEqual(context.loggingClient.getEntries.mock.callCount(), 1);
146
+ assert.strictEqual(setTimeoutMock.mock.callCount(), 1);
147
+ assert.strictEqual(setTimeoutMock.mock.calls[0].arguments[1], 10000);
148
+
149
+ const { calls: logCalls } = logAndProgressMock.mock;
150
+ assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
151
+ assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
152
+ assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
153
+ assert.match(logCalls[3].arguments[0], /Build logs:/);
154
+ assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
155
+ assert.match(logCalls[5].arguments[0], /Successfully fetched snippet/);
156
+ });
157
+
158
+ it('should use buildpacks when no Dockerfile is present', async () => {
159
+ const mockBuildId = 'mock-build-id-buildpacks';
160
+ const mockSuccessResult = {
161
+ id: mockBuildId,
162
+ status: 'SUCCESS',
163
+ results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
164
+ };
165
+
166
+ const getBuildMock = mock.fn(() => Promise.resolve([mockSuccessResult]));
167
+ const createBuildMock = mock.fn(() =>
168
+ Promise.resolve([
169
+ {
170
+ metadata: {
171
+ build: {
172
+ id: mockBuildId,
173
+ },
174
+ },
175
+ },
176
+ ])
177
+ );
178
+
179
+ const { triggerCloudBuild } = await esmock(
180
+ '../../../lib/cloud-api/build.js',
181
+ {
182
+ '../../../lib/cloud-api/helpers.js': {
183
+ callWithRetry: (fn) => fn(),
184
+ },
185
+ '../../../lib/util/helpers.js': {
186
+ logAndProgress: () => {},
187
+ },
188
+ }
189
+ );
190
+
191
+ const context = {
192
+ cloudBuildClient: {
193
+ createBuild: createBuildMock,
194
+ getBuild: getBuildMock,
195
+ },
196
+ };
197
+
198
+ await triggerCloudBuild(
199
+ context,
200
+ 'mock-project',
201
+ 'mock-location',
202
+ 'mock-bucket',
203
+ 'mock-blob',
204
+ 'mock-repo',
205
+ 'gcr.io/mock-project/mock-image',
206
+ false, // hasDockerfile = false
207
+ () => {}
208
+ );
209
+
210
+ assert.strictEqual(createBuildMock.mock.callCount(), 1);
211
+ const buildArg = createBuildMock.mock.calls[0].arguments[0].build;
212
+ const buildStep = buildArg.steps[0];
213
+ assert.strictEqual(buildStep.name, 'gcr.io/k8s-skaffold/pack');
214
+ });
215
+
216
+ it('should poll for build status until completion', async () => {
217
+ const mockBuildId = 'mock-build-id-polling';
218
+ const mockWorkingResult = { id: mockBuildId, status: 'WORKING' };
219
+ const mockSuccessResult = {
220
+ id: mockBuildId,
221
+ status: 'SUCCESS',
222
+ results: { images: [{ name: 'gcr.io/mock-project/mock-image' }] },
223
+ };
224
+
225
+ let getBuildCallCount = 0;
226
+ const getBuildMock = mock.fn(() => {
227
+ getBuildCallCount++;
228
+ if (getBuildCallCount === 1) {
229
+ return Promise.resolve([mockWorkingResult]);
230
+ }
231
+ return Promise.resolve([mockSuccessResult]);
232
+ });
233
+
234
+ const logAndProgressMock = mock.fn();
235
+ const setTimeoutMock = mock.fn((resolve) => resolve());
236
+ mock.method(global, 'setTimeout', setTimeoutMock);
237
+
238
+ const { triggerCloudBuild } = await esmock(
239
+ '../../../lib/cloud-api/build.js',
240
+ {
241
+ '../../../lib/cloud-api/helpers.js': {
242
+ callWithRetry: (fn) => fn(),
243
+ },
244
+ '../../../lib/util/helpers.js': {
245
+ logAndProgress: logAndProgressMock,
246
+ },
247
+ }
248
+ );
249
+
250
+ const context = {
251
+ cloudBuildClient: {
252
+ createBuild: mock.fn(() =>
253
+ Promise.resolve([
254
+ {
255
+ metadata: {
256
+ build: {
257
+ id: mockBuildId,
258
+ },
259
+ },
260
+ },
261
+ ])
262
+ ),
263
+ getBuild: getBuildMock,
264
+ },
265
+ };
266
+
267
+ await triggerCloudBuild(
268
+ context,
269
+ 'mock-project',
270
+ 'mock-location',
271
+ 'mock-bucket',
272
+ 'mock-blob',
273
+ 'mock-repo',
274
+ 'gcr.io/mock-project/mock-image',
275
+ true,
276
+ () => {}
277
+ );
278
+
279
+ assert.strictEqual(getBuildMock.mock.callCount(), 2);
280
+ assert.strictEqual(setTimeoutMock.mock.callCount(), 1);
281
+ assert.strictEqual(setTimeoutMock.mock.calls[0].arguments[1], 5000);
282
+ const { calls: logCalls } = logAndProgressMock.mock;
283
+ assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
284
+ assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
285
+ assert.match(logCalls[2].arguments[0], /Build status: WORKING/);
286
+ assert.match(logCalls[3].arguments[0], /completed successfully/);
287
+ assert.match(logCalls[4].arguments[0], /Image built/);
288
+ });
289
+
290
+ it('should handle failed build when no logs are found', async () => {
291
+ const mockBuildId = 'mock-build-id-no-logs';
292
+ const mockFailureResult = {
293
+ id: mockBuildId,
294
+ status: 'FAILURE',
295
+ logUrl: 'http://mock-log-url.com',
296
+ };
297
+
298
+ const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
299
+ const logAndProgressMock = mock.fn();
300
+
301
+ const { triggerCloudBuild } = await esmock(
302
+ '../../../lib/cloud-api/build.js',
303
+ {
304
+ '../../../lib/cloud-api/helpers.js': {
305
+ callWithRetry: (fn) => fn(),
306
+ },
307
+ '../../../lib/util/helpers.js': {
308
+ logAndProgress: logAndProgressMock,
309
+ },
310
+ }
311
+ );
312
+
313
+ const context = {
314
+ cloudBuildClient: {
315
+ createBuild: mock.fn(() =>
316
+ Promise.resolve([
317
+ {
318
+ metadata: {
319
+ build: {
320
+ id: mockBuildId,
321
+ },
322
+ },
323
+ },
324
+ ])
325
+ ),
326
+ getBuild: getBuildMock,
327
+ },
328
+ loggingClient: {
329
+ getEntries: mock.fn(() => Promise.resolve([[]])), // No log entries
330
+ },
331
+ };
332
+
333
+ await assert.rejects(
334
+ () =>
335
+ triggerCloudBuild(
336
+ context,
337
+ 'mock-project',
338
+ 'mock-location',
339
+ 'mock-bucket',
340
+ 'mock-blob',
341
+ 'mock-repo',
342
+ 'gcr.io/mock-project/mock-image',
343
+ true,
344
+ () => {}
345
+ ),
346
+ (err) => {
347
+ assert.match(err.message, /Build mock-build-id-no-logs failed/);
348
+ assert.doesNotMatch(err.message, /Last log lines/);
349
+ return true;
350
+ }
351
+ );
352
+
353
+ const { calls: logCalls } = logAndProgressMock.mock;
354
+ assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
355
+ assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
356
+ assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
357
+ assert.match(logCalls[3].arguments[0], /Build logs:/);
358
+ assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
359
+ assert.match(logCalls[5].arguments[0], /No specific log entries retrieved/);
360
+ });
361
+
362
+ it('should handle error when fetching logs for a failed build', async () => {
363
+ const mockBuildId = 'mock-build-id-log-error';
364
+ const mockFailureResult = {
365
+ id: mockBuildId,
366
+ status: 'FAILURE',
367
+ logUrl: 'http://mock-log-url.com',
368
+ };
369
+
370
+ const getBuildMock = mock.fn(() => Promise.resolve([mockFailureResult]));
371
+ const logAndProgressMock = mock.fn();
372
+
373
+ const { triggerCloudBuild } = await esmock(
374
+ '../../../lib/cloud-api/build.js',
375
+ {
376
+ '../../../lib/cloud-api/helpers.js': {
377
+ callWithRetry: (fn) => fn(),
378
+ },
379
+ '../../../lib/util/helpers.js': {
380
+ logAndProgress: logAndProgressMock,
381
+ },
382
+ }
383
+ );
384
+
385
+ const context = {
386
+ cloudBuildClient: {
387
+ createBuild: mock.fn(() =>
388
+ Promise.resolve([
389
+ {
390
+ metadata: {
391
+ build: {
392
+ id: mockBuildId,
393
+ },
394
+ },
395
+ },
396
+ ])
397
+ ),
398
+ getBuild: getBuildMock,
399
+ },
400
+ loggingClient: {
401
+ getEntries: mock.fn(() => Promise.reject(new Error('Log fetch error'))),
402
+ },
403
+ };
404
+
405
+ await assert.rejects(
406
+ () =>
407
+ triggerCloudBuild(
408
+ context,
409
+ 'mock-project',
410
+ 'mock-location',
411
+ 'mock-bucket',
412
+ 'mock-blob',
413
+ 'mock-repo',
414
+ 'gcr.io/mock-project/mock-image',
415
+ true,
416
+ () => {}
417
+ ),
418
+ (err) => {
419
+ assert.match(err.message, /Build mock-build-id-log-error failed/);
420
+ assert.doesNotMatch(err.message, /Last log lines/);
421
+ return true;
422
+ }
423
+ );
424
+
425
+ const { calls: logCalls } = logAndProgressMock.mock;
426
+ assert.match(logCalls[0].arguments[0], /Initiating Cloud Build/);
427
+ assert.match(logCalls[1].arguments[0], /Cloud Build job started/);
428
+ assert.match(logCalls[2].arguments[0], /failed with status: FAILURE/);
429
+ assert.match(logCalls[3].arguments[0], /Build logs:/);
430
+ assert.match(logCalls[4].arguments[0], /Attempting to fetch last/);
431
+ assert.match(
432
+ logCalls[5].arguments[0],
433
+ /Failed to fetch build logs snippet/
434
+ );
435
+ });
436
+ });
@@ -0,0 +1,18 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import esmock from 'esmock';
4
+
5
+ describe('projects', () => {
6
+ describe('generateProjectId', () => {
7
+ it('should generate a project id in the correct format', async () => {
8
+ const { generateProjectId } = await esmock(
9
+ '../../../lib/cloud-api/projects.js',
10
+ {}
11
+ );
12
+
13
+ const projectId = generateProjectId();
14
+ assert.ok(projectId.startsWith('mcp-'));
15
+ assert.strictEqual(projectId.length, 11);
16
+ });
17
+ });
18
+ });
@@ -0,0 +1,57 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { test, describe, before, after } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { spawn } from 'child_process';
6
+
7
+ class MCPClient {
8
+ client = null;
9
+ transport = null;
10
+
11
+ constructor(serverName) {
12
+ this.client = new Client({
13
+ name: `mcp-client-for-${serverName}`,
14
+ version: '1.0.0',
15
+ url: `http://localhost:3000/mcp`,
16
+ });
17
+ }
18
+
19
+ async connectToServer(serverUrl) {
20
+ this.transport = new StreamableHTTPClientTransport(serverUrl);
21
+ await this.client.connect(this.transport);
22
+ }
23
+
24
+ async cleanup() {
25
+ await this.client.close();
26
+ }
27
+ }
28
+
29
+ describe('MCP Server in Streamble HTTP mode', () => {
30
+ let client;
31
+ let serverProcess;
32
+
33
+ before(async () => {
34
+ // Start MCP server as a child process
35
+ serverProcess = spawn('node', ['mcp-server.js'], {
36
+ cwd: process.cwd(),
37
+ env: { ...process.env, GCP_STDIO: 'false' },
38
+ stdio: 'inherit',
39
+ });
40
+
41
+ // Wait for server to start (better: poll the port, here we just wait)
42
+ await new Promise((resolve) => setTimeout(resolve, 2000));
43
+
44
+ client = new MCPClient('http-server');
45
+ });
46
+
47
+ after(async () => {
48
+ await client.cleanup();
49
+ if (serverProcess) {
50
+ serverProcess.kill();
51
+ }
52
+ });
53
+
54
+ test('should start an HTTP server', async () => {
55
+ await client.connectToServer('http://localhost:3000/mcp');
56
+ });
57
+ });
@@ -8,12 +8,15 @@ describe('Tool Notifications', () => {
8
8
  registerTool: mock.fn(),
9
9
  };
10
10
 
11
- const { registerTools } = await esmock('../../tools/tools.js', {}, {
12
- '../../lib/cloud-run-deploy.js': {
13
- deploy: () => Promise.resolve({ uri: 'my-uri' }),
14
- },
15
- },
16
- );
11
+ const { registerTools } = await esmock(
12
+ '../../tools/tools.js',
13
+ {},
14
+ {
15
+ '../../lib/deployment/deployer.js': {
16
+ deploy: () => Promise.resolve({ uri: 'my-uri' }),
17
+ },
18
+ }
19
+ );
17
20
 
18
21
  registerTools(server, { gcpCredentialsAvailable: true });
19
22