@google-cloud/cloud-run-mcp 1.11.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.
@@ -4,19 +4,23 @@ on:
4
4
  release:
5
5
  types: [published]
6
6
 
7
+ permissions:
8
+ contents: write
9
+
7
10
  jobs:
8
11
  publish-npm:
9
12
  runs-on: ubuntu-latest
10
13
  steps:
11
- - uses: actions/checkout@v4
12
- - uses: actions/setup-node@v4
14
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
15
+ with:
16
+ persist-credentials: false
17
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
13
18
  with:
14
- node-version: 20
19
+ node-version: 24
15
20
  - run: npm ci
16
- # Now configure with the publish service for install.
17
- - uses: actions/setup-node@v4
21
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
18
22
  with:
19
- node-version: 20
23
+ node-version: 24
20
24
  registry-url: https://wombat-dressing-room.appspot.com/
21
25
  - run: npm publish
22
26
  env:
package/README.md CHANGED
@@ -60,6 +60,8 @@ The Cloud Run MCP server can be configured using the following environment varia
60
60
  | `SKIP_IAM_CHECK` | Controls whether to check for IAM permissions for a Cloud Run service. Set to `false` to enable checks. This is `true` by default which is a recommended way to make the service public. |
61
61
  | `ENABLE_HOST_VALIDATION` | Prevents [DNS Rebinding](https://en.wikipedia.org/wiki/DNS_rebinding) attacks by validating the Host header. This is disabled by default. |
62
62
  | `ALLOWED_HOSTS` | Comma-separated list of allowed Host headers (if host validation is enabled). The default value is `localhost,127.0.0.1,::1`. |
63
+ | `OAUTH_ENABLED` | Enables OAuth 2.0 Bearer token verification for incoming HTTP/SSE requests when set to `true`. Disabled by default. |
64
+ | `GOOGLE_OAUTH_AUDIENCE` | Expected OAuth 2.0 token audience (typically your OAuth Client ID). Required when `OAUTH_ENABLED=true`. |
63
65
 
64
66
  ## Use as a Gemini CLI extension
65
67
 
package/lib/clients.js CHANGED
@@ -24,9 +24,24 @@ const AUTHORIZATION_HEADER = 'Authorization';
24
24
  const BEARER_PREFIX = 'Bearer';
25
25
  const CLOUD_PLATFORM_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
26
26
 
27
+ export function getDefaultAccessToken() {
28
+ return process.env.OAUTH_ENABLED === 'true' ? undefined : GCLOUD_AUTH;
29
+ }
30
+
31
+ export function validateAccessToken(accessToken) {
32
+ if (
33
+ process.env.OAUTH_ENABLED === 'true' &&
34
+ (!accessToken || accessToken === GCLOUD_AUTH)
35
+ ) {
36
+ throw new Error('Access token is required when OAuth is enabled.');
37
+ }
38
+ }
39
+
27
40
  function keyGenerator(projectId, accessToken, region = '') {
28
41
  const baseKey =
29
- accessToken !== GCLOUD_AUTH ? projectId + accessToken : projectId;
42
+ accessToken && accessToken !== GCLOUD_AUTH
43
+ ? projectId + accessToken
44
+ : projectId;
30
45
  return baseKey + (region || '');
31
46
  }
32
47
 
@@ -103,6 +118,7 @@ export async function getClient(
103
118
  options = {},
104
119
  accessToken = null
105
120
  ) {
121
+ validateAccessToken(accessToken);
106
122
  if (!clients[service].has(key)) {
107
123
  const ClientClass = await loadClient();
108
124
  const finalOptions = { ...options };
@@ -126,7 +142,10 @@ export async function getClient(
126
142
  * @param {string} projectId - The Google Cloud project ID.
127
143
  * @returns {Promise<import('@google-cloud/run').v2.ServicesClient>}
128
144
  */
129
- export async function getRunClient(projectId, accessToken = GCLOUD_AUTH) {
145
+ export async function getRunClient(
146
+ projectId,
147
+ accessToken = getDefaultAccessToken()
148
+ ) {
130
149
  const key = keyGenerator(projectId, accessToken);
131
150
  return getClient(
132
151
  'run',
@@ -142,7 +161,10 @@ export async function getRunClient(projectId, accessToken = GCLOUD_AUTH) {
142
161
  * @param {string} projectId - The Google Cloud project ID.
143
162
  * @returns {Promise<import('@google-cloud/run').v2.BuildsClient>}
144
163
  */
145
- export async function getBuildsClient(projectId, accessToken = GCLOUD_AUTH) {
164
+ export async function getBuildsClient(
165
+ projectId,
166
+ accessToken = getDefaultAccessToken()
167
+ ) {
146
168
  const key = keyGenerator(projectId, accessToken);
147
169
  return getClient(
148
170
  'builds',
@@ -160,7 +182,7 @@ export async function getBuildsClient(projectId, accessToken = GCLOUD_AUTH) {
160
182
  */
161
183
  export async function getServiceUsageClient(
162
184
  projectId,
163
- accessToken = GCLOUD_AUTH
185
+ accessToken = getDefaultAccessToken()
164
186
  ) {
165
187
  const key = keyGenerator(projectId, accessToken);
166
188
  return getClient(
@@ -178,7 +200,10 @@ export async function getServiceUsageClient(
178
200
  * @param {string} projectId - The Google Cloud project ID.
179
201
  * @returns {Promise<import('@google-cloud/storage').Storage>}
180
202
  */
181
- export async function getStorageClient(projectId, accessToken = GCLOUD_AUTH) {
203
+ export async function getStorageClient(
204
+ projectId,
205
+ accessToken = getDefaultAccessToken()
206
+ ) {
182
207
  const key = keyGenerator(projectId, accessToken);
183
208
  return getClient(
184
209
  'storage',
@@ -196,7 +221,7 @@ export async function getStorageClient(projectId, accessToken = GCLOUD_AUTH) {
196
221
  */
197
222
  export async function getCloudBuildClient(
198
223
  projectId,
199
- accessToken = GCLOUD_AUTH
224
+ accessToken = getDefaultAccessToken()
200
225
  ) {
201
226
  const key = keyGenerator(projectId, accessToken);
202
227
  return getClient(
@@ -215,7 +240,7 @@ export async function getCloudBuildClient(
215
240
  */
216
241
  export async function getArtifactRegistryClient(
217
242
  projectId,
218
- accessToken = GCLOUD_AUTH
243
+ accessToken = getDefaultAccessToken()
219
244
  ) {
220
245
  const key = keyGenerator(projectId, accessToken);
221
246
  return getClient(
@@ -233,7 +258,10 @@ export async function getArtifactRegistryClient(
233
258
  * @param {string} projectId - The Google Cloud project ID.
234
259
  * @returns {Promise<import('@google-cloud/logging').Logging>}
235
260
  */
236
- export async function getLoggingClient(projectId, accessToken = GCLOUD_AUTH) {
261
+ export async function getLoggingClient(
262
+ projectId,
263
+ accessToken = getDefaultAccessToken()
264
+ ) {
237
265
  const key = keyGenerator(projectId, accessToken);
238
266
  return getClient(
239
267
  'logging',
@@ -253,7 +281,7 @@ export async function getLoggingClient(projectId, accessToken = GCLOUD_AUTH) {
253
281
  */
254
282
  export async function getBillingClient(
255
283
  projectId = 'global',
256
- accessToken = GCLOUD_AUTH
284
+ accessToken = getDefaultAccessToken()
257
285
  ) {
258
286
  const key = keyGenerator(projectId, accessToken);
259
287
  return getClient(
@@ -269,7 +297,7 @@ export async function getBillingClient(
269
297
  * Gets a Projects Client (Resource Manager).
270
298
  * @returns {Promise<import('@google-cloud/resource-manager').ProjectsClient>}
271
299
  */
272
- export async function getProjectsClient(accessToken = GCLOUD_AUTH) {
300
+ export async function getProjectsClient(accessToken = getDefaultAccessToken()) {
273
301
  const key = keyGenerator('global', accessToken);
274
302
  return getClient(
275
303
  'projects',
@@ -287,7 +315,7 @@ export async function getProjectsClient(accessToken = GCLOUD_AUTH) {
287
315
  */
288
316
  export async function getSecretManagerClient(
289
317
  projectId,
290
- accessToken = GCLOUD_AUTH
318
+ accessToken = getDefaultAccessToken()
291
319
  ) {
292
320
  const key = keyGenerator(projectId, accessToken);
293
321
  return getClient(
@@ -325,9 +353,10 @@ export async function getOAuthClient(accessToken) {
325
353
  */
326
354
  export async function getRunV1Client(
327
355
  projectId,
328
- accessToken = GCLOUD_AUTH,
356
+ accessToken = getDefaultAccessToken(),
329
357
  region = null
330
358
  ) {
359
+ validateAccessToken(accessToken);
331
360
  const key = keyGenerator(projectId, accessToken, region);
332
361
  if (!clients.runV1.has(key)) {
333
362
  const { google } = await import('googleapis');
@@ -1,8 +1,6 @@
1
- import { getOAuthClient } from '../clients.js';
1
+ import { clients, getOAuthClient } from '../clients.js';
2
2
  import { extractAccessToken } from '../util/helpers.js';
3
3
 
4
- const TOOLS_CALL_METHOD = 'tools/call';
5
-
6
4
  /**
7
5
  * Verifies the validity of an access token and checks the audience.
8
6
  * @param {string} accessToken - The access token to verify.
@@ -16,13 +14,14 @@ async function verifyAccessToken(accessToken, audience) {
16
14
  const tokenInfo = await client.getTokenInfo(accessToken);
17
15
 
18
16
  //An expired token will not have audience
19
- if (audience && tokenInfo.aud !== audience) {
20
- throw new Error(`Invalid audience: expected ${audience}`);
17
+ if (!audience || tokenInfo.aud !== audience) {
18
+ throw new Error('Invalid token audience');
21
19
  }
22
20
 
23
21
  console.log('Access token verified successfully.');
24
22
  return tokenInfo;
25
23
  } catch (error) {
24
+ clients.oauth.delete(accessToken);
26
25
  console.error('Error verifying access token:', error);
27
26
  throw error;
28
27
  }
@@ -43,11 +42,19 @@ async function ensureOAuthTokenInEnv(req, res) {
43
42
  console.log('No authorization header found in request');
44
43
  throw new Error('No authorization header');
45
44
  }
45
+ const accessToken = extractAccessToken(req.headers.authorization);
46
+ if (!accessToken) {
47
+ console.log('No access token found in authorization header');
48
+ throw new Error('No access token found in authorization header');
49
+ }
50
+ if (!audience) {
51
+ console.log('GOOGLE_OAUTH_AUDIENCE environment variable is missing');
52
+ throw new Error(
53
+ 'GOOGLE_OAUTH_AUDIENCE environment variable is required when OAuth is enabled.'
54
+ );
55
+ }
46
56
  console.log('Authorization header found.. Verifying token');
47
- await verifyAccessToken(
48
- extractAccessToken(req.headers.authorization),
49
- audience
50
- );
57
+ await verifyAccessToken(accessToken, audience);
51
58
  console.log('Token verified');
52
59
  } catch (error) {
53
60
  console.error('Authentication failed: ', error);
@@ -64,11 +71,8 @@ async function ensureOAuthTokenInEnv(req, res) {
64
71
  * @param {import('express').NextFunction} next
65
72
  */
66
73
  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
- ) {
74
+ //If OAUTH_ENABLED is not true, skip the middleware
75
+ if (process.env.OAUTH_ENABLED !== 'true') {
72
76
  return next();
73
77
  }
74
78
 
@@ -79,12 +83,19 @@ export const oauthMiddleware = async (req, res, next) => {
79
83
  console.error('OAuth Middleware Error:', error);
80
84
  // ensureOAuthTokenInEnv throws an error if auth fails.
81
85
  // We catch it and send a 401 response.
86
+ if (process.env.OAUTH_PROTECTED_RESOURCE) {
87
+ const metadataUrl = new URL(
88
+ '/.well-known/oauth-protected-resource',
89
+ process.env.OAUTH_PROTECTED_RESOURCE
90
+ ).href;
91
+ res.set('WWW-Authenticate', `Bearer resource_metadata="${metadataUrl}"`);
92
+ }
82
93
  res.status(401).json({
83
94
  jsonrpc: '2.0',
84
95
  error: {
85
96
  code: -32001, // Custom auth error code or standard -32000
86
97
  message: 'Authentication failed',
87
- data: error.message,
98
+ data: 'Invalid or missing access token',
88
99
  },
89
100
  id: null,
90
101
  });
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;
@@ -76,7 +90,8 @@ const allowedHosts = process.env.ALLOWED_HOSTS
76
90
  ? process.env.ALLOWED_HOSTS.split(',')
77
91
  : undefined;
78
92
 
79
- async function getServer(accessToken = GCLOUD_AUTH) {
93
+ async function getServer(accessToken = getDefaultAccessToken()) {
94
+ validateAccessToken(accessToken);
80
95
  // Create an MCP server with implementation details
81
96
  const server = new McpServer(
82
97
  {
@@ -189,9 +204,9 @@ if (shouldStartStdio()) {
189
204
 
190
205
  app.post('/mcp', oauthMiddleware, async (req, res) => {
191
206
  console.log('/mcp Received:', req.body);
192
- const accessToken = extractAccessToken(req.headers.authorization);
193
- const server = await getServer(accessToken);
194
207
  try {
208
+ const accessToken = extractAccessToken(req.headers.authorization);
209
+ const server = await getServer(accessToken);
195
210
  const transport = new StreamableHTTPServerTransport({
196
211
  sessionIdGenerator: undefined,
197
212
  });
@@ -249,28 +264,72 @@ if (shouldStartStdio()) {
249
264
  const sseTransports = {};
250
265
 
251
266
  // Legacy SSE endpoint for older clients
252
- app.get('/sse', async (req, res) => {
267
+ app.get('/sse', oauthMiddleware, async (req, res) => {
253
268
  console.log('/sse Received:', req.body);
254
- const accessToken = extractAccessToken(req.headers.authorization);
255
- const server = await getServer(accessToken);
256
- // Create SSE transport for legacy clients
257
- const transport = new SSEServerTransport('/messages', res);
258
- sseTransports[transport.sessionId] = transport;
259
-
260
- res.on('close', () => {
261
- delete sseTransports[transport.sessionId];
262
- });
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
+ });
263
279
 
264
- 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
+ }
265
294
  });
266
295
 
267
296
  // Legacy message endpoint for older clients
268
- app.post('/messages', async (req, res) => {
297
+ app.post('/messages', oauthMiddleware, async (req, res) => {
269
298
  console.log('/messages Received:', req.body);
270
299
  const sessionId = req.query.sessionId;
271
- const transport = sseTransports[sessionId];
272
- if (transport) {
273
- 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
+ }
274
333
  } else {
275
334
  res.status(400).send('No transport found for sessionId');
276
335
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@google-cloud/cloud-run-mcp",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "description": "Cloud Run MCP deployment tool",
6
6
  "main": "mcp-server.js",
@@ -56,7 +56,7 @@
56
56
  "google-proto-files": "^5.0.0",
57
57
  "googleapis": "^171.4.0",
58
58
  "js-yaml": "^4.1.1",
59
- "zod": "^3.25.76"
59
+ "zod": "^4.6.7"
60
60
  },
61
61
  "devDependencies": {
62
62
  "c8": "^10.1.3",
@@ -4,6 +4,7 @@ import { google } from 'googleapis';
4
4
  import {
5
5
  clients,
6
6
  getClient,
7
+ getRunClient,
7
8
  getRunV1Client,
8
9
  getCloudRunRegions,
9
10
  resetCachedRegions,
@@ -181,6 +182,62 @@ describe('getClient Helper', () => {
181
182
 
182
183
  assert.strictEqual(client.options.extraOpt, extraOpt);
183
184
  });
185
+
186
+ test('throws error when OAUTH_ENABLED=true and accessToken is missing or GCLOUD_AUTH', async () => {
187
+ const originalOAuth = process.env.OAUTH_ENABLED;
188
+ process.env.OAUTH_ENABLED = 'true';
189
+ try {
190
+ const projectId = 'test-project-oauth-err';
191
+ const service = 'run';
192
+
193
+ await assert.rejects(
194
+ async () =>
195
+ getClient(
196
+ service,
197
+ projectId,
198
+ async () => MockClient,
199
+ { projectId },
200
+ null
201
+ ),
202
+ /Access token is required when OAuth is enabled/
203
+ );
204
+
205
+ await assert.rejects(
206
+ async () =>
207
+ getClient(
208
+ service,
209
+ projectId,
210
+ async () => MockClient,
211
+ { projectId },
212
+ undefined
213
+ ),
214
+ /Access token is required when OAuth is enabled/
215
+ );
216
+
217
+ await assert.rejects(
218
+ async () =>
219
+ getClient(
220
+ service,
221
+ projectId,
222
+ async () => MockClient,
223
+ { projectId },
224
+ GCLOUD_AUTH
225
+ ),
226
+ /Access token is required when OAuth is enabled/
227
+ );
228
+
229
+ await assert.rejects(
230
+ async () => getRunClient(projectId),
231
+ /Access token is required when OAuth is enabled/
232
+ );
233
+ } finally {
234
+ if (originalOAuth === undefined) {
235
+ delete process.env.OAUTH_ENABLED;
236
+ } else {
237
+ process.env.OAUTH_ENABLED = originalOAuth;
238
+ }
239
+ }
240
+ });
184
241
  });
185
242
 
186
243
  describe('getRunV1Client', () => {
@@ -324,6 +381,32 @@ describe('getRunV1Client', () => {
324
381
  clients.run.clear();
325
382
  }
326
383
  });
384
+
385
+ test('throws error when OAUTH_ENABLED=true and accessToken is missing or GCLOUD_AUTH', async () => {
386
+ const originalOAuth = process.env.OAUTH_ENABLED;
387
+ process.env.OAUTH_ENABLED = 'true';
388
+ try {
389
+ const projectId = 'test-project-v1-oauth-err';
390
+ await assert.rejects(
391
+ async () => getRunV1Client(projectId),
392
+ /Access token is required when OAuth is enabled/
393
+ );
394
+ await assert.rejects(
395
+ async () => getRunV1Client(projectId, undefined),
396
+ /Access token is required when OAuth is enabled/
397
+ );
398
+ await assert.rejects(
399
+ async () => getRunV1Client(projectId, GCLOUD_AUTH),
400
+ /Access token is required when OAuth is enabled/
401
+ );
402
+ } finally {
403
+ if (originalOAuth === undefined) {
404
+ delete process.env.OAUTH_ENABLED;
405
+ } else {
406
+ process.env.OAUTH_ENABLED = originalOAuth;
407
+ }
408
+ }
409
+ });
327
410
  });
328
411
 
329
412
  describe('getCloudRunRegions', () => {
@@ -3,6 +3,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
3
3
  import { test, describe, before, after } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { spawn } from 'child_process';
6
+ import { pathToFileURL } from 'node:url';
6
7
  import { waitForString } from './test-utils.js';
7
8
  import {
8
9
  SCOPES,
@@ -76,18 +77,35 @@ describe('OAuth Endpoints', () => {
76
77
 
77
78
  before(async () => {
78
79
  stdout = '';
79
- serverProcess = spawn('node', ['mcp-server.js'], {
80
- cwd: process.cwd(),
81
- env: {
82
- ...process.env,
83
- GCP_STDIO: 'false',
84
- PORT: '3001',
85
- OAUTH_PROTECTED_RESOURCE: oauthProtectedResource,
86
- OAUTH_AUTHORIZATION_SERVER: oauthAuthorizationServer,
87
- OAUTH_AUTHORIZATION_ENDPOINT: oauthAuthorizationEndpoint,
88
- OAUTH_TOKEN_ENDPOINT: oauthTokenEndpoint,
89
- },
90
- });
80
+ const clientsUrl = pathToFileURL(`${process.cwd()}/lib/clients.js`).href;
81
+ // Note: verifyAccessToken evicts clients.oauth entries on any verification failure,
82
+ // so only use these pre-seeded tokens in test cases where token verification succeeds.
83
+ const preloadScript = `import { clients } from '${clientsUrl}';
84
+ const mockClient = { getTokenInfo: async () => ({ aud: 'test-audience' }) };
85
+ clients.oauth.set('token-user-a', mockClient);
86
+ clients.oauth.set('token-user-b', mockClient);`;
87
+ serverProcess = spawn(
88
+ 'node',
89
+ [
90
+ '--import',
91
+ `data:text/javascript,${encodeURIComponent(preloadScript)}`,
92
+ 'mcp-server.js',
93
+ ],
94
+ {
95
+ cwd: process.cwd(),
96
+ env: {
97
+ ...process.env,
98
+ GCP_STDIO: 'false',
99
+ PORT: '3001',
100
+ OAUTH_PROTECTED_RESOURCE: oauthProtectedResource,
101
+ OAUTH_AUTHORIZATION_SERVER: oauthAuthorizationServer,
102
+ OAUTH_AUTHORIZATION_ENDPOINT: oauthAuthorizationEndpoint,
103
+ OAUTH_TOKEN_ENDPOINT: oauthTokenEndpoint,
104
+ OAUTH_ENABLED: 'true',
105
+ GOOGLE_OAUTH_AUDIENCE: 'test-audience',
106
+ },
107
+ }
108
+ );
91
109
  stdout = await waitForString(serverProcess.stdout, httpMsg);
92
110
  });
93
111
 
@@ -127,4 +145,106 @@ describe('OAuth Endpoints', () => {
127
145
  response_types_supported: [...RESPONSE_TYPES_SUPPORTED],
128
146
  });
129
147
  });
148
+
149
+ test('should reject unauthenticated POST /mcp with JSON array body with 401', async () => {
150
+ const response = await fetch('http://localhost:3001/mcp', {
151
+ method: 'POST',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify([
154
+ { jsonrpc: '2.0', method: 'tools/call', params: {}, id: 1 },
155
+ ]),
156
+ });
157
+ assert.equal(response.status, 401);
158
+ });
159
+
160
+ test('should reject unauthenticated GET /sse with 401 and WWW-Authenticate header', async () => {
161
+ const response = await fetch('http://localhost:3001/sse');
162
+ assert.equal(response.status, 401);
163
+ assert.equal(
164
+ response.headers.get('www-authenticate'),
165
+ 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"'
166
+ );
167
+ });
168
+
169
+ test('should reject unauthenticated POST /messages with 401', async () => {
170
+ const response = await fetch(
171
+ 'http://localhost:3001/messages?sessionId=test',
172
+ {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({
176
+ jsonrpc: '2.0',
177
+ method: 'tools/call',
178
+ params: {},
179
+ id: 1,
180
+ }),
181
+ }
182
+ );
183
+ assert.equal(response.status, 401);
184
+ });
185
+
186
+ test('should reject POST /messages with 403 when accessToken does not match SSE session token', async () => {
187
+ const sseController = new AbortController();
188
+ const sseResponse = await fetch('http://localhost:3001/sse', {
189
+ headers: { Authorization: 'Bearer token-user-a' },
190
+ signal: sseController.signal,
191
+ });
192
+ assert.equal(sseResponse.status, 200);
193
+
194
+ const reader = sseResponse.body.getReader();
195
+ const { value } = await reader.read();
196
+ const sseText = new TextDecoder().decode(value);
197
+ const match = sseText.match(/sessionId=([^\s]+)/);
198
+ assert.ok(
199
+ match,
200
+ `Expected sessionId in SSE endpoint event, got: ${sseText}`
201
+ );
202
+ const sessionId = match[1];
203
+
204
+ try {
205
+ const msgResponse = await fetch(
206
+ `http://localhost:3001/messages?sessionId=${sessionId}`,
207
+ {
208
+ method: 'POST',
209
+ headers: {
210
+ 'Content-Type': 'application/json',
211
+ Authorization: 'Bearer token-user-b',
212
+ },
213
+ body: JSON.stringify({
214
+ jsonrpc: '2.0',
215
+ method: 'tools/list',
216
+ params: {},
217
+ id: 1,
218
+ }),
219
+ }
220
+ );
221
+ assert.equal(msgResponse.status, 403);
222
+ const body = await msgResponse.json();
223
+ assert.equal(
224
+ body.error.message,
225
+ 'Forbidden: session is bound to a different access token; reconnect to /sse'
226
+ );
227
+ } finally {
228
+ sseController.abort();
229
+ }
230
+ });
231
+
232
+ test('should exit at startup with non-zero code when OAUTH_ENABLED=true and GOOGLE_OAUTH_AUDIENCE is missing', async () => {
233
+ const envWithoutAudience = {
234
+ ...process.env,
235
+ GCP_STDIO: 'false',
236
+ PORT: '3002',
237
+ OAUTH_ENABLED: 'true',
238
+ };
239
+ delete envWithoutAudience.GOOGLE_OAUTH_AUDIENCE;
240
+
241
+ const exitCode = await new Promise((resolve) => {
242
+ const child = spawn('node', ['mcp-server.js'], {
243
+ cwd: process.cwd(),
244
+ env: envWithoutAudience,
245
+ });
246
+ child.on('exit', (code) => resolve(code));
247
+ });
248
+ assert.equal(exitCode, 1);
249
+ });
130
250
  });
@@ -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
  });
@@ -163,7 +163,11 @@ export async function setSourceDeployProjectPermissions(projectId) {
163
163
  const context = {
164
164
  serviceUsageClient: serviceUsageClient,
165
165
  };
166
- await ensureApisEnabled(projectId, ['run.googleapis.com']);
166
+ await ensureApisEnabled(projectId, [
167
+ 'run.googleapis.com',
168
+ 'cloudbuild.googleapis.com',
169
+ 'compute.googleapis.com',
170
+ ]);
167
171
  console.log('Adding editor role to Compute SA...');
168
172
  const projectNumber = await getProjectNumber(projectId);
169
173
  const member = `serviceAccount:${projectNumber}-compute@developer.gserviceaccount.com`;
@@ -49,6 +49,10 @@ function gcpTool(gcpCredentialsAvailable, fn) {
49
49
  return fn;
50
50
  }
51
51
 
52
+ function withDefault(schema, defaultValue) {
53
+ return defaultValue !== undefined ? schema.default(defaultValue) : schema;
54
+ }
55
+
52
56
  // Tool to list GCP projects
53
57
  function registerListProjectsTool(server, options) {
54
58
  server.registerTool(
@@ -147,10 +151,10 @@ function registerListServicesTool(server, options) {
147
151
  {
148
152
  description: 'Lists all Cloud Run services in a given project.',
149
153
  inputSchema: {
150
- project: z
151
- .string()
152
- .describe('Google Cloud project ID')
153
- .default(options.defaultProjectId),
154
+ project: withDefault(
155
+ z.string().describe('Google Cloud project ID'),
156
+ options.defaultProjectId
157
+ ),
154
158
  },
155
159
  },
156
160
  gcpTool(options.gcpCredentialsAvailable, async ({ project }) => {
@@ -202,18 +206,18 @@ function registerGetServiceTool(server, options) {
202
206
  {
203
207
  description: 'Gets details for a specific Cloud Run service.',
204
208
  inputSchema: {
205
- project: z
206
- .string()
207
- .describe('Google Cloud project ID containing the service')
208
- .default(options.defaultProjectId),
209
- region: z
210
- .string()
211
- .describe('Region where the service is located')
212
- .default(options.defaultRegion),
213
- service: z
214
- .string()
215
- .describe('Name of the Cloud Run service')
216
- .default(options.defaultServiceName),
209
+ project: withDefault(
210
+ z.string().describe('Google Cloud project ID containing the service'),
211
+ options.defaultProjectId
212
+ ),
213
+ region: withDefault(
214
+ z.string().describe('Region where the service is located'),
215
+ options.defaultRegion
216
+ ),
217
+ service: withDefault(
218
+ z.string().describe('Name of the Cloud Run service'),
219
+ options.defaultServiceName
220
+ ),
217
221
  },
218
222
  },
219
223
  gcpTool(
@@ -292,18 +296,18 @@ function registerGetServiceLogTool(server, options) {
292
296
  description:
293
297
  'Gets Logs and Error Messages for a specific Cloud Run service.',
294
298
  inputSchema: {
295
- project: z
296
- .string()
297
- .describe('Google Cloud project ID containing the service')
298
- .default(options.defaultProjectId),
299
- region: z
300
- .string()
301
- .describe('Region where the service is located')
302
- .default(options.defaultRegion),
303
- service: z
304
- .string()
305
- .describe('Name of the Cloud Run service')
306
- .default(options.defaultServiceName),
299
+ project: withDefault(
300
+ z.string().describe('Google Cloud project ID containing the service'),
301
+ options.defaultProjectId
302
+ ),
303
+ region: withDefault(
304
+ z.string().describe('Region where the service is located'),
305
+ options.defaultRegion
306
+ ),
307
+ service: withDefault(
308
+ z.string().describe('Name of the Cloud Run service'),
309
+ options.defaultServiceName
310
+ ),
307
311
  },
308
312
  },
309
313
  gcpTool(
@@ -361,24 +365,24 @@ function registerDeployLocalFolderTool(server, options) {
361
365
  description:
362
366
  'Deploy a local folder to Cloud Run. Takes an absolute folder path from the local filesystem that will be deployed. Use this tool if the entire folder content needs to be deployed.',
363
367
  inputSchema: {
364
- project: z
365
- .string()
366
- .describe(
367
- 'Google Cloud project ID. Do not select it yourself, make sure the user provides or confirms the project ID.'
368
- )
369
- .default(options.defaultProjectId),
370
- region: z
371
- .string()
372
- .optional()
373
- .default(options.defaultRegion)
374
- .describe('Region to deploy the service to'),
375
- service: z
376
- .string()
377
- .optional()
378
- .default(options.defaultServiceName)
379
- .describe(
380
- 'Name of the Cloud Run service to deploy to. If not provided, it will be inferred.'
381
- ),
368
+ project: withDefault(
369
+ z
370
+ .string()
371
+ .describe(
372
+ 'Google Cloud project ID. Do not select it yourself, make sure the user provides or confirms the project ID.'
373
+ ),
374
+ options.defaultProjectId
375
+ ),
376
+ region: withDefault(
377
+ z.string().optional(),
378
+ options.defaultRegion
379
+ ).describe('Region to deploy the service to'),
380
+ service: withDefault(
381
+ z.string().optional(),
382
+ options.defaultServiceName
383
+ ).describe(
384
+ 'Name of the Cloud Run service to deploy to. If not provided, it will be inferred.'
385
+ ),
382
386
  folderPath: z
383
387
  .string()
384
388
  .describe(
@@ -452,22 +456,22 @@ function registerDeployFileContentsTool(server, options) {
452
456
  description:
453
457
  'Deploy files to Cloud Run by providing their contents directly. Takes an array of file objects containing filename and content. Use this tool if the files only exist in the current chat context.',
454
458
  inputSchema: {
455
- project: z
456
- .string()
457
- .describe(
458
- 'Google Cloud project ID. Leave unset for the app to be deployed in a new project. If provided, make sure the user confirms the project ID they want to deploy to.'
459
- )
460
- .default(options.defaultProjectId),
461
- region: z
462
- .string()
463
- .optional()
464
- .default(options.defaultRegion)
465
- .describe('Region to deploy the service to'),
466
- service: z
467
- .string()
468
- .optional()
469
- .default(options.defaultServiceName)
470
- .describe('Name of the Cloud Run service to deploy to'),
459
+ project: withDefault(
460
+ z
461
+ .string()
462
+ .describe(
463
+ 'Google Cloud project ID. If provided, make sure the user confirms the project ID they want to deploy to.'
464
+ ),
465
+ options.defaultProjectId
466
+ ),
467
+ region: withDefault(
468
+ z.string().optional(),
469
+ options.defaultRegion
470
+ ).describe('Region to deploy the service to'),
471
+ service: withDefault(
472
+ z.string().optional(),
473
+ options.defaultServiceName
474
+ ).describe('Name of the Cloud Run service to deploy to'),
471
475
  files: z
472
476
  .array(
473
477
  z.object({
@@ -555,22 +559,22 @@ function registerDeployContainerImageTool(server, options) {
555
559
  description:
556
560
  'Deploys a container image to Cloud Run. Use this tool if the user provides a container image URL.',
557
561
  inputSchema: {
558
- project: z
559
- .string()
560
- .describe(
561
- 'Google Cloud project ID. Do not select it yourself, make sure the user provides or confirms the project ID.'
562
- )
563
- .default(options.defaultProjectId),
564
- region: z
565
- .string()
566
- .optional()
567
- .default(options.defaultRegion)
568
- .describe('Region to deploy the service to'),
569
- service: z
570
- .string()
571
- .optional()
572
- .default(options.defaultServiceName)
573
- .describe('Name of the Cloud Run service to deploy to'),
562
+ project: withDefault(
563
+ z
564
+ .string()
565
+ .describe(
566
+ 'Google Cloud project ID. Do not select it yourself, make sure the user provides or confirms the project ID.'
567
+ ),
568
+ options.defaultProjectId
569
+ ),
570
+ region: withDefault(
571
+ z.string().optional(),
572
+ options.defaultRegion
573
+ ).describe('Region to deploy the service to'),
574
+ service: withDefault(
575
+ z.string().optional(),
576
+ options.defaultServiceName
577
+ ).describe('Name of the Cloud Run service to deploy to'),
574
578
  imageUrl: z
575
579
  .string()
576
580
  .describe(