@google-cloud/cloud-run-mcp 1.8.0 → 1.10.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.
@@ -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.8.0",
3
+ "version": "1.10.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"
@@ -27,7 +27,9 @@ describe('getClient Helper', () => {
27
27
  assert.strictEqual(client.options.projectId, projectId);
28
28
  assert.ok(client.options.authClient);
29
29
 
30
- const headers = client.options.authClient.getRequestHeaders();
30
+ const headers = await client.options.authClient.getRequestHeaders();
31
+ // 'run' is a gRPC service, so it should be wrapped to return a Map
32
+ assert.ok(headers instanceof Map);
31
33
  assert.strictEqual(headers.get('Authorization'), 'Bearer fake-token-1');
32
34
  });
33
35
 
@@ -96,10 +98,12 @@ describe('getClient Helper', () => {
96
98
 
97
99
  assert.notStrictEqual(client1, client2);
98
100
 
99
- const h1 = client1.options.authClient.getRequestHeaders();
101
+ const h1 = await client1.options.authClient.getRequestHeaders();
102
+ assert.ok(h1 instanceof Map);
100
103
  assert.strictEqual(h1.get('Authorization'), 'Bearer token-A');
101
104
 
102
- const h2 = client2.options.authClient.getRequestHeaders();
105
+ const h2 = await client2.options.authClient.getRequestHeaders();
106
+ assert.ok(h2 instanceof Map);
103
107
  assert.strictEqual(h2.get('Authorization'), 'Bearer token-B');
104
108
  });
105
109
 
@@ -126,6 +130,32 @@ describe('getClient Helper', () => {
126
130
  assert.notStrictEqual(runClient, storageClient); // Different maps
127
131
  assert.ok(runClient.options.authClient);
128
132
  assert.ok(storageClient.options.authClient);
133
+
134
+ const runHeaders = await runClient.options.authClient.getRequestHeaders();
135
+ assert.ok(runHeaders instanceof Map, 'Run client headers should be a Map');
136
+
137
+ const storageHeaders =
138
+ await storageClient.options.authClient.getRequestHeaders();
139
+ assert.ok(
140
+ !(storageHeaders instanceof Map),
141
+ 'Storage client headers should NOT be a Map'
142
+ );
143
+ assert.strictEqual(storageHeaders.Authorization, `Bearer ${accessToken}`);
144
+
145
+ const loggingClient = await getClient(
146
+ 'logging',
147
+ projectId + accessToken,
148
+ async () => MockClient,
149
+ { projectId },
150
+ accessToken
151
+ );
152
+ const loggingHeaders =
153
+ await loggingClient.options.authClient.getRequestHeaders();
154
+ assert.ok(
155
+ !(loggingHeaders instanceof Map),
156
+ 'Logging client headers should NOT be a Map'
157
+ );
158
+ assert.strictEqual(loggingHeaders.Authorization, `Bearer ${accessToken}`);
129
159
  });
130
160
 
