@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.
- package/.github/workflows/npm-publish.yml +10 -6
- package/README.md +2 -0
- package/lib/clients.js +41 -12
- package/lib/middleware/oauth.js +26 -15
- package/mcp-server.js +79 -20
- package/package.json +2 -2
- package/test/local/clients.test.js +83 -0
- package/test/local/mcp-server-streamable-http.test.js +132 -12
- package/test/local/middleware/oauth.test.js +106 -5
- package/test/need-gcp/test-helpers.js +5 -1
- package/tools/register-tools.js +82 -78
|
@@ -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@
|
|
12
|
-
|
|
14
|
+
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
|
|
15
|
+
with:
|
|
16
|
+
persist-credentials: false
|
|
17
|
+
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
|
|
13
18
|
with:
|
|
14
|
-
node-version:
|
|
19
|
+
node-version: 24
|
|
15
20
|
- run: npm ci
|
|
16
|
-
|
|
17
|
-
- uses: actions/setup-node@v4
|
|
21
|
+
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444
|
|
18
22
|
with:
|
|
19
|
-
node-version:
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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');
|
package/lib/middleware/oauth.js
CHANGED
|
@@ -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
|
|
20
|
-
throw new Error(
|
|
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
|
|
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:
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
|
272
|
-
if (
|
|
273
|
-
|
|
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.
|
|
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": "^
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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(),
|
|
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, [
|
|
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`;
|
package/tools/register-tools.js
CHANGED
|
@@ -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:
|
|
151
|
-
.string()
|
|
152
|
-
.
|
|
153
|
-
|
|
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:
|
|
206
|
-
.string()
|
|
207
|
-
.
|
|
208
|
-
|
|
209
|
-
region:
|
|
210
|
-
.string()
|
|
211
|
-
.
|
|
212
|
-
|
|
213
|
-
service:
|
|
214
|
-
.string()
|
|
215
|
-
.
|
|
216
|
-
|
|
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:
|
|
296
|
-
.string()
|
|
297
|
-
.
|
|
298
|
-
|
|
299
|
-
region:
|
|
300
|
-
.string()
|
|
301
|
-
.
|
|
302
|
-
|
|
303
|
-
service:
|
|
304
|
-
.string()
|
|
305
|
-
.
|
|
306
|
-
|
|
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:
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
.
|
|
374
|
-
.
|
|
375
|
-
service
|
|
376
|
-
|
|
377
|
-
.optional()
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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:
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
.
|
|
465
|
-
.
|
|
466
|
-
service
|
|
467
|
-
|
|
468
|
-
.optional()
|
|
469
|
-
|
|
470
|
-
|
|
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:
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
.
|
|
568
|
-
.
|
|
569
|
-
service
|
|
570
|
-
|
|
571
|
-
.optional()
|
|
572
|
-
|
|
573
|
-
|
|
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(
|