mlgym-deploy 3.0.8 → 3.2.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.
Files changed (2) hide show
  1. package/index.js +303 -2
  2. package/package.json +2 -2
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.0'; // Added mlgym_deploy_logs tool for viewing deployment history
21
21
  const PACKAGE_NAME = 'mlgym-deploy';
22
22
 
23
23
  // Debug logging configuration - ENABLED BY DEFAULT
@@ -963,6 +963,145 @@ 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
+ // Analyze docker-compose.yml to determine deployment strategy
1024
+ async function analyzeComposeFile(composePath) {
1025
+ const content = await fs.readFile(composePath, 'utf-8');
1026
+ const lines = content.split('\n');
1027
+
1028
+ let webServiceCount = 0;
1029
+ let totalServiceCount = 0;
1030
+ let usesLocalDockerfile = false;
1031
+ let inServicesSection = false;
1032
+ let currentService = '';
1033
+ let currentServiceIsWeb = false;
1034
+
1035
+ for (const line of lines) {
1036
+ const trimmed = line.trim();
1037
+
1038
+ // Detect services section
1039
+ if (trimmed.startsWith('services:')) {
1040
+ inServicesSection = true;
1041
+ continue;
1042
+ }
1043
+
1044
+ if (!inServicesSection) continue;
1045
+
1046
+ // End of services section
1047
+ if (trimmed && !line.startsWith(' ') && !line.startsWith('\t')) {
1048
+ inServicesSection = false;
1049
+ break;
1050
+ }
1051
+
1052
+ // New service definition (at 2-space or 1-tab indentation level)
1053
+ if ((line.startsWith(' ') || line.startsWith('\t')) &&
1054
+ trimmed.includes(':') &&
1055
+ !trimmed.startsWith('-') &&
1056
+ !trimmed.includes('image:') &&
1057
+ !trimmed.includes('build:') &&
1058
+ !trimmed.includes('ports:') &&
1059
+ !trimmed.includes('expose:')) {
1060
+
1061
+ if (currentService) {
1062
+ totalServiceCount++;
1063
+ if (currentServiceIsWeb) {
1064
+ webServiceCount++;
1065
+ }
1066
+ }
1067
+
1068
+ currentService = trimmed.split(':')[0].trim();
1069
+ currentServiceIsWeb = false;
1070
+ continue;
1071
+ }
1072
+
1073
+ // Check if current service exposes web ports
1074
+ if (trimmed.startsWith('ports:') || trimmed.startsWith('expose:')) {
1075
+ currentServiceIsWeb = true;
1076
+ }
1077
+
1078
+ // Check if service uses local Dockerfile
1079
+ if (trimmed === 'build: .' || trimmed === 'build: ./') {
1080
+ usesLocalDockerfile = true;
1081
+ }
1082
+ }
1083
+
1084
+ // Count last service
1085
+ if (currentService) {
1086
+ totalServiceCount++;
1087
+ if (currentServiceIsWeb) {
1088
+ webServiceCount++;
1089
+ }
1090
+ }
1091
+
1092
+ return { webServiceCount, usesLocalDockerfile, totalServiceCount };
1093
+ }
1094
+
1095
+ // Helper to check if file exists
1096
+ async function fileExists(filePath) {
1097
+ try {
1098
+ await fs.access(filePath);
1099
+ return true;
1100
+ } catch {
1101
+ return false;
1102
+ }
1103
+ }
1104
+
966
1105
  // Initialize Project (requires authentication)