131
161
  test('passes additional options correctly', async () => {
@@ -50,7 +50,7 @@ describe('Deployment Helpers', () => {
50
50
  assert.equal(result.hasDockerfile, true);
51
51
  });
52
52
 
53
- test('makeFileDeploymentMetadata correctly identifies Node.js project attributes', async () => {
53
+ test('makeFileDeploymentMetadata correctly identifies Node.js project', async () => {
54
54
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
55
55
  fs: fsMock,
56
56
  });
@@ -69,75 +69,13 @@ describe('Deployment Helpers', () => {
69
69
  return false;
70
70
  });
71
71
 
72
- // Mock readFileSync for package.json
73
- fsMock.readFileSync.mock.mockImplementation(() =>
74
- JSON.stringify({
75
- scripts: { start: 'node server.js' },
76
- })
77
- );
78
-
79
72
  const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
80
73
 
81
74
  assert.equal(result.hasDockerfile, false);
82
75
  assert.equal(result.deploymentAttrs.runtime, 'nodejs');
83
- assert.deepEqual(result.deploymentAttrs.cmd, ['node']);
84
- assert.deepEqual(result.deploymentAttrs.args, ['server.js']);
85
- });
86
-
87
- test('makeFileDeploymentMetadata ignores Node.js if engines.node is present', async () => {
88
- const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
89
- fs: fsMock,
90
- });
91
-
92
- const folderPath = '/absolute/path/to/node-app-engines';
93
-
94
- fsMock.statSync.mock.mockImplementation(() => ({
95
- isDirectory: () => true,
96
- }));
97
-
98
- fsMock.existsSync.mock.mockImplementation((p) => {
99
- if (p.endsWith('package.json')) return true;
100
- return false;
101
- });
102
-
103
- fsMock.readFileSync.mock.mockImplementation(() =>
104
- JSON.stringify({
105
- scripts: { start: 'node server.js' },
106
- engines: { node: '>=14' },
107
- })
108
- );
109
-
110
- const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
111
-
112
- assert.equal(result.deploymentAttrs.runtime, undefined);
113
- });
114
-
115
- test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
116
- const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
117
- fs: fsMock,
118
- });
119
-
120
- const folderPath = '/absolute/path/to/bad-json-app';
121
-
122
- fsMock.statSync.mock.mockImplementation(() => ({
123
- isDirectory: () => true,
124
- }));
125
-
126
- fsMock.existsSync.mock.mockImplementation((p) => {
127
- if (p.endsWith('package.json')) return true;
128
- return false;
129
- });
130
-
131
- // Mock readFileSync to return invalid JSON
132
- fsMock.readFileSync.mock.mockImplementation(() => '{ invalid json }');
133
-
134
- const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
135
-
136
- // Should return empty deployment attrs
137
- assert.equal(result.deploymentAttrs.runtime, undefined);
138
76
  });
139
77
 
140
- test('canDeployWithoutBuild returns true for valid Node.js zip deploy', async () => {
78
+ test('canDeployWithoutBuild returns true for valid Node.js project', async () => {
141
79
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
142
80
  fs: fsMock,
143
81
  });
@@ -146,9 +84,6 @@ describe('Deployment Helpers', () => {
146
84
  hasDockerfile: false,
147
85
  deploymentAttrs: {
148
86
  runtime: 'nodejs',
149
- cmd: ['node'],
150
- args: ['index.js'],
151
- baseImage: 'base-image',
152
87
  },
153
88
  };
154
89
 
@@ -164,15 +99,13 @@ describe('Deployment Helpers', () => {
164
99
  hasDockerfile: true,
165
100
  deploymentAttrs: {
166
101
  runtime: 'nodejs',
167
- cmd: ['node'],
168
- args: ['index.js'],
169
102
  },
170
103
  };
171
104
 
172
105
  assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), false);
173
106
  });
174
107
 
175
- test('createDirectSourceDeploymentContainer creates correct object', async () => {
108
+ test('createDirectSourceDeploymentContainer creates correct object without envVars', async () => {
176
109
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
177
110
  fs: fsMock,
178
111
  });
@@ -204,28 +137,43 @@ describe('Deployment Helpers', () => {
204
137
  });
205
138
  });
206
139
 
207
- test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
140
+ test('createDirectSourceDeploymentContainer creates correct object with envVars', async () => {
208
141
  const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
209
142
  fs: fsMock,
210
143
  });
211
144
 
212
- const folderPath = '/absolute/path/to/bad-json-app';
145
+ const input = {
146
+ bucketName: 'test-bucket',
147
+ fileName: 'source.tar.gz',
148
+ deploymentAttrs: {
149
+ cmd: ['node'],
150
+ args: ['server.js'],
151
+ baseImage: 'gcr.io/google-appengine/nodejs',
152
+ envVars: {
153
+ PORT: '8080',
154
+ NODE_ENV: 'production',
155
+ },
156
+ },
157
+ };
213
158
 
214
- fsMock.statSync.mock.mockImplementation(() => ({
215
- isDirectory: () => true,
216
- }));
159
+ const result =
160
+ deploymentHelpers.createDirectSourceDeploymentContainer(input);
217
161
 
