@google-cloud/cloud-run-mcp 1.10.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 +26 -0
- package/SECURITY.md +7 -0
- package/cloudbuild.yaml +37 -0
- package/example-sources-to-deploy/java/pom.xml +41 -0
- package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
- package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
- package/example-sources-to-deploy/nodejs/index.js +15 -0
- package/example-sources-to-deploy/nodejs/package.json +12 -0
- package/example-sources-to-deploy/python/pip-project/main.py +11 -0
- package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
- package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
- package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
- package/lib/clients.js +142 -12
- package/lib/cloud-api/build.js +192 -28
- package/lib/cloud-api/run.js +62 -0
- package/lib/cloud-api/secrets.js +196 -0
- package/lib/cloud-api/storage.js +164 -16
- package/lib/deployment/compose.js +517 -0
- package/lib/deployment/constants.js +8 -0
- package/lib/deployment/deployer.js +283 -28
- package/lib/deployment/helpers.js +58 -1
- package/lib/deployment/universal-maker.js +21 -148
- package/lib/middleware/oauth.js +26 -15
- package/lib/util/archive.js +3 -3
- package/lib/util/artifacts.js +189 -0
- package/lib/util/helpers.js +109 -0
- package/mcp-server.js +83 -20
- package/package.json +7 -4
- package/skills/cloud-run/SKILL.md +109 -0
- package/test/local/archive.test.js +88 -0
- package/test/local/clients.test.js +307 -2
- package/test/local/cloud-api/build.test.js +330 -25
- package/test/local/cloud-api/run.test.js +129 -0
- package/test/local/cloud-api/secrets.test.js +291 -0
- package/test/local/cloud-api/storage.test.js +405 -0
- package/test/local/compose.test.js +618 -0
- package/test/local/deploy.test.js +408 -0
- package/test/local/deployment-helpers.test.js +100 -1
- package/test/local/mcp-server-streamable-http.test.js +132 -12
- package/test/local/middleware/oauth.test.js +106 -5
- package/test/local/test-utils.js +2 -18
- package/test/local/tools.test.js +43 -1
- package/test/local/universal-maker.test.js +23 -114
- package/test/local/util.test.js +361 -0
- package/test/need-gcp/deployer.test.js +1 -1
- package/test/need-gcp/test-helpers.js +5 -1
- package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
- package/tools/register-tools.js +98 -78
- /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
- /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
- /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2025 Google LLC
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { test, describe, mock } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import esmock from 'esmock';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
|
|
22
|
+
describe('Deploy Compose', () => {
|
|
23
|
+
const projectId = 'test-project';
|
|
24
|
+
const region = 'us-central1';
|
|
25
|
+
const accessToken = 'test-token';
|
|
26
|
+
const composeFilePath = '/abs/path/to/compose.yaml';
|
|
27
|
+
const files = [path.dirname(composeFilePath)];
|
|
28
|
+
|
|
29
|
+
test('successfully deploys a simple compose file with one source-built service', async () => {
|
|
30
|
+
// Setup mocks
|
|
31
|
+
const prepareSourceDirectoryMock = mock.fn(async () => '/tmp/temp-dir');
|
|
32
|
+
const getProjectNumberMock = mock.fn(async () => '123456789');
|
|
33
|
+
const downloadRunComposeMock = mock.fn(
|
|
34
|
+
async () => '/usr/local/bin/run-compose'
|
|
35
|
+
);
|
|
36
|
+
const resourceComposeMock = mock.fn(async () =>
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
source_builds: {
|
|
39
|
+
web: { context: 'app' },
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
const translateComposeMock = mock.fn(async () =>
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
services: {
|
|
46
|
+
web: 'web.yaml',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
const triggerCloudBuildMock = mock.fn(async () => ({
|
|
51
|
+
results: {
|
|
52
|
+
images: [
|
|
53
|
+
{
|
|
54
|
+
name: 'us-central1-docker.pkg.dev/test-project/mcp-cloud-run-deployments/web:latest',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
const ensureArtifactRegistryRepoExistsMock = mock.fn();
|
|
60
|
+
const ensureStorageBucketExistsMock = mock.fn(async () => ({}));
|
|
61
|
+
const uploadToStorageBucketMock = mock.fn();
|
|
62
|
+
const zipFilesMock = mock.fn(async () => Buffer.from('dummy zip content'));
|
|
63
|
+
const cleanupTempDirectoryMock = mock.fn();
|
|
64
|
+
const getRunV1ClientMock = mock.fn(async () => ({
|
|
65
|
+
namespaces: {
|
|
66
|
+
services: {
|
|
67
|
+
replaceService: mock.fn(async () => ({
|
|
68
|
+
data: { metadata: { name: 'web' } },
|
|
69
|
+
})),
|
|
70
|
+
create: mock.fn(),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
const fsMock = {
|
|
75
|
+
existsSync: mock.fn(() => true),
|
|
76
|
+
promises: {
|
|
77
|
+
mkdtemp: mock.fn(async () => '/tmp/random-dir'),
|
|
78
|
+
readFile: mock.fn(async () => 'name: web'),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const logAndProgressMock = mock.fn();
|
|
82
|
+
|
|
83
|
+
// Import with mocks
|
|
84
|
+
const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
|
|
85
|
+
'../../lib/deployment/source-processor.js': {
|
|
86
|
+
prepareSourceDirectory: prepareSourceDirectoryMock,
|
|
87
|
+
cleanupTempDirectory: cleanupTempDirectoryMock,
|
|
88
|
+
},
|
|
89
|
+
'../../lib/util/helpers.js': {
|
|
90
|
+
getProjectNumber: getProjectNumberMock,
|
|
91
|
+
logAndProgress: logAndProgressMock,
|
|
92
|
+
},
|
|
93
|
+
'../../lib/deployment/compose.js': {
|
|
94
|
+
runCompose: downloadRunComposeMock,
|
|
95
|
+
resourceCompose: resourceComposeMock,
|
|
96
|
+
translateCompose: translateComposeMock,
|
|
97
|
+
composeVolumes: mock.fn(async (resourcesConfig) => resourcesConfig),
|
|
98
|
+
},
|
|
99
|
+
'../../lib/cloud-api/build.js': {
|
|
100
|
+
triggerCloudBuild: triggerCloudBuildMock,
|
|
101
|
+
composeBuild: mock.fn(
|
|
102
|
+
async (resourcesConfig, token, pId, reg, fPath, cb) => {
|
|
103
|
+
for (const serviceName in resourcesConfig.source_builds) {
|
|
104
|
+
const imageUrl = `${reg}-docker.pkg.dev/${pId}/mcp-cloud-run-deployments/${serviceName}:latest`;
|
|
105
|
+
const buildResult = await triggerCloudBuildMock(
|
|
106
|
+
pId,
|
|
107
|
+
reg,
|
|
108
|
+
'bucket',
|
|
109
|
+
'blob',
|
|
110
|
+
'repo',
|
|
111
|
+
imageUrl,
|
|
112
|
+
true,
|
|
113
|
+
token,
|
|
114
|
+
cb
|
|
115
|
+
);
|
|
116
|
+
resourcesConfig.source_builds[serviceName].image_id =
|
|
117
|
+
buildResult.results.images[0].name;
|
|
118
|
+
}
|
|
119
|
+
return resourcesConfig;
|
|
120
|
+
}
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
'../../lib/cloud-api/registry.js': {
|
|
124
|
+
ensureArtifactRegistryRepoExists: ensureArtifactRegistryRepoExistsMock,
|
|
125
|
+
},
|
|
126
|
+
'../../lib/util/archive.js': {
|
|
127
|
+
zipFiles: zipFilesMock,
|
|
128
|
+
},
|
|
129
|
+
'../../lib/cloud-api/storage.js': {
|
|
130
|
+
ensureStorageBucketExists: ensureStorageBucketExistsMock,
|
|
131
|
+
uploadToStorageBucket: uploadToStorageBucketMock,
|
|
132
|
+
},
|
|
133
|
+
'../../lib/clients.js': {
|
|
134
|
+
getRunV1Client: getRunV1ClientMock,
|
|
135
|
+
},
|
|
136
|
+
fs: fsMock,
|
|
137
|
+
path: path,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const result = await deployCompose({
|
|
141
|
+
projectId,
|
|
142
|
+
region,
|
|
143
|
+
files,
|
|
144
|
+
composeFilePath,
|
|
145
|
+
accessToken,
|
|
146
|
+
progressCallback: logAndProgressMock,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Validations
|
|
150
|
+
assert.ok(result);
|
|
151
|
+
assert.equal(result.service, 'web');
|
|
152
|
+
assert.equal(result.uri, undefined); // In the mock, status.url is not provided
|
|
153
|
+
assert.equal(prepareSourceDirectoryMock.mock.callCount(), 1);
|
|
154
|
+
assert.equal(resourceComposeMock.mock.callCount(), 1);
|
|
155
|
+
assert.equal(translateComposeMock.mock.callCount(), 1);
|
|
156
|
+
assert.equal(triggerCloudBuildMock.mock.callCount(), 1);
|
|
157
|
+
assert.equal(cleanupTempDirectoryMock.mock.callCount(), 1);
|
|
158
|
+
|
|
159
|
+
// Verify triggerCloudBuild was called with correctly updated image tag
|
|
160
|
+
const buildCall = triggerCloudBuildMock.mock.calls[0];
|
|
161
|
+
assert.equal(
|
|
162
|
+
buildCall.arguments[5],
|
|
163
|
+
'us-central1-docker.pkg.dev/test-project/mcp-cloud-run-deployments/web:latest'
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('failure in prepareSourceDirectory triggers cleanup and rethrows', async () => {
|
|
168
|
+
const error = new Error('Preparation failed');
|
|
169
|
+
const prepareSourceDirectoryMock = mock.fn(async () => {
|
|
170
|
+
throw error;
|
|
171
|
+
});
|
|
172
|
+
const logAndProgressMock = mock.fn();
|
|
173
|
+
const cleanupTempDirectoryMock = mock.fn();
|
|
174
|
+
|
|
175
|
+
const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
|
|
176
|
+
'../../lib/deployment/source-processor.js': {
|
|
177
|
+
prepareSourceDirectory: prepareSourceDirectoryMock,
|
|
178
|
+
cleanupTempDirectory: cleanupTempDirectoryMock,
|
|
179
|
+
},
|
|
180
|
+
'../../lib/util/helpers.js': {
|
|
181
|
+
logAndProgress: logAndProgressMock,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
deployCompose({
|
|
187
|
+
projectId,
|
|
188
|
+
region,
|
|
189
|
+
files,
|
|
190
|
+
composeFilePath,
|
|
191
|
+
accessToken,
|
|
192
|
+
progressCallback: logAndProgressMock,
|
|
193
|
+
}),
|
|
194
|
+
error
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
assert.equal(
|
|
198
|
+
logAndProgressMock.mock.calls.some(
|
|
199
|
+
(call) => call.arguments[2] === 'error'
|
|
200
|
+
),
|
|
201
|
+
true
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('calls composeSecrets during deployment', async () => {
|
|
206
|
+
const composeSecretsMock = mock.fn(
|
|
207
|
+
async (resourcesConfig) => resourcesConfig
|
|
208
|
+
);
|
|
209
|
+
const prepareSourceDirectoryMock = mock.fn(async () => '/tmp/temp-dir');
|
|
210
|
+
const downloadRunComposeMock = mock.fn(async () => '/bin/run-compose');
|
|
211
|
+
const resourceComposeMock = mock.fn(async () => JSON.stringify({}));
|
|
212
|
+
const translateComposeMock = mock.fn(async () =>
|
|
213
|
+
JSON.stringify({ services: {} })
|
|
214
|
+
);
|
|
215
|
+
const getProjectNumberMock = mock.fn(async () => '123456');
|
|
216
|
+
const cleanupTempDirectoryMock = mock.fn();
|
|
217
|
+
|
|
218
|
+
const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
|
|
219
|
+
'../../lib/deployment/source-processor.js': {
|
|
220
|
+
prepareSourceDirectory: prepareSourceDirectoryMock,
|
|
221
|
+
cleanupTempDirectory: cleanupTempDirectoryMock,
|
|
222
|
+
},
|
|
223
|
+
'../../lib/util/helpers.js': {
|
|
224
|
+
getProjectNumber: getProjectNumberMock,
|
|
225
|
+
logAndProgress: mock.fn(),
|
|
226
|
+
},
|
|
227
|
+
'../../lib/deployment/compose.js': {
|
|
228
|
+
runCompose: downloadRunComposeMock,
|
|
229
|
+
resourceCompose: resourceComposeMock,
|
|
230
|
+
translateCompose: translateComposeMock,
|
|
231
|
+
composeVolumes: mock.fn(async (resourcesConfig) => resourcesConfig),
|
|
232
|
+
composeSecrets: composeSecretsMock,
|
|
233
|
+
},
|
|
234
|
+
'../../lib/clients.js': {
|
|
235
|
+
getRunV1Client: mock.fn(async () => ({ namespaces: { services: {} } })),
|
|
236
|
+
},
|
|
237
|
+
fs: {
|
|
238
|
+
promises: {
|
|
239
|
+
readFile: mock.fn(async () => 'name: web'),
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
path: path,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await deployCompose({
|
|
246
|
+
projectId,
|
|
247
|
+
region,
|
|
248
|
+
files,
|
|
249
|
+
composeFilePath,
|
|
250
|
+
accessToken,
|
|
251
|
+
progressCallback: mock.fn(),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
assert.strictEqual(composeSecretsMock.mock.callCount(), 1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('successfully deploys models and services in correct order', async () => {
|
|
258
|
+
const prepareSourceDirectoryMock = mock.fn(async () => '/tmp/temp-dir');
|
|
259
|
+
const getProjectNumberMock = mock.fn(async () => '123456');
|
|
260
|
+
const downloadRunComposeMock = mock.fn(async () => '/bin/run-compose');
|
|
261
|
+
const resourceComposeMock = mock.fn(async () => JSON.stringify({}));
|
|
262
|
+
const translateComposeMock = mock.fn(async () =>
|
|
263
|
+
JSON.stringify({
|
|
264
|
+
models: { 'my-model': 'model.yaml' },
|
|
265
|
+
services: { 'my-app': 'app.yaml' },
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const deployedServices = [];
|
|
270
|
+
const replaceServiceMock = mock.fn(async (params) => {
|
|
271
|
+
// Extract service name from namespaces/{p}/services/{s}
|
|
272
|
+
const serviceName = params.name.split('/').pop();
|
|
273
|
+
deployedServices.push(serviceName);
|
|
274
|
+
return { data: { status: { url: `https://${serviceName}.a.run.app` } } };
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const getRunV1ClientMock = mock.fn(async () => ({
|
|
278
|
+
namespaces: {
|
|
279
|
+
services: {
|
|
280
|
+
replaceService: replaceServiceMock,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
|
|
286
|
+
'../../lib/deployment/source-processor.js': {
|
|
287
|
+
prepareSourceDirectory: prepareSourceDirectoryMock,
|
|
288
|
+
cleanupTempDirectory: mock.fn(),
|
|
289
|
+
},
|
|
290
|
+
'../../lib/util/helpers.js': {
|
|
291
|
+
getProjectNumber: getProjectNumberMock,
|
|
292
|
+
logAndProgress: mock.fn(),
|
|
293
|
+
},
|
|
294
|
+
'../../lib/deployment/compose.js': {
|
|
295
|
+
runCompose: downloadRunComposeMock,
|
|
296
|
+
resourceCompose: resourceComposeMock,
|
|
297
|
+
translateCompose: translateComposeMock,
|
|
298
|
+
composeVolumes: mock.fn(async (resourcesConfig) => resourcesConfig),
|
|
299
|
+
composeSecrets: mock.fn(async (resourcesConfig) => resourcesConfig),
|
|
300
|
+
},
|
|
301
|
+
'../../lib/clients.js': {
|
|
302
|
+
getRunV1Client: getRunV1ClientMock,
|
|
303
|
+
},
|
|
304
|
+
fs: {
|
|
305
|
+
promises: {
|
|
306
|
+
readFile: mock.fn(async () => 'name: dummy'),
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
path: path,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const result = await deployCompose({
|
|
313
|
+
projectId,
|
|
314
|
+
region,
|
|
315
|
+
files,
|
|
316
|
+
composeFilePath,
|
|
317
|
+
accessToken,
|
|
318
|
+
progressCallback: mock.fn(),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
assert.ok(result);
|
|
322
|
+
assert.strictEqual(result.service, 'my-app');
|
|
323
|
+
assert.strictEqual(result.uri, 'https://my-app.a.run.app');
|
|
324
|
+
assert.strictEqual(deployedServices.length, 2);
|
|
325
|
+
// Verify order: models first
|
|
326
|
+
assert.strictEqual(deployedServices[0], 'my-model');
|
|
327
|
+
assert.strictEqual(deployedServices[1], 'my-app');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('sanitizes service names with underscores', async () => {
|
|
331
|
+
const rawServiceName = 'ai-model-test_web';
|
|
332
|
+
const sanitizedName = 'ai-model-test-web';
|
|
333
|
+
|
|
334
|
+
const prepareSourceDirectoryMock = mock.fn(async () => '/tmp/temp-dir');
|
|
335
|
+
const getProjectNumberMock = mock.fn(async () => '123456');
|
|
336
|
+
const downloadRunComposeMock = mock.fn(async () => '/bin/run-compose');
|
|
337
|
+
const translateComposeMock = mock.fn(async () =>
|
|
338
|
+
JSON.stringify({
|
|
339
|
+
services: { [rawServiceName]: 'web.yaml' },
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const replaceServiceMock = mock.fn(async (params) => {
|
|
344
|
+
return { data: { metadata: { name: params.name.split('/').pop() } } };
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const { deployCompose } = await esmock('../../lib/deployment/deployer.js', {
|
|
348
|
+
'../../lib/deployment/source-processor.js': {
|
|
349
|
+
prepareSourceDirectory: prepareSourceDirectoryMock,
|
|
350
|
+
cleanupTempDirectory: mock.fn(),
|
|
351
|
+
},
|
|
352
|
+
'../../lib/util/helpers.js': {
|
|
353
|
+
getProjectNumber: getProjectNumberMock,
|
|
354
|
+
logAndProgress: mock.fn(),
|
|
355
|
+
sanitizeCloudRunServiceName: (name) =>
|
|
356
|
+
name
|
|
357
|
+
.toLowerCase()
|
|
358
|
+
.replace(/[^a-z0-9]/g, '-')
|
|
359
|
+
.replace(/^-+|-+$/g, ''),
|
|
360
|
+
},
|
|
361
|
+
'../../lib/deployment/compose.js': {
|
|
362
|
+
runCompose: downloadRunComposeMock,
|
|
363
|
+
resourceCompose: mock.fn(async () => JSON.stringify({})),
|
|
364
|
+
translateCompose: translateComposeMock,
|
|
365
|
+
composeVolumes: mock.fn(async (resourcesConfig) => resourcesConfig),
|
|
366
|
+
composeSecrets: mock.fn(async (resourcesConfig) => resourcesConfig),
|
|
367
|
+
},
|
|
368
|
+
'../../lib/cloud-api/build.js': {
|
|
369
|
+
composeBuild: mock.fn(async (rc) => rc),
|
|
370
|
+
},
|
|
371
|
+
'../../lib/clients.js': {
|
|
372
|
+
getRunV1Client: mock.fn(async () => ({
|
|
373
|
+
namespaces: { services: { replaceService: replaceServiceMock } },
|
|
374
|
+
})),
|
|
375
|
+
},
|
|
376
|
+
fs: {
|
|
377
|
+
promises: {
|
|
378
|
+
readFile: mock.fn(async () => 'metadata:\n name: ai-model-test_web'),
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
path: path,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await deployCompose({
|
|
385
|
+
projectId,
|
|
386
|
+
region,
|
|
387
|
+
files,
|
|
388
|
+
composeFilePath,
|
|
389
|
+
accessToken,
|
|
390
|
+
progressCallback: mock.fn(),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
assert.strictEqual(replaceServiceMock.mock.callCount(), 1);
|
|
394
|
+
const call = replaceServiceMock.mock.calls[0];
|
|
395
|
+
|
|
396
|
+
// Verify name in URL was sanitized
|
|
397
|
+
assert.strictEqual(
|
|
398
|
+
call.arguments[0].name,
|
|
399
|
+
`namespaces/${projectId}/services/${sanitizedName}`
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// Verify name in requestBody metadata was sanitized
|
|
403
|
+
assert.strictEqual(
|
|
404
|
+
call.arguments[0].requestBody.metadata.name,
|
|
405
|
+
sanitizedName
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -23,7 +23,10 @@ describe('Deployment Helpers', () => {
|
|
|
23
23
|
const fsMock = {
|
|
24
24
|
statSync: mock.fn(),
|
|
25
25
|
existsSync: mock.fn(),
|
|
26
|
-
|
|
26
|
+
promises: {
|
|
27
|
+
readdir: mock.fn(),
|
|
28
|
+
readFile: mock.fn(),
|
|
29
|
+
},
|
|
27
30
|
};
|
|
28
31
|
|
|
29
32
|
test('makeFileDeploymentMetadata correctly identifies Dockerfile in folder', async () => {
|
|
@@ -176,4 +179,100 @@ describe('Deployment Helpers', () => {
|
|
|
176
179
|
],
|
|
177
180
|
});
|
|
178
181
|
});
|
|
182
|
+
|
|
183
|
+
test('uploadDirectory recursively uploads files', async () => {
|
|
184
|
+
const uploadToStorageBucketMock = mock.fn(async () => ({}));
|
|
185
|
+
const readFileMock = mock.fn(async () => Buffer.from('content'));
|
|
186
|
+
|
|
187
|
+
// Create a setup for each call to differentiate directory depth
|
|
188
|
+
fsMock.promises.readdir.mock.resetCalls();
|
|
189
|
+
fsMock.promises.readdir.mock.mockImplementation(async (p) => {
|
|
190
|
+
if (p.endsWith('subdir')) {
|
|
191
|
+
return [{ name: 'file2.txt', isDirectory: () => false }];
|
|
192
|
+
}
|
|
193
|
+
return [
|
|
194
|
+
{ name: 'file1.txt', isDirectory: () => false },
|
|
195
|
+
{ name: 'subdir', isDirectory: () => true },
|
|
196
|
+
];
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
200
|
+
fs: {
|
|
201
|
+
...fsMock,
|
|
202
|
+
promises: {
|
|
203
|
+
...fsMock.promises,
|
|
204
|
+
readFile: readFileMock,
|
|
205
|
+
},
|
|
206
|
+
default: {
|
|
207
|
+
...fsMock,
|
|
208
|
+
promises: {
|
|
209
|
+
...fsMock.promises,
|
|
210
|
+
readFile: readFileMock,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
'../../lib/cloud-api/storage.js': {
|
|
215
|
+
uploadToStorageBucket: uploadToStorageBucketMock,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const bucket = { name: 'test-bucket' };
|
|
220
|
+
const localPath = '/local/path';
|
|
221
|
+
const gcsPrefix = 'prefix';
|
|
222
|
+
const progressCallback = mock.fn();
|
|
223
|
+
|
|
224
|
+
await deploymentHelpers.uploadDirectory(
|
|
225
|
+
bucket,
|
|
226
|
+
localPath,
|
|
227
|
+
gcsPrefix,
|
|
228
|
+
progressCallback
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
assert.strictEqual(uploadToStorageBucketMock.mock.callCount(), 2);
|
|
232
|
+
|
|
233
|
+
// Verify first upload (root level)
|
|
234
|
+
const call1 = uploadToStorageBucketMock.mock.calls[0];
|
|
235
|
+
assert.ok(call1.arguments[2].endsWith('prefix/file1.txt'));
|
|
236
|
+
|
|
237
|
+
// Verify second upload (subdir level)
|
|
238
|
+
const call2 = uploadToStorageBucketMock.mock.calls[1];
|
|
239
|
+
assert.ok(call2.arguments[2].endsWith('prefix/subdir/file2.txt'));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('makeFileDeploymentMetadata correctly identifies compose file in folder', async () => {
|
|
243
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
244
|
+
fs: fsMock,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const folderPath = '/absolute/path/to/folder';
|
|
248
|
+
|
|
249
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
250
|
+
isDirectory: () => true,
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
fsMock.existsSync.mock.mockImplementation((filePath) => {
|
|
254
|
+
if (filePath.endsWith('compose.yaml')) return true;
|
|
255
|
+
return false;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
259
|
+
|
|
260
|
+
assert.ok(result.composeFilePath.endsWith('compose.yaml'));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('makeFileDeploymentMetadata returns null for composeFilePath if no compose file exists', async () => {
|
|
264
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
265
|
+
fs: fsMock,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const files = ['Dockerfile', 'index.js'];
|
|
269
|
+
|
|
270
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
271
|
+
isDirectory: () => false,
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata(files);
|
|
275
|
+
|
|
276
|
+
assert.strictEqual(result.composeFilePath, null);
|
|
277
|
+
});
|
|
179
278
|
});
|
|
@@ -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
|
});
|