967
1106
  async function initProject(args) {
968
1107
  let { name, description, enable_deployment = true, hostname, local_path = '.' } = args;
@@ -1022,6 +1161,17 @@ async function initProject(args) {
1022
1161
  };
1023
1162
 
1024
1163
  if (enable_deployment) {
1164
+ // Detect deployment strategy from local directory (like CLI does)
1165
+ const absolutePath = path.resolve(local_path);
1166
+ log.info(`MCP >>> [initProject] Detecting deployment strategy in: ${absolutePath}`);
1167
+
1168
+ const strategy = await detectDeploymentStrategy(absolutePath);
1169
+ log.info(`MCP >>> [initProject] Detected strategy: ${strategy.type} - ${strategy.reason}`);
1170
+ console.error(`🔍 Detected deployment strategy: ${strategy.type}`);
1171
+ if (strategy.reason) {
1172
+ console.error(` Reason: ${strategy.reason}`);
1173
+ }
1174
+
1025
1175
  // Generate a secure webhook secret for deployments
1026
1176
  const webhookSecret = Array.from(
1027
1177
  crypto.getRandomValues(new Uint8Array(32)),
@@ -1029,10 +1179,14 @@ async function initProject(args) {
1029
1179
  ).join('');
1030
1180
 
1031
1181
  // Use FLAT structure exactly like CLI does - no nested deployment_info
1182
+ // CRITICAL: Send deployment_type so backend doesn't try to detect from empty repo
1032
1183
  projectData.enable_deployment = true;
1184
+ projectData.deployment_type = strategy.type; // ← NEW: Send detected type!
1033
1185
  projectData.webhook_secret = webhookSecret;
1034
1186
  projectData.hostname = hostname;
1035
1187
  projectData.local_path = local_path;
1188
+
1189
+ log.info(`MCP >>> [initProject] Sending deployment_type: ${strategy.type} to backend`);
1036
1190
  }
1037
1191
 
1038
1192
  const result = await apiRequest('POST', '/api/v1/projects', projectData, true);
@@ -1830,6 +1984,121 @@ async function getStatus(args) {
1830
1984
  }
1831
1985
  }
1832
1986
 
1987
+ // Get deployment logs for a project
1988
+ async function getDeploymentLogs(args) {
1989
+ const local_path = args.local_path || '.';
1990
+ const depth = args.depth || 1;
1991
+ const project_name = args.project_name;
1992
+
1993
+ log.info(`MCP >>> [getDeploymentLogs] Getting logs for project: ${project_name || 'auto-detect'}, depth: ${depth}`);
1994
+
1995
+ try {
1996
+ // Check authentication
1997
+ const auth = await loadAuth();
1998
+ if (!auth.token) {
1999
+ throw new Error('Authentication required. Please deploy or login first.');
2000
+ }
2001
+
2002
+ // Determine project name
2003
+ let projectName = project_name;
2004
+ if (!projectName) {
2005
+ // Try to get from git remote
2006
+ const projectStatus = await checkExistingProject(local_path);
2007
+ if (!projectStatus.exists) {
2008
+ throw new Error('Project not found. Please specify project_name or run from a project directory with git remote.');
2009
+ }
2010
+ projectName = projectStatus.name;
2011
+ }
2012
+
2013
+ log.info(`MCP >>> [getDeploymentLogs] Detected project name: ${projectName}`);
2014
+
2015
+ // Get project details from backend to get Coolify app UUID
2016
+ const projectsResult = await apiRequest('GET', '/api/v1/projects', null, true);
2017
+ if (!projectsResult.success) {
2018
+ throw new Error(`Failed to get projects: ${projectsResult.error}`);
2019
+ }
2020
+
2021
+ const project = projectsResult.data.projects?.find(p => p.name === projectName);
2022
+ if (!project) {
2023
+ throw new Error(`Project "${projectName}" not found in your account`);
2024
+ }
2025
+
2026
+ if (!project.coolify_app_uuid) {
2027
+ throw new Error(`Project "${projectName}" does not have deployment enabled`);
2028
+ }
2029
+
2030
+ const appUUID = project.coolify_app_uuid;
2031
+ log.info(`MCP >>> [getDeploymentLogs] Found Coolify app UUID: ${appUUID}`);
2032
+
2033
+ // Call User Agent API to get deployment logs
2034
+ const userAgentURL = 'http://coolify.eu.ezb.net:9000';
2035
+ const endpoint = `${userAgentURL}/applications/${appUUID}/deployment-logs?depth=${depth}`;
2036
+
2037
+ log.info(`MCP >>> [getDeploymentLogs] Calling User Agent: ${endpoint}`);
2038
+
2039
+ const response = await axios.get(endpoint, {
2040
+ headers: {
2041
+ 'Authorization': 'Bearer 1|Jkztb5qPptwRKgtocfbT2TLjp8WGJG1SkPE4DzLt4c5d600f',
2042
+ 'Content-Type': 'application/json'
2043
+ }
2044
+ });
2045
+
2046
+ const logsData = response.data;
2047
+ log.info(`MCP >>> [getDeploymentLogs] Retrieved ${logsData.count} deployment(s)`);
2048
+
2049
+ // Format the response
2050
+ if (logsData.count === 0) {
2051
+ return {
2052
+ content: [{
2053
+ type: 'text',
2054
+ text: JSON.stringify({
2055
+ status: 'ok',
2056
+ project_name: projectName,
2057
+ app_uuid: appUUID,
2058
+ count: 0,
2059
+ message: 'No deployment logs found for this application. To create a deployment, push code to GitLab.'
2060
+ }, null, 2)
2061
+ }]
2062
+ };
2063
+ }
2064
+
2065
+ // Format deployments with readable timestamps
2066
+ const formattedDeployments = logsData.deployments.map(dep => ({
2067
+ id: dep.id,
2068
+ status: dep.status,
2069
+ created_at: new Date(dep.created_at).toISOString().replace('T', ' ').substring(0, 19),
2070
+ updated_at: new Date(dep.updated_at).toISOString().replace('T', ' ').substring(0, 19),
2071
+ output: dep.output || 'No logs available'
2072
+ }));
2073
+
2074
+ return {
2075
+ content: [{
2076
+ type: 'text',
2077
+ text: JSON.stringify({
2078
+ status: 'ok',
2079
+ project_name: projectName,
2080
+ app_uuid: appUUID,
2081
+ count: logsData.count,
2082
+ deployments: formattedDeployments
2083
+ }, null, 2)
2084
+ }]
2085
+ };
2086
+
2087
+ } catch (error) {
2088
+ log.error(`MCP >>> [getDeploymentLogs] Error: ${error.message}`);
2089
+ return {
2090
+ content: [{
2091
+ type: 'text',
2092
+ text: JSON.stringify({
2093
+ status: 'error',
2094
+ error: error.message,
2095
+ hint: 'Make sure the project exists and has deployment enabled'
2096
+ }, null, 2)
2097
+ }]
2098
+ };
2099
+ }
2100
+ }
2101
+
1833
2102
  // Create the MCP server
1834
2103
  const server = new Server(
1835
2104
  {
@@ -1921,6 +2190,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1921
2190
  }
1922
2191
  }
1923
2192
  },