218
- fsMock.existsSync.mock.mockImplementation((p) => {
219
- if (p.endsWith('package.json')) return true;
220
- return false;
162
+ assert.deepEqual(result, {
163
+ image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
164
+ baseImageUri: 'gcr.io/google-appengine/nodejs',
165
+ sourceCode: {
166
+ cloudStorageSource: {
167
+ bucket: 'test-bucket',
168
+ object: 'source.tar.gz',
169
+ },
170
+ },
171
+ command: ['node'],
172
+ args: ['server.js'],
173
+ env: [
174
+ { name: 'PORT', value: '8080' },
175
+ { name: 'NODE_ENV', value: 'production' },
176
+ ],
221
177
  });
222
-
223
- // Mock readFileSync to return invalid JSON
224
- fsMock.readFileSync.mock.mockImplementation(() => '{ invalid json }');
225
-
226
- const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
227
-
228
- // Should return empty deployment attrs
229
- assert.equal(result.deploymentAttrs.runtime, undefined);
230
178
  });
231
179
  });
@@ -13,7 +13,6 @@ describe('MCP Server stdio startup', () => {
13
13
  stderr = '';
14
14
  serverProcess = spawn('node', ['mcp-server.js'], {
15
15
  cwd: process.cwd(),
16
- env: { ...process.env, GCP_STDIO: 'true' },
17
16
  });
18
17
  stderr = await waitForString(serverProcess.stderr, stdioMsg);
19
18
  });
@@ -4,6 +4,11 @@ import { test, describe, before, after } from 'node:test';
4
4
  import assert from 'node:assert/strict';
5
5
  import { spawn } from 'child_process';
6
6
  import { waitForString } from './test-utils.js';
7
+ import {
8
+ SCOPES,
9
+ BEARER_METHODS_SUPPORTED,
10
+ RESPONSE_TYPES_SUPPORTED,
11
+ } from '../../constants.js';
7
12
 
8
13
  class MCPClient {
9
14
  client = null;
@@ -59,3 +64,67 @@ describe('MCP Server in Streamble HTTP mode', () => {
59
64
  });
60
65
  });
61
66
  });
67
+
68
+ describe('OAuth Endpoints', () => {
69
+ let serverProcess;
70
+ let stdout = '';
71
+ const httpMsg = 'Cloud Run MCP server listening on port 3001';
72
+ const oauthProtectedResource = 'https://example.com/resource';
73
+ const oauthAuthorizationServer = 'https://example.com/auth';
74
+ const oauthAuthorizationEndpoint = 'https://example.com/auth/authorize';
75
+ const oauthTokenEndpoint = 'https://example.com/auth/token';
76
+
77
+ before(async () => {
78
+ 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
+ });
91
+ stdout = await waitForString(serverProcess.stdout, httpMsg);
92
+ });
93
+
94
+ after(() => {
95
+ if (serverProcess) {
96
+ serverProcess.kill();
97
+ }
98
+ });
99
+
100
+ test('should return correct OAuth protected resource configuration', async () => {
101
+ const response = await fetch(
102
+ 'http://localhost:3001/.well-known/oauth-protected-resource'
103
+ );
104
+ assert.equal(response.status, 200);
105
+ const data = await response.json();
106
+
107
+ assert.deepStrictEqual(data, {
108
+ resource: oauthProtectedResource,
109
+ authorization_servers: [oauthAuthorizationServer],
110
+ scopes_supported: [SCOPES.OPENID, SCOPES.EMAIL, SCOPES.CLOUD_PLATFORM],
111
+ bearer_methods_supported: [...BEARER_METHODS_SUPPORTED],
112
+ });
113
+ });
114
+
115
+ test('should return correct OAuth authorization server configuration', async () => {
116
+ const response = await fetch(
117
+ 'http://localhost:3001/.well-known/oauth-authorization-server'
118
+ );
119
+ assert.equal(response.status, 200);
120
+ const data = await response.json();
121
+
122
+ assert.deepStrictEqual(data, {
123
+ issuer: oauthProtectedResource,
124
+ authorization_endpoint: oauthAuthorizationEndpoint,
125
+ token_endpoint: oauthTokenEndpoint,
126
+ scopes_supported: [SCOPES.OPENID, SCOPES.EMAIL, SCOPES.CLOUD_PLATFORM],
127
+ response_types_supported: [...RESPONSE_TYPES_SUPPORTED],
128
+ });
129
+ });
130
+ });
@@ -11,6 +11,7 @@ describe('MCP Server in stdio mode', () => {
11
11
  transport = new StdioClientTransport({
12
12
  command: 'node',
13
13
  args: ['mcp-server.js'],
14
+ env: { ...process.env, GCP_STDIO: 'true' },
14
15
  });
15
16
  client = new Client({
16
17
  name: 'test-client',