@google-cloud/cloud-run-mcp 1.7.0 → 1.9.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.
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ import { getRunClient } from '../clients.js';
17
18
  import { callWithRetry, ensureApisEnabled } from '../cloud-api/helpers.js';
18
19
  import { zipFiles } from '../util/archive.js';
19
20
  import {
@@ -58,7 +59,6 @@ import {
58
59
  * @throws {Error} If the deployment or update process fails.
59
60
  */
60
61
  async function deployToCloudRun(
61
- context,
62
62
  projectId,
63
63
  location,
64
64
  serviceId,
@@ -66,15 +66,13 @@ async function deployToCloudRun(
66
66
  progressCallback,
67
67
  skipIamCheck,
68
68
  deploymentType,
69
+ accessToken,
69
70
  zippedSourceContainer,
70
71
  runtime
71
72
  ) {
72
- const parent = context.runClient.locationPath(projectId, location);
73
- const servicePath = context.runClient.servicePath(
74
- projectId,
75
- location,
76
- serviceId
77
- );
73
+ const runClient = await getRunClient(projectId, accessToken);
74
+ const parent = runClient.locationPath(projectId, location);
75
+ const servicePath = runClient.servicePath(projectId, location, serviceId);
78
76
  const revisionName = `${serviceId}-${Date.now()}`; // Generate a unique revision name
79
77
 
80
78
  const containers =
@@ -101,10 +99,10 @@ async function deployToCloudRun(
101
99
 
102
100
  try {
103
101
  const exists = await checkCloudRunServiceExists(
104
- context,
105
102
  projectId,
106
103
  location,
107
104
  serviceId,
105
+ accessToken,
108
106
  progressCallback
109
107
  );
110
108
 
@@ -121,7 +119,7 @@ async function deployToCloudRun(
121
119
  dryRunServiceConfig.name = servicePath;
122
120
  await callWithRetry(
123
121
  () =>
124
- context.runClient.updateService({
122
+ runClient.updateService({
125
123
  service: dryRunServiceConfig,
126
124
  validateOnly: true,
127
125
  }),
@@ -130,7 +128,7 @@ async function deployToCloudRun(
130
128
  } else {
131
129
  await callWithRetry(
132
130
  () =>
133
- context.runClient.createService({
131
+ runClient.createService({
134
132
  parent: parent,
135
133
  service: dryRunServiceConfig,
136
134
  serviceId: serviceId,
@@ -181,7 +179,7 @@ async function deployToCloudRun(
181
179
  );
182
180
  service.name = servicePath;
183
181
  [operation] = await callWithRetry(
184
- () => context.runClient.updateService({ service }),
182
+ () => runClient.updateService({ service }),
185
183
  `updateService ${serviceId}`
186
184
  );
187
185
  } else {
@@ -191,7 +189,7 @@ async function deployToCloudRun(
191
189
  );
192
190
  [operation] = await callWithRetry(
193
191
  () =>
194
- context.runClient.createService({
192
+ runClient.createService({
195
193
  parent: parent,
196
194
  service: service, // 'service' object might have invokerIamDisabled removed
197
195
  serviceId: serviceId,
@@ -242,7 +240,6 @@ async function deployToCloudRun(
242
240
  * @throws {Error} If any part of the deployment process fails.
243
241
  */
244
242
  async function deployWithZip(
245
- context,
246
243
  projectId,
247
244
  region,
248
245
  serviceName,
@@ -250,7 +247,8 @@ async function deployWithZip(
250
247
  bucketName,
251
248
  deploymentAttrs,
252
249
  progressCallback,
253
- skipIamCheck
250
+ skipIamCheck,
251
+ accessToken
254
252
  ) {
255
253
  await logAndProgress(
256
254
  `Attempting direct source deployment...`,
@@ -278,14 +276,14 @@ async function deployWithZip(
278
276
  }
279
277
 
280
278
  const bucket = await ensureStorageBucketExists(
281
- context,
279
+ projectId,
282
280
  bucketName,
283
281
  region,
282
+ accessToken,
284
283
  progressCallback
285
284
  );
286
285
 
287
286
  await uploadToStorageBucket(
288
- context,
289
287
  bucket,
290
288
  archiveBuffer,
291
289
  archiveName,
@@ -300,7 +298,6 @@ async function deployWithZip(
300
298
  });
301
299
 
302
300
  const service = await deployToCloudRun(
303
- context,
304
301
  projectId,
305
302
  region,
306
303
  serviceName,
@@ -308,6 +305,7 @@ async function deployWithZip(
308
305
  progressCallback,
309
306
  skipIamCheck,
310
307
  DEPLOYMENT_TYPES.NO_BUILD,
308
+ accessToken,
311
309
  container,
312
310
  deploymentAttrs.runtime
313
311
  );
@@ -337,7 +335,6 @@ async function deployWithZip(
337
335
  * @throws {Error} If any part of the build or deployment process fails.
338
336
  */
339
337
  async function deployWithBuild(
340
- context,
341
338
  projectId,
342
339
  region,
343
340
  serviceName,
@@ -345,14 +342,16 @@ async function deployWithBuild(
345
342
  hasDockerfile,
346
343
  bucketName,
347
344
  progressCallback,
348
- skipIamCheck
345
+ skipIamCheck,
346
+ accessToken
349
347
  ) {
350
348
  const imageUrl = `${region}-docker.pkg.dev/${projectId}/${DEPLOYMENT_CONFIG.REPO_NAME}/${serviceName}:${DEPLOYMENT_CONFIG.IMAGE_TAG}`;
351
349
 
352
350
  const bucket = await ensureStorageBucketExists(
353
- context,
351
+ projectId,
354
352
  bucketName,
355
353
  region,
354
+ accessToken,
356
355
  progressCallback
357
356
  );
358
357
 
@@ -360,7 +359,6 @@ async function deployWithBuild(
360
359
  const archiveName = DEPLOYMENT_CONFIG.ZIP_FILE_NAME;
361
360
 
362
361
  await uploadToStorageBucket(
363
- context,
364
362
  bucket,
365
363
  archiveBuffer,
366
364
  archiveName,
@@ -369,8 +367,8 @@ async function deployWithBuild(
369
367
  await logAndProgress('Source code uploaded successfully', progressCallback);
370
368
 
371
369
  await ensureArtifactRegistryRepoExists(
372
- context,
373
370
  projectId,
371
+ accessToken,
374
372
  region,
375
373
  DEPLOYMENT_CONFIG.REPO_NAME,
376
374
  'DOCKER',
@@ -380,7 +378,6 @@ async function deployWithBuild(
380
378
  await logAndProgress(`Dockerfile: ${hasDockerfile}`, progressCallback);
381
379
 
382
380
  const buildResult = await triggerCloudBuild(
383
- context,
384
381
  projectId,
385
382
  region,
386
383
  bucketName,
@@ -388,20 +385,21 @@ async function deployWithBuild(
388
385
  DEPLOYMENT_CONFIG.REPO_NAME,
389
386
  imageUrl,
390
387
  hasDockerfile,
388
+ accessToken,
391
389
  progressCallback
392
390
  );
393
391
 
394
392
  const builtImageUrl = buildResult.results.images[0].name;
395
393
 
396
394
  const service = await deployToCloudRun(
397
- context,
398
395
  projectId,
399
396
  region,
400
397
  serviceName,
401
398
  builtImageUrl,
402
399
  progressCallback,
403
400
  skipIamCheck,
404
- DEPLOYMENT_TYPES.WITH_BUILD
401
+ DEPLOYMENT_TYPES.WITH_BUILD,
402
+ accessToken
405
403
  );
406
404
 
407
405
  await logAndProgress(`Deployment completed successfully`, progressCallback);
@@ -426,6 +424,7 @@ export async function deploy({
426
424
  files,
427
425
  progressCallback,
428
426
  skipIamCheck,
427
+ accessToken,
429
428
  }) {
430
429
  if (!projectId) {
431
430
  const errorMsg =
@@ -452,30 +451,11 @@ export async function deploy({
452
451
  }
453
452
  }
454
453
 
455
- const { Storage } = await import('@google-cloud/storage');
456
- const { CloudBuildClient } = await import('@google-cloud/cloudbuild');
457
- const { ArtifactRegistryClient } = await import(
458
- '@google-cloud/artifact-registry'
459
- );
460
- const { v2: CloudRunV2Module } = await import('@google-cloud/run');
461
- const { ServicesClient } = CloudRunV2Module;
462
- const { ServiceUsageClient } = await import('@google-cloud/service-usage');
463
- const { Logging } = await import('@google-cloud/logging');
464
-
465
454
  try {
466
- const context = {
467
- storage: new Storage({ projectId }),
468
- cloudBuildClient: new CloudBuildClient({ projectId }),
469
- artifactRegistryClient: new ArtifactRegistryClient({ projectId }),
470
- runClient: new ServicesClient({ projectId }),
471
- serviceUsageClient: new ServiceUsageClient({ projectId }),
472
- loggingClient: new Logging({ projectId }),
473
- };
474
-
475
455
  await ensureApisEnabled(
476
- context,
477
456
  projectId,
478
457
  REQUIRED_APIS.SOURCE_DEPLOY,
458
+ accessToken,
479
459
  progressCallback
480
460
  );
481
461
 
@@ -495,7 +475,6 @@ export async function deploy({
495
475
  if (canDeployWithoutBuild({ hasDockerfile, deploymentAttrs })) {
496
476
  try {
497
477
  return await deployWithZip(
498
- context,
499
478
  projectId,
500
479
  region,
501
480
  serviceName,
@@ -503,7 +482,8 @@ export async function deploy({
503
482
  bucketName,
504
483
  deploymentAttrs,
505
484
  progressCallback,
506
- skipIamCheck
485
+ skipIamCheck,
486
+ accessToken
507
487
  );
508
488
  } catch (error) {
509
489
  await logAndProgress(
@@ -515,7 +495,6 @@ export async function deploy({
515
495
  }
516
496
 
517
497
  return await deployWithBuild(
518
- context,
519
498
  projectId,
520
499
  region,
521
500
  serviceName,
@@ -523,7 +502,8 @@ export async function deploy({
523
502
  hasDockerfile,
524
503
  bucketName,
525
504
  progressCallback,
526
- skipIamCheck
505
+ skipIamCheck,
506
+ accessToken
527
507
  );
528
508
  } catch (error) {
529
509
  const deployFailedMessage = `Deployment Failed: ${error.message}`;
@@ -552,6 +532,7 @@ export async function deployImage({
552
532
  imageUrl,
553
533
  progressCallback,
554
534
  skipIamCheck,
535
+ accessToken,
555
536
  }) {
556
537
  if (!projectId) {
557
538
  const errorMsg =
@@ -577,20 +558,11 @@ export async function deployImage({
577
558
  }
578
559
  }
579
560
 
580
- const { v2: CloudRunV2Module } = await import('@google-cloud/run');
581
- const { ServicesClient } = CloudRunV2Module;
582
- const { ServiceUsageClient } = await import('@google-cloud/service-usage');
583
-
584
561
  try {
585
- const context = {
586
- runClient: new ServicesClient({ projectId }),
587
- serviceUsageClient: new ServiceUsageClient({ projectId }),
588
- };
589
-
590
562
  await ensureApisEnabled(
591
- context,
592
563
  projectId,
593
564
  REQUIRED_APIS.IMAGE_DEPLOY,
565
+ accessToken,
594
566
  progressCallback
595
567
  );
596
568
 
@@ -600,14 +572,14 @@ export async function deployImage({
600
572
  await logAndProgress(`Image URL: ${imageUrl}`, progressCallback);
601
573
 
602
574
  const service = await deployToCloudRun(
603
- context,
604
575
  projectId,
605
576
  region,
606
577
  serviceName,
607
578
  imageUrl,
608
579
  progressCallback,
609
580
  skipIamCheck,
610
- DEPLOYMENT_TYPES.IMAGE
581
+ DEPLOYMENT_TYPES.IMAGE,
582
+ accessToken
611
583
  );
612
584
 
613
585
  await logAndProgress(`Deployment completed successfully`, progressCallback);
@@ -170,7 +170,7 @@ export function canDeployWithoutBuild({ hasDockerfile, deploymentAttrs }) {
170
170
  * Creates the container spec for a direct source deployment (no Cloud Build).
171
171
  * @param {object} params - The parameters for creating the container spec.
172
172
  * @param {string} params.bucketName - The GCS bucket name where the source is uploaded.
173
- * @param {string} params.fileName - The GCS object name (e.g., source.tar.gz).
173
+ * @param {string} params.fileName - The GCS object name (eg., source.tar.gz).
174
174
  * @param {{cmd: string[], args: string[], baseImage: string}} params.deploymentAttrs - Deployment attributes including command, args, and the base image to use.
175
175
  * @returns {object} The container specification object for the Cloud Run service.
176
176
  */
@@ -0,0 +1,92 @@
1
+ import { getOAuthClient } from '../clients.js';
2
+ import { extractAccessToken } from '../util/helpers.js';
3
+
4
+ const TOOLS_CALL_METHOD = 'tools/call';
5
+
6
+ /**
7
+ * Verifies the validity of an access token and checks the audience.
8
+ * @param {string} accessToken - The access token to verify.
9
+ * @param {string} [audience] - Audience to check against the token's audience.
10
+ * @returns {Promise<object>} - The token info if valid.
11
+ * @throws {Error} - If the token is invalid or the audience does not match.
12
+ */
13
+ async function verifyAccessToken(accessToken, audience) {
14
+ try {
15
+ const client = await getOAuthClient(accessToken);
16
+ const tokenInfo = await client.getTokenInfo(accessToken);
17
+
18
+ //An expired token will not have audience
19
+ if (audience && tokenInfo.aud !== audience) {
20
+ throw new Error(`Invalid audience: expected ${audience}`);
21
+ }
22
+
23
+ console.log('Access token verified successfully.');
24
+ return tokenInfo;
25
+ } catch (error) {
26
+ console.error('Error verifying access token:', error);
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Ensures that a valid OAuth token is present in the request headers
33
+ * and verifies it against the configured audience.
34
+ * @param {object} req - The request object.
35
+ * @param {object} res - The response object.
36
+ * @throws {Error} - If the token is missing or invalid.
37
+ */
38
+ async function ensureOAuthTokenInEnv(req, res) {
39
+ try {
40
+ const audience = process.env.GOOGLE_OAUTH_AUDIENCE;
41
+ console.log('Verifying token');
42
+ if (req.headers.authorization === undefined) {
43
+ console.log('No authorization header found in request');
44
+ throw new Error('No authorization header');
45
+ }
46
+ console.log('Authorization header found.. Verifying token');
47
+ await verifyAccessToken(
48
+ extractAccessToken(req.headers.authorization),
49
+ audience
50
+ );
51
+ console.log('Token verified');
52
+ } catch (error) {
53
+ console.error('Authentication failed: ', error);
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Middleware to check for OAuth token if OAuth is enabled.
60
+ * If OAUTH_ENABLED is 'true', it verifies the Authorization header.
61
+ *
62
+ * @param {import('express').Request} req
63
+ * @param {import('express').Response} res
64
+ * @param {import('express').NextFunction} next
65
+ */
66
+ export const oauthMiddleware = async (req, res, next) => {
67
+ //If OAUTH_ENABLED is not true or the request is not a tools/call, skip the middleware
68
+ if (
69
+ process.env.OAUTH_ENABLED !== 'true' ||
70
+ req.body.method !== TOOLS_CALL_METHOD
71
+ ) {
72
+ return next();
73
+ }
74
+
75
+ try {
76
+ await ensureOAuthTokenInEnv(req, res);
77
+ next();
78
+ } catch (error) {
79
+ console.error('OAuth Middleware Error:', error);
80
+ // ensureOAuthTokenInEnv throws an error if auth fails.
81
+ // We catch it and send a 401 response.
82
+ res.status(401).json({
83
+ jsonrpc: '2.0',
84
+ error: {
85
+ code: -32001, // Custom auth error code or standard -32000
86
+ message: 'Authentication failed',
87
+ data: error.message,
88
+ },
89
+ id: null,
90
+ });
91
+ }
92
+ };
@@ -40,3 +40,15 @@ export async function logAndProgress(
40
40
  progressCallback({ level: severity, data: message });
41
41
  }
42
42
  }
43
+
44
+ /**
45
+ * Extracts the access token from the Authorization header.
46
+ * @param {string} authorizationHeader - The Authorization header string.
47
+ * @returns {string | undefined} - The extracted access token or undefined if not found.
48
+ */
49
+ export function extractAccessToken(authorizationHeader) {
50
+ if (!authorizationHeader) {
51
+ return undefined;
52
+ }
53
+ return authorizationHeader.split(' ')[1];
54
+ }
package/mcp-server.js CHANGED
@@ -28,7 +28,18 @@ import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js';
28
28
  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
- import 'dotenv/config';
31
+ import { extractAccessToken } from './lib/util/helpers.js';
32
+ import { oauthMiddleware } from './lib/middleware/oauth.js';
33
+ import { config } from '@dotenvx/dotenvx';
34
+ import {
35
+ SCOPES,
36
+ GCLOUD_AUTH,
37
+ BEARER_METHODS_SUPPORTED,
38
+ RESPONSE_TYPES_SUPPORTED,
39
+ } from './constants.js';
40
+
41
+ //Suppress the warning related to missing .env file in case of non-OAuth mode
42
+ config({ quiet: true, ignore: ['MISSING_ENV_FILE'] });
32
43
 
33
44
  const gcpInfo = await checkGCP();
34
45
  let gcpCredentialsAvailable = false;
@@ -63,7 +74,7 @@ const allowedHosts = process.env.ALLOWED_HOSTS
63
74
  ? process.env.ALLOWED_HOSTS.split(',')
64
75
  : undefined;
65
76
 
66
- async function getServer() {
77
+ async function getServer(accessToken = GCLOUD_AUTH) {
67
78
  // Create an MCP server with implementation details
68
79
  const server = new McpServer(
69
80
  {
@@ -97,6 +108,7 @@ async function getServer() {
97
108
  defaultServiceName,
98
109
  skipIamCheck,
99
110
  gcpCredentialsAvailable,
111
+ accessToken,
100
112
  });
101
113
  } else {
102
114
  console.log(
@@ -109,6 +121,7 @@ async function getServer() {
109
121
  defaultServiceName,
110
122
  skipIamCheck,
111
123
  gcpCredentialsAvailable,
124
+ accessToken,
112
125
  });
113
126
  }
114
127
 
@@ -118,7 +131,28 @@ async function getServer() {
118
131
  return server;
119
132
  }
120
133
 
121
- // stdio
134
+ const getOAuthProtectedResource = (req, res) => {
135
+ res.json({
136
+ resource: process.env.OAUTH_PROTECTED_RESOURCE,
137
+ authorization_servers: [process.env.OAUTH_AUTHORIZATION_SERVER],
138
+ scopes_supported: [SCOPES.OPENID, SCOPES.EMAIL, SCOPES.CLOUD_PLATFORM],
139
+ bearer_methods_supported: [...BEARER_METHODS_SUPPORTED],
140
+ });
141
+ res.status(200).send();
142
+ };
143
+
144
+ const getOAuthAuthorizationServer = (req, res) => {
145
+ res.json({
146
+ issuer: process.env.OAUTH_PROTECTED_RESOURCE,
147
+ authorization_endpoint: process.env.OAUTH_AUTHORIZATION_ENDPOINT,
148
+ token_endpoint: process.env.OAUTH_TOKEN_ENDPOINT,
149
+ scopes_supported: [SCOPES.OPENID, SCOPES.EMAIL, SCOPES.CLOUD_PLATFORM],
150
+ response_types_supported: [...RESPONSE_TYPES_SUPPORTED],
151
+ });
152
+ res.status(200).send();
153
+ };
154
+
155
+ // stdio mode
122
156
  if (shouldStartStdio()) {
123
157
  gcpCredentialsAvailable = await ensureGCPCredentials();
124
158
  const stdioTransport = new StdioServerTransport();
@@ -128,7 +162,8 @@ if (shouldStartStdio()) {
128
162
  } else {
129
163
  // non-stdio mode
130
164
  console.log('Stdio transport mode is turned off.');
131
- gcpCredentialsAvailable = await ensureGCPCredentials();
165
+ gcpCredentialsAvailable =
166
+ process.env.OAUTH_ENABLED === 'true' || (await ensureGCPCredentials());
132
167
 
133
168
  const app = enableHostValidation
134
169
  ? createMcpExpressApp({ allowedHosts })
@@ -141,9 +176,17 @@ if (shouldStartStdio()) {
141
176
  );
142
177
  }
143
178
 
144
- app.post('/mcp', async (req, res) => {
179
+ app.get('/.well-known/oauth-protected-resource', getOAuthProtectedResource);
180
+
181
+ app.get(
182
+ '/.well-known/oauth-authorization-server',
183
+ getOAuthAuthorizationServer
184
+ );
185
+
186
+ app.post('/mcp', oauthMiddleware, async (req, res) => {
145
187
  console.log('/mcp Received:', req.body);
146
- const server = await getServer();
188
+ const accessToken = extractAccessToken(req.headers.authorization);
189
+ const server = await getServer(accessToken);
147
190
  try {
148
191
  const transport = new StreamableHTTPServerTransport({
149
192
  sessionIdGenerator: undefined,
@@ -204,7 +247,8 @@ if (shouldStartStdio()) {
204
247
  // Legacy SSE endpoint for older clients
205
248
  app.get('/sse', async (req, res) => {
206
249
  console.log('/sse Received:', req.body);
207
- const server = await getServer();
250
+ const accessToken = extractAccessToken(req.headers.authorization);
251
+ const server = await getServer(accessToken);
208
252
  // Create SSE transport for legacy clients
209
253
  const transport = new SSEServerTransport('/messages', res);
210
254
  sseTransports[transport.sessionId] = transport;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google-cloud/cloud-run-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "Cloud Run MCP deployment tool",
6
6
  "main": "mcp-server.js",
@@ -50,7 +50,7 @@
50
50
  "@google-cloud/storage": "^7.16.0",
51
51
  "@modelcontextprotocol/sdk": "^1.24.3",
52
52
  "archiver": "^7.0.1",
53
- "dotenv": "^17.2.1",
53
+ "@dotenvx/dotenvx": "^1.52.0",
54
54
  "express": "^5.1.0",
55
55
  "google-proto-files": "^5.0.0",
56
56
  "zod": "^3.25.76"
@@ -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
  }