2193
+ {
2194
+ name: 'mlgym_deploy_logs',
2195
+ description: 'View deployment history and logs for a project. Shows the last N deployments with their status, timestamps, and build logs.',
2196
+ inputSchema: {
2197
+ type: 'object',
2198
+ properties: {
2199
+ project_name: {
2200
+ type: 'string',
2201
+ description: 'Project name (optional if in project directory with git remote)',
2202
+ pattern: '^[a-z0-9][a-z0-9-]*[a-z0-9]$',
2203
+ minLength: 3
2204
+ },
2205
+ depth: {
2206
+ type: 'number',
2207
+ description: 'Number of deployments to retrieve (1-10, default: 1)',
2208
+ minimum: 1,
2209
+ maximum: 10,
2210
+ default: 1
2211
+ },
2212
+ local_path: {
2213
+ type: 'string',
2214
+ description: 'Local project directory path (optional, defaults to current directory)',
2215
+ default: '.'
2216
+ }
2217
+ }
2218
+ }
2219
+ },
1924
2220
  // ========== LEGACY TOOLS (Re-enabled for specific use cases) ==========
1925
2221
  {
1926
2222
  name: 'mlgym_user_create',
@@ -1998,6 +2294,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1998
2294
  result = await getStatus(args);
1999
2295
  break;
2000
2296
 
2297
+ case 'mlgym_deploy_logs':
2298
+ log.info(`Retrieving deployment logs...`);
2299
+ result = await getDeploymentLogs(args);
2300
+ break;
2301
+
2001
2302
  case 'mlgym_user_create':
2002
2303
  log.info(`Creating user account...`);
2003
2304
  result = await createUser(args);
@@ -2009,7 +2310,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2009
2310
  break;
2010
2311
 
2011
2312
  default:
2012
- throw new Error(`Unknown tool: ${name}. Available tools: mlgym_deploy, mlgym_status, mlgym_user_create, mlgym_auth_login`);
2313
+ throw new Error(`Unknown tool: ${name}. Available tools: mlgym_deploy, mlgym_status, mlgym_deploy_logs, mlgym_user_create, mlgym_auth_login`);
2013
2314
  }
2014
2315
 
2015
2316
  const duration = Date.now() - startTime;
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.0",
4
+ "description": "MCP server for MLGym - Added deployment logs viewer tool",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "bin": {