@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.
- package/.env.example +13 -0
- package/.env.gcloud-sdk-oauth +13 -0
- package/README.md +38 -0
- package/constants.js +8 -0
- package/gemini-extension.json +12 -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/constants.js +3 -0
- package/lib/deployment/deployer.js +75 -9
- package/lib/deployment/helpers.js +41 -48
- package/lib/deployment/source-processor.js +0 -48
- package/lib/deployment/universal-maker.js +274 -0
- 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/deployment-helpers.test.js +34 -86
- 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/source-processor.test.js +0 -165
- package/test/local/tools.test.js +2 -2
- package/test/local/universal-maker.test.js +248 -0
- 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.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
|
-
"
|
|
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
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
}));
|
|
159
|
+
const result =
|
|
160
|
+
deploymentHelpers.createDirectSourceDeploymentContainer(input);
|
|
217
161
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
});
|