mlgym-deploy 3.0.8 → 3.2.2
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/index.js +515 -2
- package/mcp-docker-compose-patch.js +189 -0
- package/package.json +2 -2
- package/mlgym-deploy-2.10.0.tgz +0 -0
package/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import crypto from 'crypto';
|
|
|
17
17
|
const execAsync = promisify(exec);
|
|
18
18
|
|
|
19
19
|
// Current version of this MCP server - INCREMENT FOR WORKFLOW FIXES
|
|
20
|
-
const CURRENT_VERSION = '3.
|
|
20
|
+
const CURRENT_VERSION = '3.2.2'; // Added automatic validation and fixing for docker-compose and Dockerfile
|
|
21
21
|
const PACKAGE_NAME = 'mlgym-deploy';
|
|
22
22
|
|
|
23
23
|
// Debug logging configuration - ENABLED BY DEFAULT
|
|
@@ -963,6 +963,254 @@ async function smartDeploy(args) {
|
|
|
963
963
|
}
|
|
964
964
|
}
|
|
965
965
|
|
|
966
|
+
// Deployment Strategy Detection (matches CLI logic)
|
|
967
|
+
async function detectDeploymentStrategy(projectPath) {
|
|
968
|
+
const dockerfilePath = path.join(projectPath, 'Dockerfile');
|
|
969
|
+
const composePathYML = path.join(projectPath, 'docker-compose.yml');
|
|
970
|
+
const composePathYAML = path.join(projectPath, 'docker-compose.yaml');
|
|
971
|
+
|
|
972
|
+
const hasDockerfile = await fileExists(dockerfilePath);
|
|
973
|
+
const hasComposeYML = await fileExists(composePathYML);
|
|
974
|
+
const hasComposeYAML = await fileExists(composePathYAML);
|
|
975
|
+
const hasCompose = hasComposeYML || hasComposeYAML;
|
|
976
|
+
const composePath = hasComposeYML ? composePathYML : composePathYAML;
|
|
977
|
+
|
|
978
|
+
log.info(`MCP >>> [detectDeploymentStrategy] Dockerfile: ${hasDockerfile}, docker-compose: ${hasCompose}`);
|
|
979
|
+
|
|
980
|
+
// Case 1: Only Dockerfile
|
|
981
|
+
if (hasDockerfile && !hasCompose) {
|
|
982
|
+
log.info('MCP >>> [detectDeploymentStrategy] Strategy: dockerfile (Dockerfile only)');
|
|
983
|
+
return { type: 'dockerfile', reason: 'Dockerfile only' };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Case 2: Only docker-compose
|
|
987
|
+
if (!hasDockerfile && hasCompose) {
|
|
988
|
+
log.info('MCP >>> [detectDeploymentStrategy] Strategy: docker-compose (compose only)');
|
|
989
|
+
return { type: 'docker-compose', reason: 'docker-compose.yml only' };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Case 3: Both exist - analyze docker-compose to decide
|
|
993
|
+
if (hasDockerfile && hasCompose) {
|
|
994
|
+
log.info('MCP >>> [detectDeploymentStrategy] Both files found, analyzing compose...');
|
|
995
|
+
|
|
996
|
+
try {
|
|
997
|
+
const { webServiceCount, usesLocalDockerfile, totalServiceCount } = await analyzeComposeFile(composePath);
|
|
998
|
+
|
|
999
|
+
log.info(`MCP >>> [analyzeCompose] Total services: ${totalServiceCount}, Web services: ${webServiceCount}, Uses local Dockerfile: ${usesLocalDockerfile}`);
|
|
1000
|
+
|
|
1001
|
+
// SIMPLE: Only 1 web service, no other services, uses local Dockerfile
|
|
1002
|
+
// → docker-compose is just convenience wrapper
|
|
1003
|
+
if (totalServiceCount === 1 && webServiceCount === 1 && usesLocalDockerfile) {
|
|
1004
|
+
log.info('MCP >>> [detectDeploymentStrategy] Strategy: dockerfile (single web service)');
|
|
1005
|
+
return { type: 'dockerfile', reason: 'docker-compose.yml only has web service (convenience wrapper for Dockerfile)' };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// COMPLEX: Multiple services (web + database, etc.)
|
|
1009
|
+
// → MUST use docker-compose for orchestration
|
|
1010
|
+
log.info('MCP >>> [detectDeploymentStrategy] Strategy: docker-compose (multi-service)');
|
|
1011
|
+
return { type: 'docker-compose', reason: 'Application requires multiple services (web + database/cache/etc)' };
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
log.error('MCP >>> [detectDeploymentStrategy] Analysis failed, defaulting to docker-compose:', err.message);
|
|
1014
|
+
return { type: 'docker-compose', reason: 'Multiple deployment files found' };
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Case 4: No deployment files
|
|
1019
|
+
log.info('MCP >>> [detectDeploymentStrategy] Strategy: none (no deployment files)');
|
|
1020
|
+
return { type: 'none', reason: 'No Dockerfile or docker-compose.yml found' };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Validate and potentially fix docker-compose.yml for Coolify compliance
|
|
1024
|
+
function validateAndFixDockerCompose(content) {
|
|
1025
|
+
const lines = content.split('\n');
|
|
1026
|
+
const issues = [];
|
|
1027
|
+
const fixes = [];
|
|
1028
|
+
let fixed = [...lines];
|
|
1029
|
+
let inServicesSection = false;
|
|
1030
|
+
let inPortsSection = false;
|
|
1031
|
+
let currentService = '';
|
|
1032
|
+
let currentIndent = '';
|
|
1033
|
+
|
|
1034
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1035
|
+
const line = lines[i];
|
|
1036
|
+
const trimmed = line.trim();
|
|
1037
|
+
|
|
1038
|
+
// Detect services section
|
|
1039
|
+
if (trimmed === 'services:') {
|
|
1040
|
+
inServicesSection = true;
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (!inServicesSection) continue;
|
|
1045
|
+
|
|
1046
|
+
// Check for service definition
|
|
1047
|
+
if (trimmed && !line.startsWith(' ') && !line.startsWith('\t')) {
|
|
1048
|
+
inServicesSection = false;
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// New service
|
|
1053
|
+
if ((line.startsWith(' ') || line.startsWith('\t')) &&
|
|
1054
|
+
trimmed.endsWith(':') &&
|
|
1055
|
+
!trimmed.startsWith('-') &&
|
|
1056
|
+
!['ports:', 'expose:', 'environment:', 'volumes:', 'networks:'].includes(trimmed)) {
|
|
1057
|
+
currentService = trimmed.slice(0, -1);
|
|
1058
|
+
currentIndent = line.match(/^(\s*)/)[1];
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Check for ports section
|
|
1062
|
+
if (trimmed === 'ports:') {
|
|
1063
|
+
inPortsSection = true;
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Process port entries
|
|
1068
|
+
if (inPortsSection && trimmed.startsWith('- ')) {
|
|
1069
|
+
const portEntry = trimmed.slice(2).replace(/['"]/g, '');
|
|
1070
|
+
|
|
1071
|
+
if (portEntry.includes(':')) {
|
|
1072
|
+
const [external, internal] = portEntry.split(':').map(p => p.trim());
|
|
1073
|
+
const webPorts = ['80', '8080', '3000', '4000', '5000', '8000', '9000'];
|
|
1074
|
+
|
|
1075
|
+
if (webPorts.includes(internal)) {
|
|
1076
|
+
issues.push({
|
|
1077
|
+
line: i + 1,
|
|
1078
|
+
service: currentService,
|
|
1079
|
+
issue: `Port mapping "${external}:${internal}" not allowed`,
|
|
1080
|
+
fix: 'Use port "80" only'
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// Fix the line
|
|
1084
|
+
fixed[i] = line.replace(trimmed, '- "80"');
|
|
1085
|
+
fixes.push(`Fixed ${currentService}: ${external}:${internal} → 80`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
} else if (inPortsSection && !trimmed.startsWith('-')) {
|
|
1089
|
+
inPortsSection = false;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
return {
|
|
1094
|
+
isValid: issues.length === 0,
|
|
1095
|
+
issues,
|
|
1096
|
+
fixes,
|
|
1097
|
+
fixedContent: fixed.join('\n'),
|
|
1098
|
+
hasChanges: fixes.length > 0
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Validate Dockerfile for Coolify compliance
|
|
1103
|
+
function validateDockerfile(content) {
|
|
1104
|
+
const lines = content.split('\n');
|
|
1105
|
+
const issues = [];
|
|
1106
|
+
let hasExpose80 = false;
|
|
1107
|
+
|
|
1108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1109
|
+
const trimmed = lines[i].trim().toUpperCase();
|
|
1110
|
+
|
|
1111
|
+
if (trimmed.startsWith('EXPOSE')) {
|
|
1112
|
+
// Check if it exposes port 80
|
|
1113
|
+
if (trimmed.includes('80') && !trimmed.includes('8080') && !trimmed.includes('8443')) {
|
|
1114
|
+
hasExpose80 = true;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (!hasExpose80) {
|
|
1120
|
+
issues.push({
|
|
1121
|
+
issue: 'Dockerfile does not EXPOSE port 80',
|
|
1122
|
+
fix: 'Add "EXPOSE 80" to your Dockerfile'
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return {
|
|
1127
|
+
isValid: issues.length === 0,
|
|
1128
|
+
issues
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Analyze docker-compose.yml to determine deployment strategy
|
|
1133
|
+
async function analyzeComposeFile(composePath) {
|
|
1134
|
+
const content = await fs.readFile(composePath, 'utf-8');
|
|
1135
|
+
const lines = content.split('\n');
|
|
1136
|
+
|
|
1137
|
+
let webServiceCount = 0;
|
|
1138
|
+
let totalServiceCount = 0;
|
|
1139
|
+
let usesLocalDockerfile = false;
|
|
1140
|
+
let inServicesSection = false;
|
|
1141
|
+
let currentService = '';
|
|
1142
|
+
let currentServiceIsWeb = false;
|
|
1143
|
+
|
|
1144
|
+
for (const line of lines) {
|
|
1145
|
+
const trimmed = line.trim();
|
|
1146
|
+
|
|
1147
|
+
// Detect services section
|
|
1148
|
+
if (trimmed.startsWith('services:')) {
|
|
1149
|
+
inServicesSection = true;
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (!inServicesSection) continue;
|
|
1154
|
+
|
|
1155
|
+
// End of services section
|
|
1156
|
+
if (trimmed && !line.startsWith(' ') && !line.startsWith('\t')) {
|
|
1157
|
+
inServicesSection = false;
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// New service definition (at 2-space or 1-tab indentation level)
|
|
1162
|
+
if ((line.startsWith(' ') || line.startsWith('\t')) &&
|
|
1163
|
+
trimmed.includes(':') &&
|
|
1164
|
+
!trimmed.startsWith('-') &&
|
|
1165
|
+
!trimmed.includes('image:') &&
|
|
1166
|
+
!trimmed.includes('build:') &&
|
|
1167
|
+
!trimmed.includes('ports:') &&
|
|
1168
|
+
!trimmed.includes('expose:')) {
|
|
1169
|
+
|
|
1170
|
+
if (currentService) {
|
|
1171
|
+
totalServiceCount++;
|
|
1172
|
+
if (currentServiceIsWeb) {
|
|
1173
|
+
webServiceCount++;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
currentService = trimmed.split(':')[0].trim();
|
|
1178
|
+
currentServiceIsWeb = false;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Check if current service exposes web ports
|
|
1183
|
+
if (trimmed.startsWith('ports:') || trimmed.startsWith('expose:')) {
|
|
1184
|
+
currentServiceIsWeb = true;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Check if service uses local Dockerfile
|
|
1188
|
+
if (trimmed === 'build: .' || trimmed === 'build: ./') {
|
|
1189
|
+
usesLocalDockerfile = true;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Count last service
|
|
1194
|
+
if (currentService) {
|
|
1195
|
+
totalServiceCount++;
|
|
1196
|
+
if (currentServiceIsWeb) {
|
|
1197
|
+
webServiceCount++;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return { webServiceCount, usesLocalDockerfile, totalServiceCount };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Helper to check if file exists
|
|
1205
|
+
async function fileExists(filePath) {
|
|
1206
|
+
try {
|
|
1207
|
+
await fs.access(filePath);
|
|
1208
|
+
return true;
|
|
1209
|
+
} catch {
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
966
1214
|
// Initialize Project (requires authentication)
|
|
967
1215
|
async function initProject(args) {
|
|
968
1216
|
let { name, description, enable_deployment = true, hostname, local_path = '.' } = args;
|
|
@@ -1022,6 +1270,17 @@ async function initProject(args) {
|
|
|
1022
1270
|
};
|
|
1023
1271
|
|
|
1024
1272
|
if (enable_deployment) {
|
|
1273
|
+
// Detect deployment strategy from local directory (like CLI does)
|
|
1274
|
+
const absolutePath = path.resolve(local_path);
|
|
1275
|
+
log.info(`MCP >>> [initProject] Detecting deployment strategy in: ${absolutePath}`);
|
|
1276
|
+
|
|
1277
|
+
const strategy = await detectDeploymentStrategy(absolutePath);
|
|
1278
|
+
log.info(`MCP >>> [initProject] Detected strategy: ${strategy.type} - ${strategy.reason}`);
|
|
1279
|
+
console.error(`🔍 Detected deployment strategy: ${strategy.type}`);
|
|
1280
|
+
if (strategy.reason) {
|
|
1281
|
+
console.error(` Reason: ${strategy.reason}`);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1025
1284
|
// Generate a secure webhook secret for deployments
|
|
1026
1285
|
const webhookSecret = Array.from(
|
|
1027
1286
|
crypto.getRandomValues(new Uint8Array(32)),
|
|
@@ -1029,10 +1288,40 @@ async function initProject(args) {
|
|
|
1029
1288
|
).join('');
|
|
1030
1289
|
|
|
1031
1290
|
// Use FLAT structure exactly like CLI does - no nested deployment_info
|
|
1291
|
+
// CRITICAL: Send deployment_type so backend doesn't try to detect from empty repo
|
|
1032
1292
|
projectData.enable_deployment = true;
|
|
1293
|
+
projectData.deployment_type = strategy.type; // ← NEW: Send detected type!
|
|
1033
1294
|
projectData.webhook_secret = webhookSecret;
|
|
1034
1295
|
projectData.hostname = hostname;
|
|
1035
1296
|
projectData.local_path = local_path;
|
|
1297
|
+
|
|
1298
|
+
// Read docker-compose content if using docker-compose strategy (v3.2.1+)
|
|
1299
|
+
if (strategy.type === 'docker-compose') {
|
|
1300
|
+
const composeFiles = ['docker-compose.yml', 'docker-compose.yaml'];
|
|
1301
|
+
let composeContent = null;
|
|
1302
|
+
|
|
1303
|
+
for (const filename of composeFiles) {
|
|
1304
|
+
const composePath = path.join(local_path, filename);
|
|
1305
|
+
if (fs.existsSync(composePath)) {
|
|
1306
|
+
try {
|
|
1307
|
+
composeContent = fs.readFileSync(composePath, 'utf8');
|
|
1308
|
+
log.info(`MCP >>> [initProject] Read ${filename}: ${composeContent.length} bytes`);
|
|
1309
|
+
break;
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
log.error(`MCP >>> [initProject] Error reading ${filename}:`, err.message);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (composeContent) {
|
|
1317
|
+
projectData.docker_compose_content = composeContent;
|
|
1318
|
+
log.info(`MCP >>> [initProject] Sending docker-compose content: ${composeContent.length} bytes`);
|
|
1319
|
+
} else {
|
|
1320
|
+
log.warn('MCP >>> [initProject] docker-compose strategy but no compose file found!');
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
log.info(`MCP >>> [initProject] Sending deployment_type: ${strategy.type} to backend`);
|
|
1036
1325
|
}
|
|
1037
1326
|
|
|
1038
1327
|
const result = await apiRequest('POST', '/api/v1/projects', projectData, true);
|
|
@@ -1718,6 +2007,83 @@ async function deployProject(args) {
|
|
|
1718
2007
|
log.success('MCP >>> STEP 4: Project preparation complete');
|
|
1719
2008
|
log.debug('MCP >>> Preparation actions:', prepResult);
|
|
1720
2009
|
|
|
2010
|
+
// Step 4.5: Validate and fix deployment files for Coolify compliance
|
|
2011
|
+
log.info('MCP >>> STEP 4.5: Validating deployment configuration...');
|
|
2012
|
+
const strategy = detectDeploymentStrategy(local_path);
|
|
2013
|
+
|
|
2014
|
+
if (strategy.type === 'docker-compose') {
|
|
2015
|
+
const composePath = path.join(local_path, 'docker-compose.yml');
|
|
2016
|
+
const composePathYAML = path.join(local_path, 'docker-compose.yaml');
|
|
2017
|
+
const actualPath = fs.existsSync(composePath) ? composePath : composePathYAML;
|
|
2018
|
+
|
|
2019
|
+
if (fs.existsSync(actualPath)) {
|
|
2020
|
+
const content = fs.readFileSync(actualPath, 'utf8');
|
|
2021
|
+
const validation = validateAndFixDockerCompose(content);
|
|
2022
|
+
|
|
2023
|
+
if (!validation.isValid) {
|
|
2024
|
+
log.warning('MCP >>> Docker-compose validation issues found:');
|
|
2025
|
+
validation.issues.forEach(issue => {
|
|
2026
|
+
log.warning(` - Line ${issue.line}: ${issue.issue} (${issue.fix})`);
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
// Auto-fix the issues
|
|
2030
|
+
log.info('MCP >>> Auto-fixing docker-compose.yml port mappings...');
|
|
2031
|
+
fs.writeFileSync(actualPath, validation.fixedContent);
|
|
2032
|
+
|
|
2033
|
+
// Create backup
|
|
2034
|
+
const backupPath = actualPath + '.backup';
|
|
2035
|
+
fs.writeFileSync(backupPath, content);
|
|
2036
|
+
log.info(`MCP >>> Created backup at ${backupPath}`);
|
|
2037
|
+
|
|
2038
|
+
log.success('MCP >>> Fixed docker-compose.yml:');
|
|
2039
|
+
validation.fixes.forEach(fix => log.success(` - ${fix}`));
|
|
2040
|
+
} else {
|
|
2041
|
+
log.success('MCP >>> Docker-compose.yml is Coolify compliant');
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
} else if (strategy.type === 'dockerfile') {
|
|
2045
|
+
const dockerfilePath = path.join(local_path, 'Dockerfile');
|
|
2046
|
+
|
|
2047
|
+
if (fs.existsSync(dockerfilePath)) {
|
|
2048
|
+
const content = fs.readFileSync(dockerfilePath, 'utf8');
|
|
2049
|
+
const validation = validateDockerfile(content);
|
|
2050
|
+
|
|
2051
|
+
if (!validation.isValid) {
|
|
2052
|
+
log.warning('MCP >>> Dockerfile validation issues:');
|
|
2053
|
+
validation.issues.forEach(issue => {
|
|
2054
|
+
log.warning(` - ${issue.issue}`);
|
|
2055
|
+
log.info(` Fix: ${issue.fix}`);
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
// For Dockerfile, we'll add EXPOSE 80 if missing
|
|
2059
|
+
if (!content.includes('EXPOSE 80')) {
|
|
2060
|
+
log.info('MCP >>> Auto-fixing Dockerfile: adding EXPOSE 80...');
|
|
2061
|
+
const lines = content.split('\n');
|
|
2062
|
+
|
|
2063
|
+
// Find the last FROM or WORKDIR line to add EXPOSE after it
|
|
2064
|
+
let insertIndex = lines.length - 1;
|
|
2065
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2066
|
+
if (lines[i].trim().startsWith('WORKDIR') || lines[i].trim().startsWith('FROM')) {
|
|
2067
|
+
insertIndex = i + 1;
|
|
2068
|
+
break;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
lines.splice(insertIndex, 0, '', 'EXPOSE 80');
|
|
2073
|
+
const fixedContent = lines.join('\n');
|
|
2074
|
+
|
|
2075
|
+
// Create backup
|
|
2076
|
+
fs.writeFileSync(dockerfilePath + '.backup', content);
|
|
2077
|
+
fs.writeFileSync(dockerfilePath, fixedContent);
|
|
2078
|
+
|
|
2079
|
+
log.success('MCP >>> Fixed Dockerfile: added EXPOSE 80');
|
|
2080
|
+
}
|
|
2081
|
+
} else {
|
|
2082
|
+
log.success('MCP >>> Dockerfile is Coolify compliant');
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
1721
2087
|
// Step 5: Create GitLab project and deploy
|
|
1722
2088
|
log.info('MCP >>> STEP 5: Creating GitLab project and deploying...');
|
|
1723
2089
|
log.debug('MCP >>> Project params:', { name: project_name, hostname: hostname || project_name, local_path });
|
|
@@ -1830,6 +2196,121 @@ async function getStatus(args) {
|
|
|
1830
2196
|
}
|
|
1831
2197
|
}
|
|
1832
2198
|
|
|
2199
|
+
// Get deployment logs for a project
|
|
2200
|
+
async function getDeploymentLogs(args) {
|
|
2201
|
+
const local_path = args.local_path || '.';
|
|
2202
|
+
const depth = args.depth || 1;
|
|
2203
|
+
const project_name = args.project_name;
|
|
2204
|
+
|
|
2205
|
+
log.info(`MCP >>> [getDeploymentLogs] Getting logs for project: ${project_name || 'auto-detect'}, depth: ${depth}`);
|
|
2206
|
+
|
|
2207
|
+
try {
|
|
2208
|
+
// Check authentication
|
|
2209
|
+
const auth = await loadAuth();
|
|
2210
|
+
if (!auth.token) {
|
|
2211
|
+
throw new Error('Authentication required. Please deploy or login first.');
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// Determine project name
|
|
2215
|
+
let projectName = project_name;
|
|
2216
|
+
if (!projectName) {
|
|
2217
|
+
// Try to get from git remote
|
|
2218
|
+
const projectStatus = await checkExistingProject(local_path);
|
|
2219
|
+
if (!projectStatus.exists) {
|
|
2220
|
+
throw new Error('Project not found. Please specify project_name or run from a project directory with git remote.');
|
|
2221
|
+
}
|
|
2222
|
+
projectName = projectStatus.name;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
log.info(`MCP >>> [getDeploymentLogs] Detected project name: ${projectName}`);
|
|
2226
|
+
|
|
2227
|
+
// Get project details from backend to get Coolify app UUID
|
|
2228
|
+
const projectsResult = await apiRequest('GET', '/api/v1/projects', null, true);
|
|
2229
|
+
if (!projectsResult.success) {
|
|
2230
|
+
throw new Error(`Failed to get projects: ${projectsResult.error}`);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
const project = projectsResult.data.projects?.find(p => p.name === projectName);
|
|
2234
|
+
if (!project) {
|
|
2235
|
+
throw new Error(`Project "${projectName}" not found in your account`);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
if (!project.coolify_app_uuid) {
|
|
2239
|
+
throw new Error(`Project "${projectName}" does not have deployment enabled`);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
const appUUID = project.coolify_app_uuid;
|
|
2243
|
+
log.info(`MCP >>> [getDeploymentLogs] Found Coolify app UUID: ${appUUID}`);
|
|
2244
|
+
|
|
2245
|
+
// Call User Agent API to get deployment logs
|
|
2246
|
+
const userAgentURL = 'http://coolify.eu.ezb.net:9000';
|
|
2247
|
+
const endpoint = `${userAgentURL}/applications/${appUUID}/deployment-logs?depth=${depth}`;
|
|
2248
|
+
|
|
2249
|
+
log.info(`MCP >>> [getDeploymentLogs] Calling User Agent: ${endpoint}`);
|
|
2250
|
+
|
|
2251
|
+
const response = await axios.get(endpoint, {
|
|
2252
|
+
headers: {
|
|
2253
|
+
'Authorization': 'Bearer 1|Jkztb5qPptwRKgtocfbT2TLjp8WGJG1SkPE4DzLt4c5d600f',
|
|
2254
|
+
'Content-Type': 'application/json'
|
|
2255
|
+
}
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
const logsData = response.data;
|
|
2259
|
+
log.info(`MCP >>> [getDeploymentLogs] Retrieved ${logsData.count} deployment(s)`);
|
|
2260
|
+
|
|
2261
|
+
// Format the response
|
|
2262
|
+
if (logsData.count === 0) {
|
|
2263
|
+
return {
|
|
2264
|
+
content: [{
|
|
2265
|
+
type: 'text',
|
|
2266
|
+
text: JSON.stringify({
|
|
2267
|
+
status: 'ok',
|
|
2268
|
+
project_name: projectName,
|
|
2269
|
+
app_uuid: appUUID,
|
|
2270
|
+
count: 0,
|
|
2271
|
+
message: 'No deployment logs found for this application. To create a deployment, push code to GitLab.'
|
|
2272
|
+
}, null, 2)
|
|
2273
|
+
}]
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
// Format deployments with readable timestamps
|
|
2278
|
+
const formattedDeployments = logsData.deployments.map(dep => ({
|
|
2279
|
+
id: dep.id,
|
|
2280
|
+
status: dep.status,
|
|
2281
|
+
created_at: new Date(dep.created_at).toISOString().replace('T', ' ').substring(0, 19),
|
|
2282
|
+
updated_at: new Date(dep.updated_at).toISOString().replace('T', ' ').substring(0, 19),
|
|
2283
|
+
output: dep.output || 'No logs available'
|
|
2284
|
+
}));
|
|
2285
|
+
|
|
2286
|
+
return {
|
|
2287
|
+
content: [{
|
|
2288
|
+
type: 'text',
|
|
2289
|
+
text: JSON.stringify({
|
|
2290
|
+
status: 'ok',
|
|
2291
|
+
project_name: projectName,
|
|
2292
|
+
app_uuid: appUUID,
|
|
2293
|
+
count: logsData.count,
|
|
2294
|
+
deployments: formattedDeployments
|
|
2295
|
+
}, null, 2)
|
|
2296
|
+
}]
|
|
2297
|
+
};
|
|
2298
|
+
|
|
2299
|
+
} catch (error) {
|
|
2300
|
+
log.error(`MCP >>> [getDeploymentLogs] Error: ${error.message}`);
|
|
2301
|
+
return {
|
|
2302
|
+
content: [{
|
|
2303
|
+
type: 'text',
|
|
2304
|
+
text: JSON.stringify({
|
|
2305
|
+
status: 'error',
|
|
2306
|
+
error: error.message,
|
|
2307
|
+
hint: 'Make sure the project exists and has deployment enabled'
|
|
2308
|
+
}, null, 2)
|
|
2309
|
+
}]
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
1833
2314
|
// Create the MCP server
|
|
1834
2315
|
const server = new Server(
|
|
1835
2316
|
{
|
|
@@ -1921,6 +2402,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1921
2402
|
}
|
|
1922
2403
|
}
|
|
1923
2404
|
},
|
|
2405
|
+
{
|
|
2406
|
+
name: 'mlgym_deploy_logs',
|
|
2407
|
+
description: 'View deployment history and logs for a project. Shows the last N deployments with their status, timestamps, and build logs.',
|
|
2408
|
+
inputSchema: {
|
|
2409
|
+
type: 'object',
|
|
2410
|
+
properties: {
|
|
2411
|
+
project_name: {
|
|
2412
|
+
type: 'string',
|
|
2413
|
+
description: 'Project name (optional if in project directory with git remote)',
|
|
2414
|
+
pattern: '^[a-z0-9][a-z0-9-]*[a-z0-9]$',
|
|
2415
|
+
minLength: 3
|
|
2416
|
+
},
|
|
2417
|
+
depth: {
|
|
2418
|
+
type: 'number',
|
|
2419
|
+
description: 'Number of deployments to retrieve (1-10, default: 1)',
|
|
2420
|
+
minimum: 1,
|
|
2421
|
+
maximum: 10,
|
|
2422
|
+
default: 1
|
|
2423
|
+
},
|
|
2424
|
+
local_path: {
|
|
2425
|
+
type: 'string',
|
|
2426
|
+
description: 'Local project directory path (optional, defaults to current directory)',
|
|
2427
|
+
default: '.'
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
},
|
|
1924
2432
|
// ========== LEGACY TOOLS (Re-enabled for specific use cases) ==========
|
|
1925
2433
|
{
|
|
1926
2434
|
name: 'mlgym_user_create',
|
|
@@ -1998,6 +2506,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1998
2506
|
result = await getStatus(args);
|
|
1999
2507
|
break;
|
|
2000
2508
|
|
|
2509
|
+
case 'mlgym_deploy_logs':
|
|
2510
|
+
log.info(`Retrieving deployment logs...`);
|
|
2511
|
+
result = await getDeploymentLogs(args);
|
|
2512
|
+
break;
|
|
2513
|
+
|
|
2001
2514
|
case 'mlgym_user_create':
|
|
2002
2515
|
log.info(`Creating user account...`);
|
|
2003
2516
|
result = await createUser(args);
|
|
@@ -2009,7 +2522,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2009
2522
|
break;
|
|
2010
2523
|
|
|
2011
2524
|
default:
|
|
2012
|
-
throw new Error(`Unknown tool: ${name}. Available tools: mlgym_deploy, mlgym_status, mlgym_user_create, mlgym_auth_login`);
|
|
2525
|
+
throw new Error(`Unknown tool: ${name}. Available tools: mlgym_deploy, mlgym_status, mlgym_deploy_logs, mlgym_user_create, mlgym_auth_login`);
|
|
2013
2526
|
}
|
|
2014
2527
|
|
|
2015
2528
|
const duration = Date.now() - startTime;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// MCP Patch: Add docker-compose.yml content reading (matching CLI v2.0.14+)
|
|
2
|
+
//
|
|
3
|
+
// This patch ensures MCP reads docker-compose.yml content and sends it to the backend
|
|
4
|
+
// just like the CLI does, so the backend can store it in Coolify's database.
|
|
5
|
+
|
|
6
|
+
// Add this function after detectDeploymentStrategy function:
|
|
7
|
+
function readDockerComposeContent(projectPath) {
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
// Check both .yml and .yaml extensions
|
|
12
|
+
const composeFiles = ['docker-compose.yml', 'docker-compose.yaml'];
|
|
13
|
+
|
|
14
|
+
for (const filename of composeFiles) {
|
|
15
|
+
const composePath = path.join(projectPath, filename);
|
|
16
|
+
if (fs.existsSync(composePath)) {
|
|
17
|
+
try {
|
|
18
|
+
const content = fs.readFileSync(composePath, 'utf8');
|
|
19
|
+
console.log(`[MCP] Read ${filename}: ${content.length} bytes`);
|
|
20
|
+
return content;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error(`[MCP] Error reading ${filename}:`, err.message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// MODIFICATION NEEDED in initProject function (around line 1180-1190):
|
|
31
|
+
//
|
|
32
|
+
// FIND THIS BLOCK:
|
|
33
|
+
// projectData.deployment_type = strategy.type; // ← NEW: Send detected type!
|
|
34
|
+
// log.info(`MCP >>> [initProject] Sending deployment_type: ${strategy.type} to backend`);
|
|
35
|
+
//
|
|
36
|
+
// ADD AFTER IT:
|
|
37
|
+
// // Read docker-compose content if using docker-compose strategy
|
|
38
|
+
// if (strategy.type === 'docker-compose') {
|
|
39
|
+
// const composeContent = readDockerComposeContent(projectPath);
|
|
40
|
+
// if (composeContent) {
|
|
41
|
+
// projectData.docker_compose_content = composeContent;
|
|
42
|
+
// log.info(`MCP >>> [initProject] Sending docker-compose content: ${composeContent.length} bytes`);
|
|
43
|
+
// } else {
|
|
44
|
+
// log.warn('MCP >>> [initProject] docker-compose strategy but no compose file found!');
|
|
45
|
+
// }
|
|
46
|
+
// }
|
|
47
|
+
|
|
48
|
+
// The complete section should look like:
|
|
49
|
+
/*
|
|
50
|
+
// CRITICAL: Send deployment_type so backend doesn't try to detect from empty repo
|
|
51
|
+
log.info(`MCP >>> [initProject] Using strategy: ${JSON.stringify(strategy)}`);
|
|
52
|
+
projectData.deployment_type = strategy.type; // ← NEW: Send detected type!
|
|
53
|
+
|
|
54
|
+
// Read docker-compose content if using docker-compose strategy (v3.2.1+)
|
|
55
|
+
if (strategy.type === 'docker-compose') {
|
|
56
|
+
const composeContent = readDockerComposeContent(projectPath);
|
|
57
|
+
if (composeContent) {
|
|
58
|
+
projectData.docker_compose_content = composeContent;
|
|
59
|
+
log.info(`MCP >>> [initProject] Sending docker-compose content: ${composeContent.length} bytes`);
|
|
60
|
+
} else {
|
|
61
|
+
log.warn('MCP >>> [initProject] docker-compose strategy but no compose file found!');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
log.info(`MCP >>> [initProject] Sending deployment_type: ${strategy.type} to backend`);
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
// Also add validation function:
|
|
69
|
+
function validateDockerCompose(content) {
|
|
70
|
+
const issues = [];
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Parse YAML to check structure
|
|
74
|
+
const lines = content.split('\n');
|
|
75
|
+
|
|
76
|
+
// Check for 'ports' vs 'expose'
|
|
77
|
+
const hasPortsMapping = content.includes('ports:');
|
|
78
|
+
const hasExposeOnly = content.includes('expose:') && !hasPortsMapping;
|
|
79
|
+
|
|
80
|
+
if (!hasPortsMapping) {
|
|
81
|
+
issues.push({
|
|
82
|
+
level: 'ERROR',
|
|
83
|
+
message: 'No service exposes ports for web traffic',
|
|
84
|
+
suggestion: 'At least one service must have "ports:" configuration for Coolify/Traefik routing'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (hasExposeOnly) {
|
|
89
|
+
issues.push({
|
|
90
|
+
level: 'WARNING',
|
|
91
|
+
message: 'Service uses "expose" instead of "ports"',
|
|
92
|
+
suggestion: 'Change "expose" to "ports" for proper Traefik routing'
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check for hardcoded passwords
|
|
97
|
+
const passwordPatterns = [
|
|
98
|
+
/password:\s*["'](?!.*\$\{).*["']/gi,
|
|
99
|
+
/PASSWORD=(?!.*\$\{)[^$\s]+/gi,
|
|
100
|
+
/secret:\s*["'](?!.*\$\{).*["']/gi
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const pattern of passwordPatterns) {
|
|
104
|
+
if (pattern.test(content)) {
|
|
105
|
+
issues.push({
|
|
106
|
+
level: 'WARNING',
|
|
107
|
+
message: 'Hardcoded password detected',
|
|
108
|
+
suggestion: 'Use environment variables: PASSWORD=${PASSWORD}'
|
|
109
|
+
});
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check for absolute volume paths
|
|
115
|
+
if (/volumes:\s*\n\s*-\s*\//m.test(content)) {
|
|
116
|
+
issues.push({
|
|
117
|
+
level: 'WARNING',
|
|
118
|
+
message: 'Absolute host path in volume mount',
|
|
119
|
+
suggestion: 'Use named volumes instead of absolute paths'
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
} catch (err) {
|
|
124
|
+
issues.push({
|
|
125
|
+
level: 'ERROR',
|
|
126
|
+
message: `Invalid docker-compose format: ${err.message}`,
|
|
127
|
+
suggestion: 'Fix YAML syntax errors'
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return issues;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateDockerfile(projectPath) {
|
|
135
|
+
const fs = require('fs');
|
|
136
|
+
const path = require('path');
|
|
137
|
+
const dockerfilePath = path.join(projectPath, 'Dockerfile');
|
|
138
|
+
|
|
139
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const issues = [];
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const content = fs.readFileSync(dockerfilePath, 'utf8');
|
|
147
|
+
|
|
148
|
+
// Check for EXPOSE directive
|
|
149
|
+
if (!content.includes('EXPOSE ')) {
|
|
150
|
+
issues.push({
|
|
151
|
+
level: 'WARNING',
|
|
152
|
+
message: 'Dockerfile has no EXPOSE directive',
|
|
153
|
+
suggestion: 'Add EXPOSE 80 or appropriate port'
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check for USER directive (security)
|
|
158
|
+
if (!content.includes('USER ') || content.includes('USER root')) {
|
|
159
|
+
issues.push({
|
|
160
|
+
level: 'WARNING',
|
|
161
|
+
message: 'Dockerfile runs as root user',
|
|
162
|
+
suggestion: 'Add non-root USER for better security'
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check for WORKDIR
|
|
167
|
+
if (!content.includes('WORKDIR ')) {
|
|
168
|
+
issues.push({
|
|
169
|
+
level: 'INFO',
|
|
170
|
+
message: 'No WORKDIR set in Dockerfile',
|
|
171
|
+
suggestion: 'Consider setting WORKDIR for organization'
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
} catch (err) {
|
|
176
|
+
// Ignore read errors
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return issues;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Export for testing
|
|
183
|
+
if (typeof module !== 'undefined') {
|
|
184
|
+
module.exports = {
|
|
185
|
+
readDockerComposeContent,
|
|
186
|
+
validateDockerCompose,
|
|
187
|
+
validateDockerfile
|
|
188
|
+
};
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mlgym-deploy",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "MCP server for MLGym -
|
|
3
|
+
"version": "3.2.2",
|
|
4
|
+
"description": "MCP server for MLGym - Auto-validates and fixes docker-compose/Dockerfile for Coolify",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/mlgym-deploy-2.10.0.tgz
DELETED
|
Binary file
|