@google-cloud/cloud-run-mcp 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -0
- package/.env.gcloud-sdk-oauth +13 -0
- package/README.md +38 -0
- package/constants.js +8 -0
- package/lib/clients.js +72 -2
- package/lib/cloud-api/auth.js +1 -0
- package/lib/cloud-api/billing.js +16 -10
- package/lib/cloud-api/build.js +6 -4
- package/lib/cloud-api/helpers.js +19 -6
- package/lib/cloud-api/projects.js +15 -10
- package/lib/cloud-api/registry.js +5 -1
- package/lib/cloud-api/run.js +12 -11
- package/lib/cloud-api/storage.js +2 -1
- package/lib/deployment/deployer.js +24 -7
- package/lib/middleware/oauth.js +92 -0
- package/lib/util/helpers.js +12 -0
- package/mcp-server.js +51 -7
- package/package.json +2 -2
- package/test/local/clients.test.js +33 -3
- package/test/local/mcp-server-stdio.test.js +0 -1
- package/test/local/mcp-server-streamable-http.test.js +69 -0
- package/test/local/mcp-server.test.js +1 -0
- package/test/local/middleware/oauth.test.js +156 -0
- package/test/local/tools.test.js +2 -2
- package/tools/register-tools.js +18 -5
- package/.kokoro/kokoro_build.sh +0 -22
- package/.kokoro/presubmit.cfg +0 -21
- package/.kokoro/run_tests.sh +0 -31
|
@@ -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
|
+
};
|
package/lib/util/helpers.js
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cloud Run MCP deployment tool",
|
|
6
6
|
"main": "mcp-server.js",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"@google-cloud/storage": "^7.16.0",
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
52
52
|
"archiver": "^7.0.1",
|
|
53
|
-
"
|
|
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 () => {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import esmock from 'esmock';
|
|
4
|
+
|
|
5
|
+
describe('oauthMiddleware', () => {
|
|
6
|
+
let req;
|
|
7
|
+
let res;
|
|
8
|
+
let next;
|
|
9
|
+
let originalEnv;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
originalEnv = process.env;
|
|
13
|
+
process.env = { ...originalEnv };
|
|
14
|
+
req = {
|
|
15
|
+
headers: {},
|
|
16
|
+
body: {},
|
|
17
|
+
};
|
|
18
|
+
res = {
|
|
19
|
+
headersSent: false,
|
|
20
|
+
status: mock.fn(() => res),
|
|
21
|
+
json: mock.fn(),
|
|
22
|
+
};
|
|
23
|
+
next = mock.fn();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
process.env = originalEnv;
|
|
28
|
+
mock.restoreAll();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should call next() if OAUTH_ENABLED is not "true"', async () => {
|
|
32
|
+
process.env.OAUTH_ENABLED = 'false';
|
|
33
|
+
const { oauthMiddleware } = await esmock(
|
|
34
|
+
'../../../lib/middleware/oauth.js',
|
|
35
|
+
{}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
await oauthMiddleware(req, res, next);
|
|
39
|
+
|
|
40
|
+
assert.strictEqual(next.mock.callCount(), 1);
|
|
41
|
+
assert.strictEqual(res.status.mock.callCount(), 0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should call next() if method is not tools/call', async () => {
|
|
45
|
+
process.env.OAUTH_ENABLED = 'true';
|
|
46
|
+
req.body.method = 'other/method';
|
|
47
|
+
|
|
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
|
+
const { oauthMiddleware } = await esmock(
|
|
51
|
+
'../../../lib/middleware/oauth.js',
|
|
52
|
+
{}
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
await oauthMiddleware(req, res, next);
|
|
56
|
+
|
|
57
|
+
assert.strictEqual(next.mock.callCount(), 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return 401 if Authorization header is missing for tool call', async () => {
|
|
61
|
+
process.env.OAUTH_ENABLED = 'true';
|
|
62
|
+
req.body.method = 'tools/call';
|
|
63
|
+
|
|
64
|
+
const { oauthMiddleware } = await esmock(
|
|
65
|
+
'../../../lib/middleware/oauth.js',
|
|
66
|
+
{}
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await oauthMiddleware(req, res, next);
|
|
70
|
+
|
|
71
|
+
assert.strictEqual(next.mock.callCount(), 0);
|
|
72
|
+
assert.strictEqual(res.status.mock.callCount(), 1);
|
|
73
|
+
assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
|
|
74
|
+
assert.strictEqual(res.json.mock.callCount(), 1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should verify token and call next() for valid tool call', async () => {
|
|
78
|
+
process.env.OAUTH_ENABLED = 'true';
|
|
79
|
+
req.body.method = 'tools/call';
|
|
80
|
+
req.headers.authorization = 'Bearer valid-token';
|
|
81
|
+
|
|
82
|
+
const mockGetTokenInfo = mock.fn(async () => ({ aud: 'valid-audience' }));
|
|
83
|
+
const mockGetOAuthClient = mock.fn(async () => ({
|
|
84
|
+
getTokenInfo: mockGetTokenInfo,
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
const { oauthMiddleware } = await esmock(
|
|
88
|
+
'../../../lib/middleware/oauth.js',
|
|
89
|
+
{
|
|
90
|
+
'../../../lib/clients.js': {
|
|
91
|
+
getOAuthClient: mockGetOAuthClient,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await oauthMiddleware(req, res, next);
|
|
97
|
+
|
|
98
|
+
assert.strictEqual(next.mock.callCount(), 1);
|
|
99
|
+
assert.strictEqual(mockGetOAuthClient.mock.callCount(), 1);
|
|
100
|
+
assert.strictEqual(mockGetTokenInfo.mock.callCount(), 1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should return 401 if token verification fails', async () => {
|
|
104
|
+
process.env.OAUTH_ENABLED = 'true';
|
|
105
|
+
req.body.method = 'tools/call';
|
|
106
|
+
req.headers.authorization = 'Bearer invalid-token';
|
|
107
|
+
|
|
108
|
+
const mockGetOAuthClient = mock.fn(async () => ({
|
|
109
|
+
getTokenInfo: async () => {
|
|
110
|
+
throw new Error('Invalid token');
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
const { oauthMiddleware } = await esmock(
|
|
115
|
+
'../../../lib/middleware/oauth.js',
|
|
116
|
+
{
|
|
117
|
+
'../../../lib/clients.js': {
|
|
118
|
+
getOAuthClient: mockGetOAuthClient,
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
await oauthMiddleware(req, res, next);
|
|
124
|
+
|
|
125
|
+
assert.strictEqual(next.mock.callCount(), 0);
|
|
126
|
+
assert.strictEqual(res.status.mock.callCount(), 1);
|
|
127
|
+
assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should return 401 if audience does not match', async () => {
|
|
131
|
+
process.env.OAUTH_ENABLED = 'true';
|
|
132
|
+
process.env.GOOGLE_OAUTH_AUDIENCE = 'expected-audience';
|
|
133
|
+
req.body.method = 'tools/call';
|
|
134
|
+
req.headers.authorization = 'Bearer valid-token-wrong-audience';
|
|
135
|
+
|
|
136
|
+
const mockGetTokenInfo = mock.fn(async () => ({ aud: 'wrong-audience' }));
|
|
137
|
+
const mockGetOAuthClient = mock.fn(async () => ({
|
|
138
|
+
getTokenInfo: mockGetTokenInfo,
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
const { oauthMiddleware } = await esmock(
|
|
142
|
+
'../../../lib/middleware/oauth.js',
|
|
143
|
+
{
|
|
144
|
+
'../../../lib/clients.js': {
|
|
145
|
+
getOAuthClient: mockGetOAuthClient,
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await oauthMiddleware(req, res, next);
|
|
151
|
+
|
|
152
|
+
assert.strictEqual(next.mock.callCount(), 0);
|
|
153
|
+
assert.strictEqual(res.status.mock.callCount(), 1);
|
|
154
|
+
assert.deepStrictEqual(res.status.mock.calls[0].arguments, [401]);
|
|
155
|
+
});
|
|
156
|
+
});
|
package/test/local/tools.test.js
CHANGED
|
@@ -42,7 +42,7 @@ describe('registerTools', () => {
|
|
|
42
42
|
{},
|
|
43
43
|
{
|
|
44
44
|
'../../lib/cloud-api/projects.js': {
|
|
45
|
-
listProjects: () =>
|
|
45
|
+
listProjects: (token) =>
|
|
46
46
|
Promise.resolve([{ id: 'project1' }, { id: 'project2' }]),
|
|
47
47
|
},
|
|
48
48
|
}
|
|
@@ -433,7 +433,7 @@ describe('registerTools', () => {
|
|
|
433
433
|
content: [
|
|
434
434
|
{
|
|
435
435
|
type: 'text',
|
|
436
|
-
text: 'GCP credentials are not available. Please configure your environment
|
|
436
|
+
text: 'GCP credentials are not available. Please configure your environment using OAuth or `gcloud auth`.',
|
|
437
437
|
},
|
|
438
438
|
],
|
|
439
439
|
});
|
package/tools/register-tools.js
CHANGED
|
@@ -41,7 +41,7 @@ function gcpTool(gcpCredentialsAvailable, fn) {
|
|
|
41
41
|
content: [
|
|
42
42
|
{
|
|
43
43
|
type: 'text',
|
|
44
|
-
text: 'GCP credentials are not available. Please configure your environment
|
|
44
|
+
text: 'GCP credentials are not available. Please configure your environment using OAuth or `gcloud auth`.',
|
|
45
45
|
},
|
|
46
46
|
],
|
|
47
47
|
});
|
|
@@ -59,7 +59,7 @@ function registerListProjectsTool(server, options) {
|
|
|
59
59
|
},
|
|
60
60
|
gcpTool(options.gcpCredentialsAvailable, async () => {
|
|
61
61
|
try {
|
|
62
|
-
const projects = await listProjects();
|
|
62
|
+
const projects = await listProjects(options.accessToken);
|
|
63
63
|
return {
|
|
64
64
|
content: [
|
|
65
65
|
{
|
|
@@ -113,7 +113,11 @@ function registerCreateProjectTool(server, options) {
|
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
115
|
try {
|
|
116
|
-
const result = await createProjectAndAttachBilling(
|
|
116
|
+
const result = await createProjectAndAttachBilling(
|
|
117
|
+
projectId,
|
|
118
|
+
undefined,
|
|
119
|
+
options.accessToken
|
|
120
|
+
);
|
|
117
121
|
return {
|
|
118
122
|
content: [
|
|
119
123
|
{
|
|
@@ -161,7 +165,7 @@ function registerListServicesTool(server, options) {
|
|
|
161
165
|
};
|
|
162
166
|
}
|
|
163
167
|
try {
|
|
164
|
-
const allServices = await listServices(project);
|
|
168
|
+
const allServices = await listServices(project, options.accessToken);
|
|
165
169
|
const content = [];
|
|
166
170
|
for (const region of Object.keys(allServices)) {
|
|
167
171
|
const serviceList = allServices[region];
|
|
@@ -230,7 +234,12 @@ function registerGetServiceTool(server, options) {
|
|
|
230
234
|
};
|
|
231
235
|
}
|
|
232
236
|
try {
|
|
233
|
-
const serviceDetails = await getService(
|
|
237
|
+
const serviceDetails = await getService(
|
|
238
|
+
project,
|
|
239
|
+
region,
|
|
240
|
+
service,
|
|
241
|
+
options.accessToken
|
|
242
|
+
);
|
|
234
243
|
if (serviceDetails) {
|
|
235
244
|
return {
|
|
236
245
|
content: [
|
|
@@ -299,6 +308,7 @@ function registerGetServiceLogTool(server, options) {
|
|
|
299
308
|
project,
|
|
300
309
|
region,
|
|
301
310
|
service,
|
|
311
|
+
options.accessToken,
|
|
302
312
|
requestOptions
|
|
303
313
|
);
|
|
304
314
|
|
|
@@ -394,6 +404,7 @@ function registerDeployLocalFolderTool(server, options) {
|
|
|
394
404
|
region: region,
|
|
395
405
|
files: [folderPath],
|
|
396
406
|
skipIamCheck: options.skipIamCheck, // Pass the new flag
|
|
407
|
+
accessToken: options.accessToken,
|
|
397
408
|
progressCallback,
|
|
398
409
|
});
|
|
399
410
|
return {
|
|
@@ -495,6 +506,7 @@ function registerDeployFileContentsTool(server, options) {
|
|
|
495
506
|
region: region,
|
|
496
507
|
files: files,
|
|
497
508
|
skipIamCheck: options.skipIamCheck, // Pass the new flag
|
|
509
|
+
accessToken: options.accessToken,
|
|
498
510
|
progressCallback,
|
|
499
511
|
});
|
|
500
512
|
return {
|
|
@@ -575,6 +587,7 @@ function registerDeployContainerImageTool(server, options) {
|
|
|
575
587
|
region: region,
|
|
576
588
|
imageUrl: imageUrl,
|
|
577
589
|
skipIamCheck: options.skipIamCheck,
|
|
590
|
+
accessToken: options.accessToken,
|
|
578
591
|
progressCallback,
|
|
579
592
|
});
|
|
580
593
|
return {
|
package/.kokoro/kokoro_build.sh
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
|
|
3
|
-
# Copyright 2022 Google LLC
|
|
4
|
-
#
|
|
5
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
# you may not use this file except in compliance with the License.
|
|
7
|
-
# You may obtain a copy of the License at
|
|
8
|
-
#
|
|
9
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
#
|
|
11
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
# See the License for the specific language governing permissions and
|
|
15
|
-
# limitations under the License.
|
|
16
|
-
|
|
17
|
-
# Code under repo is checked out to ${KOKORO_ARTIFACTS_DIR}/git.
|
|
18
|
-
# The final directory name in this path is determined by the scm name specified
|
|
19
|
-
# in the job configuration.
|
|
20
|
-
|
|
21
|
-
cd "${KOKORO_ARTIFACTS_DIR}/github/cloud-run-mcp"
|
|
22
|
-
.kokoro/run_tests.sh
|