@google-cloud/cloud-run-mcp 1.6.0 → 1.7.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 +1 -1
- package/CONTRIBUTING.md +1 -1
- package/lib/cloud-api/billing.js +85 -0
- package/lib/cloud-api/helpers.js +73 -31
- package/lib/deployment/constants.js +54 -0
- package/lib/deployment/deployer.js +252 -105
- package/lib/deployment/helpers.js +194 -0
- package/lib/deployment/source-processor.js +124 -0
- package/lib/util/archive.js +15 -6
- package/package.json +2 -2
- package/test/local/deployment-helpers.test.js +231 -0
- package/test/local/mcp-server-stdio.test.js +59 -0
- package/test/local/mcp-server-streamable-http.test.js +25 -21
- package/test/local/source-processor.test.js +290 -0
- package/test/local/test-utils.js +51 -0
- package/test/need-gcp/workflows/deployment-workflows.test.js +3 -3
|
@@ -0,0 +1,124 @@
|
|
|
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 path from 'node:path';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import fsp from 'node:fs/promises';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import util from 'node:util';
|
|
22
|
+
import { exec } from 'node:child_process';
|
|
23
|
+
import { TEMP_PATHS, RUNTIMES } from './constants.js';
|
|
24
|
+
import { logAndProgress } from '../util/helpers.js';
|
|
25
|
+
|
|
26
|
+
const execAsync = util.promisify(exec);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Prepares a temporary directory with the source code for deployment.
|
|
30
|
+
* Handles both file paths and content objects.
|
|
31
|
+
*
|
|
32
|
+
* @param {Array<string|{filename: string, content: string|Buffer}>} files - Files to deploy.
|
|
33
|
+
* @param {function(object): void} [progressCallback] - Optional callback for progress updates.
|
|
34
|
+
* @returns {Promise<string>} The path to the prepared temporary directory.
|
|
35
|
+
*/
|
|
36
|
+
export async function prepareSourceDirectory(files, progressCallback) {
|
|
37
|
+
const homeDir = os.homedir();
|
|
38
|
+
const tempBaseDir = path.join(homeDir, TEMP_PATHS.BASE, TEMP_PATHS.SUBDIR);
|
|
39
|
+
const sourceName = path.basename(files[0]);
|
|
40
|
+
|
|
41
|
+
const tempDir = path.join(tempBaseDir, `${sourceName}-${Date.now()}`);
|
|
42
|
+
|
|
43
|
+
// Creates a temporary directory to store the source code.
|
|
44
|
+
await fsp.mkdir(tempDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
// Copies the source code to the temporary directory.
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const stats = await fsp.stat(file);
|
|
49
|
+
if (stats.isDirectory()) {
|
|
50
|
+
// Copy directory contents
|
|
51
|
+
await execAsync(`cp -R "${file}/." "${tempDir}/"`);
|
|
52
|
+
} else {
|
|
53
|
+
const destPath = path.join(tempDir, path.basename(file));
|
|
54
|
+
await fsp.copyFile(file, destPath);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return tempDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Installs Node.js dependencies in the target directory if a package.json exists.
|
|
63
|
+
* Skips if node_modules already exists (assumed pre-installed or copied).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} targetDir - The directory containing the source code.
|
|
66
|
+
*/
|
|
67
|
+
async function installNodeDependencies(targetDir) {
|
|
68
|
+
const nodeModulesPath = path.join(targetDir, 'node_modules');
|
|
69
|
+
|
|
70
|
+
if (fs.existsSync(nodeModulesPath)) {
|
|
71
|
+
console.log(
|
|
72
|
+
`Existing node_modules detected. Skipping dependency installation.`
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check for lock file to decide between install and ci
|
|
78
|
+
const hasLockFile = fs.existsSync(path.join(targetDir, 'package-lock.json'));
|
|
79
|
+
const npmCommand = hasLockFile
|
|
80
|
+
? 'npm ci --omit=dev'
|
|
81
|
+
: 'npm install --omit=dev';
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await execAsync(npmCommand, { cwd: targetDir });
|
|
85
|
+
console.log(`${npmCommand} completed successfully`);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.log(`Dependency installation failed: ${e.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generic wrapper to install dependencies for the detected runtime.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} targetDir - The directory containing the source code.
|
|
95
|
+
* @param {Object} deploymentAttrs - Deployment attributes containing runtime info.
|
|
96
|
+
*/
|
|
97
|
+
export async function installDependencies(targetDir, deploymentAttrs) {
|
|
98
|
+
switch (deploymentAttrs?.runtime) {
|
|
99
|
+
case RUNTIMES.NODEJS:
|
|
100
|
+
await installNodeDependencies(targetDir);
|
|
101
|
+
break;
|
|
102
|
+
default:
|
|
103
|
+
console.log(
|
|
104
|
+
`No specific dependency installation logic for runtime: ${deploymentAttrs?.runtime}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Cleans up the temporary directory.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} dirPath - Path to the directory to remove.
|
|
113
|
+
*/
|
|
114
|
+
export async function cleanupTempDirectory(dirPath) {
|
|
115
|
+
if (!dirPath) return;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (fs.existsSync(dirPath)) {
|
|
119
|
+
await fsp.rm(dirPath, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
} catch (cleanupError) {
|
|
122
|
+
console.error(`Failed to cleanup temp directory ${dirPath}:`, cleanupError);
|
|
123
|
+
}
|
|
124
|
+
}
|
package/lib/util/archive.js
CHANGED
|
@@ -23,26 +23,35 @@ import { logAndProgress } from './helpers.js';
|
|
|
23
23
|
* @param {Array<string|{filename: string, content: Buffer|string}>} files - An array of items to zip.
|
|
24
24
|
* Each item can be a string representing a file/directory path, or an object
|
|
25
25
|
* with `filename` and `content` properties for in-memory files.
|
|
26
|
+
* @param {boolean} [useTarGz] - If true, creates a tar.gz archive instead of a zip.
|
|
26
27
|
* @param {function(object): void} [progressCallback] - Optional callback for progress updates.
|
|
27
28
|
* @returns {Promise<Buffer>} A promise that resolves with a Buffer containing the zip data.
|
|
28
29
|
* @throws {Error} If an input file path is not found, an input item has an invalid format, or an archiver error occurs.
|
|
29
30
|
*/
|
|
30
|
-
export async function zipFiles(files, progressCallback) {
|
|
31
|
+
export async function zipFiles(files, useTarGz, progressCallback) {
|
|
31
32
|
const path = await import('path');
|
|
32
33
|
const fs = await import('fs');
|
|
33
34
|
const archiver = (await import('archiver')).default;
|
|
34
35
|
|
|
35
36
|
return new Promise((resolve, reject) => {
|
|
36
|
-
logAndProgress('Creating in-memory
|
|
37
|
+
logAndProgress('Creating in-memory archive...', progressCallback);
|
|
37
38
|
const chunks = [];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
|
|
40
|
+
let archive;
|
|
41
|
+
if (useTarGz) {
|
|
42
|
+
archive = archiver('tar', {
|
|
43
|
+
gzip: { level: 9 },
|
|
44
|
+
});
|
|
45
|
+
} else {
|
|
46
|
+
archive = archiver('zip', {
|
|
47
|
+
zlib: { level: 9 },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
41
50
|
|
|
42
51
|
archive.on('data', (chunk) => chunks.push(chunk));
|
|
43
52
|
archive.on('end', () => {
|
|
44
53
|
logAndProgress(
|
|
45
|
-
`
|
|
54
|
+
`Archive created successfully. Total size: ${archive.pointer()} bytes`,
|
|
46
55
|
progressCallback
|
|
47
56
|
);
|
|
48
57
|
resolve(Buffer.concat(chunks));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@google-cloud/cloud-run-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cloud Run MCP deployment tool",
|
|
6
6
|
"main": "mcp-server.js",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@google-cloud/cloudbuild": "^5.0.1",
|
|
46
46
|
"@google-cloud/logging": "^11.2.0",
|
|
47
47
|
"@google-cloud/resource-manager": "^6.0.1",
|
|
48
|
-
"@google-cloud/run": "^
|
|
48
|
+
"@google-cloud/run": "^3.1.0",
|
|
49
49
|
"@google-cloud/service-usage": "^4.1.0",
|
|
50
50
|
"@google-cloud/storage": "^7.16.0",
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
@@ -0,0 +1,231 @@
|
|
|
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 { DEPLOYMENT_CONFIG } from '../../lib/deployment/constants.js';
|
|
21
|
+
|
|
22
|
+
describe('Deployment Helpers', () => {
|
|
23
|
+
const fsMock = {
|
|
24
|
+
statSync: mock.fn(),
|
|
25
|
+
existsSync: mock.fn(),
|
|
26
|
+
readFileSync: mock.fn(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test('makeFileDeploymentMetadata correctly identifies Dockerfile in folder', async () => {
|
|
30
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
31
|
+
fs: fsMock,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const folderPath = '/absolute/path/to/folder';
|
|
35
|
+
|
|
36
|
+
// Mock isFolder logic
|
|
37
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
38
|
+
isDirectory: () => true,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Mock Dockerfile check
|
|
42
|
+
fsMock.existsSync.mock.mockImplementation((filePath) => {
|
|
43
|
+
if (filePath.endsWith('Dockerfile') || filePath.endsWith('dockerfile'))
|
|
44
|
+
return true;
|
|
45
|
+
return false;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
49
|
+
|
|
50
|
+
assert.equal(result.hasDockerfile, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('makeFileDeploymentMetadata correctly identifies Node.js project attributes', async () => {
|
|
54
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
55
|
+
fs: fsMock,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const folderPath = '/absolute/path/to/node-app';
|
|
59
|
+
|
|
60
|
+
// Mock isFolder
|
|
61
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
62
|
+
isDirectory: () => true,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// Mock existsSync
|
|
66
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
67
|
+
if (p.endsWith('Dockerfile')) return false;
|
|
68
|
+
if (p.endsWith('package.json')) return true;
|
|
69
|
+
return false;
|
|
70
|
+
});
|
|
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
|
+
const result = deploymentHelpers.makeFileDeploymentMetadata([folderPath]);
|
|
80
|
+
|
|
81
|
+
assert.equal(result.hasDockerfile, false);
|
|
82
|
+
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
|
+
});
|
|
139
|
+
|
|
140
|
+
test('canDeployWithoutBuild returns true for valid Node.js zip deploy', async () => {
|
|
141
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
142
|
+
fs: fsMock,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const metadata = {
|
|
146
|
+
hasDockerfile: false,
|
|
147
|
+
deploymentAttrs: {
|
|
148
|
+
runtime: 'nodejs',
|
|
149
|
+
cmd: ['node'],
|
|
150
|
+
args: ['index.js'],
|
|
151
|
+
baseImage: 'base-image',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('canDeployWithoutBuild returns false if Dockerfile exists', async () => {
|
|
159
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
160
|
+
fs: fsMock,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const metadata = {
|
|
164
|
+
hasDockerfile: true,
|
|
165
|
+
deploymentAttrs: {
|
|
166
|
+
runtime: 'nodejs',
|
|
167
|
+
cmd: ['node'],
|
|
168
|
+
args: ['index.js'],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
assert.equal(deploymentHelpers.canDeployWithoutBuild(metadata), false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('createDirectSourceDeploymentContainer creates correct object', async () => {
|
|
176
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
177
|
+
fs: fsMock,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const input = {
|
|
181
|
+
bucketName: 'test-bucket',
|
|
182
|
+
fileName: 'source.tar.gz',
|
|
183
|
+
deploymentAttrs: {
|
|
184
|
+
cmd: ['node'],
|
|
185
|
+
args: ['server.js'],
|
|
186
|
+
baseImage: 'gcr.io/google-appengine/nodejs',
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result =
|
|
191
|
+
deploymentHelpers.createDirectSourceDeploymentContainer(input);
|
|
192
|
+
|
|
193
|
+
assert.deepEqual(result, {
|
|
194
|
+
image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
|
|
195
|
+
baseImageUri: 'gcr.io/google-appengine/nodejs',
|
|
196
|
+
sourceCode: {
|
|
197
|
+
cloudStorageSource: {
|
|
198
|
+
bucket: 'test-bucket',
|
|
199
|
+
object: 'source.tar.gz',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
command: ['node'],
|
|
203
|
+
args: ['server.js'],
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('makeFileDeploymentMetadata handles invalid package.json gracefully', async () => {
|
|
208
|
+
const deploymentHelpers = await esmock('../../lib/deployment/helpers.js', {
|
|
209
|
+
fs: fsMock,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const folderPath = '/absolute/path/to/bad-json-app';
|
|
213
|
+
|
|
214
|
+
fsMock.statSync.mock.mockImplementation(() => ({
|
|
215
|
+
isDirectory: () => true,
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
fsMock.existsSync.mock.mockImplementation((p) => {
|
|
219
|
+
if (p.endsWith('package.json')) return true;
|
|
220
|
+
return false;
|
|
221
|
+
});
|
|
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
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test, describe, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { waitForString } from './test-utils.js';
|
|
5
|
+
|
|
6
|
+
describe('MCP Server stdio startup', () => {
|
|
7
|
+
let serverProcess;
|
|
8
|
+
let stderr = '';
|
|
9
|
+
const stdioMsg = 'Cloud Run MCP server stdio transport connected';
|
|
10
|
+
|
|
11
|
+
describe('when GCP_STDIO=true', () => {
|
|
12
|
+
before(async () => {
|
|
13
|
+
stderr = '';
|
|
14
|
+
serverProcess = spawn('node', ['mcp-server.js'], {
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
env: { ...process.env, GCP_STDIO: 'true' },
|
|
17
|
+
});
|
|
18
|
+
stderr = await waitForString(serverProcess.stderr, stdioMsg);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
after(() => {
|
|
22
|
+
if (serverProcess) {
|
|
23
|
+
serverProcess.kill();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('should start in stdio mode', () => {
|
|
28
|
+
assert.ok(stderr.includes(stdioMsg));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('when GCP_STDIO=false', () => {
|
|
33
|
+
before(async () => {
|
|
34
|
+
stderr = '';
|
|
35
|
+
const env = { ...process.env };
|
|
36
|
+
env.GCP_STDIO = 'false';
|
|
37
|
+
serverProcess = spawn('node', ['mcp-server.js'], {
|
|
38
|
+
cwd: process.cwd(),
|
|
39
|
+
env: env,
|
|
40
|
+
});
|
|
41
|
+
const stderrChunks = [];
|
|
42
|
+
serverProcess.stderr.on('data', (chunk) => {
|
|
43
|
+
stderrChunks.push(chunk);
|
|
44
|
+
});
|
|
45
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
46
|
+
stderr = Buffer.concat(stderrChunks).toString();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
after(() => {
|
|
50
|
+
if (serverProcess) {
|
|
51
|
+
serverProcess.kill();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('should not start in stdio mode', () => {
|
|
56
|
+
assert.ok(!stderr.includes(stdioMsg));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -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 { waitForString } from './test-utils.js';
|
|
6
7
|
|
|
7
8
|
class MCPClient {
|
|
8
9
|
client = null;
|
|
@@ -29,29 +30,32 @@ class MCPClient {
|
|
|
29
30
|
describe('MCP Server in Streamble HTTP mode', () => {
|
|
30
31
|
let client;
|
|
31
32
|
let serverProcess;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
let stdout = '';
|
|
34
|
+
const httpMsg = 'Cloud Run MCP server listening on port 3000';
|
|
35
|
+
|
|
36
|
+
describe('when GCP_STDIO=false', () => {
|
|
37
|
+
before(async () => {
|
|
38
|
+
stdout = '';
|
|
39
|
+
// Start MCP server as a child process
|
|
40
|
+
serverProcess = spawn('node', ['mcp-server.js'], {
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
env: { ...process.env, GCP_STDIO: 'false' },
|
|
43
|
+
});
|
|
44
|
+
stdout = await waitForString(serverProcess.stdout, httpMsg);
|
|
45
|
+
|
|
46
|
+
client = new MCPClient('http-server');
|
|
39
47
|
});
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
after(async () => {
|
|
48
|
-
await client.cleanup();
|
|
49
|
-
if (serverProcess) {
|
|
50
|
-
serverProcess.kill();
|
|
51
|
-
}
|
|
52
|
-
});
|
|
49
|
+
after(async () => {
|
|
50
|
+
await client.cleanup();
|
|
51
|
+
if (serverProcess) {
|
|
52
|
+
serverProcess.kill();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
test('should start an HTTP server', async () => {
|
|
57
|
+
await client.connectToServer('http://localhost:3000/mcp');
|
|
58
|
+
assert.ok(stdout.includes(httpMsg));
|
|
59
|
+
});
|
|
56
60
|
});
|
|
57
61
|
});
|