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 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.0.8'; // Added extensive MCP >>> debug logging throughout deployment workflow
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.0.8",
4
- "description": "MCP server for MLGym - Added extensive debug logging with MCP >>> prefix",
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": {
Binary file