mlgym-deploy 3.0.7 → 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 +363 -5
  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.7'; // Fixed Dockerfile generation path bug
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
@@ -705,25 +705,38 @@ CMD ["/bin/sh"]`;
705
705
  async function prepareProject(args) {
706
706
  const { local_path = '.', project_type, framework, package_manager } = args;
707
707
  const absolutePath = path.resolve(local_path);
708
+ log.info('MCP >>> [prepareProject-func] Called with local_path:', local_path);
709
+ log.debug('MCP >>> [prepareProject-func] Resolved absolutePath:', absolutePath);
710
+ log.debug('MCP >>> [prepareProject-func] project_type:', project_type, 'framework:', framework);
708
711
 
709
712
  const actions = [];
710
713
 
711
714
  try {
712
715
  // Check if Dockerfile exists
713
716
  const dockerfilePath = path.join(absolutePath, 'Dockerfile');
717
+ log.debug('MCP >>> [prepareProject-func] Dockerfile path:', dockerfilePath);
714
718
  let dockerfileExists = false;
715
719
 
716
720
  try {
717
721
  await fs.access(dockerfilePath);
718
722
  dockerfileExists = true;
723
+ log.info('MCP >>> [prepareProject-func] Dockerfile already exists at', dockerfilePath);
719
724
  actions.push('Dockerfile already exists - skipping generation');
720
- } catch {}
725
+ } catch {
726
+ log.info('MCP >>> [prepareProject-func] Dockerfile does not exist, will generate');
727
+ }
721
728
 
722
729
  // Generate Dockerfile if missing
723
730
  if (!dockerfileExists && project_type !== 'unknown') {
731
+ log.info('MCP >>> [prepareProject-func] Generating Dockerfile...');
724
732
  const dockerfile = generateDockerfile(project_type, framework, package_manager);
733
+ log.debug('MCP >>> [prepareProject-func] Generated Dockerfile length:', dockerfile.length, 'bytes');
734
+ log.info('MCP >>> [prepareProject-func] Writing Dockerfile to:', dockerfilePath);
725
735
  await fs.writeFile(dockerfilePath, dockerfile);
736
+ log.success('MCP >>> [prepareProject-func] ✅ Dockerfile written successfully!');
726
737
  actions.push(`Generated Dockerfile for ${project_type}/${framework || 'generic'}`);
738
+ } else if (project_type === 'unknown') {
739
+ log.warning('MCP >>> [prepareProject-func] Project type is unknown, skipping Dockerfile generation');
727
740
  }
728
741
 
729
742
  // Check/create .gitignore
@@ -950,6 +963,145 @@ async function smartDeploy(args) {
950
963
  }
951
964
  }
952
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
+
953
1105
  // Initialize Project (requires authentication)
954
1106
  async function initProject(args) {
955
1107
  let { name, description, enable_deployment = true, hostname, local_path = '.' } = args;
@@ -1009,6 +1161,17 @@ async function initProject(args) {
1009
1161
  };
1010
1162
 
1011
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
+
1012
1175
  // Generate a secure webhook secret for deployments
1013
1176
  const webhookSecret = Array.from(
1014
1177
  crypto.getRandomValues(new Uint8Array(32)),
@@ -1016,10 +1179,14 @@ async function initProject(args) {
1016
1179
  ).join('');
1017
1180
 
1018
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
1019
1183
  projectData.enable_deployment = true;
1184
+ projectData.deployment_type = strategy.type; // ← NEW: Send detected type!
1020
1185
  projectData.webhook_secret = webhookSecret;
1021
1186
  projectData.hostname = hostname;
1022
1187
  projectData.local_path = local_path;
1188
+
1189
+ log.info(`MCP >>> [initProject] Sending deployment_type: ${strategy.type} to backend`);
1023
1190
  }
1024
1191
 
1025
1192
  const result = await apiRequest('POST', '/api/v1/projects', projectData, true);
@@ -1121,44 +1288,59 @@ async function initProject(args) {
1121
1288
  }
1122
1289
 
1123
1290
  // Create initial commit and push (like CLI does)
1291
+ log.info('MCP >>> [initProject] Starting git commit and push...');
1292
+ log.debug('MCP >>> [initProject] Working directory:', absolutePath);
1124
1293
  const gitSteps = [];
1125
1294
  let pushSucceeded = false;
1126
1295
 
1127
1296
  try {
1128
1297
  // Check if there are any files to commit
1298
+ log.info('MCP >>> [initProject] Checking git status...');
1129
1299
  const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: absolutePath });
1300
+ log.debug('MCP >>> [initProject] Git status output:', statusOutput.trim() || '(clean)');
1130
1301
 
1131
1302
  if (statusOutput.trim()) {
1132
1303
  // There are uncommitted changes
1304
+ log.info('MCP >>> [initProject] Uncommitted changes detected, staging files...');
1133
1305
  gitSteps.push('Adding files to git');
1134
1306
  await execAsync('git add .', { cwd: absolutePath });
1307
+ log.success('MCP >>> [initProject] Files staged (git add .)');
1135
1308
 
1309
+ log.info('MCP >>> [initProject] Creating commit...');
1136
1310
  gitSteps.push('Creating initial commit');
1137
1311
  await execAsync('git commit -m "Initial MLGym deployment"', { cwd: absolutePath });
1312
+ log.success('MCP >>> [initProject] Commit created');
1138
1313
  } else {
1139
1314
  // Check if there are any commits at all
1315
+ log.info('MCP >>> [initProject] No uncommitted changes, checking for existing commits...');
1140
1316
  try {
1141
1317
  await execAsync('git rev-parse HEAD', { cwd: absolutePath });
1318
+ log.info('MCP >>> [initProject] Repository already has commits');
1142
1319
  gitSteps.push('Repository already has commits');
1143
1320
  } catch {
1144
1321
  // No commits yet, create an initial one
1322
+ log.warning('MCP >>> [initProject] No commits found, creating initial README...');
1145
1323
  gitSteps.push('Creating README for initial commit');
1146
1324
  const readmePath = path.join(absolutePath, 'README.md');
1147
1325
  if (!await fs.access(readmePath).then(() => true).catch(() => false)) {
1148
1326
  await fs.writeFile(readmePath, `# ${name}\n\n${description}\n\nDeployed with MLGym\n`);
1327
+ log.info('MCP >>> [initProject] README.md created');
1149
1328
  }
1150
1329
  await execAsync('git add .', { cwd: absolutePath });
1151
1330
  await execAsync('git commit -m "Initial MLGym deployment"', { cwd: absolutePath });
1331
+ log.success('MCP >>> [initProject] Initial commit created with README');
1152
1332
  }
1153
1333
  }
1154
1334
 
1155
1335
  // Push to trigger webhook and deployment
1336
+ log.info('MCP >>> [initProject] Pushing to GitLab...');
1337
+ log.debug('MCP >>> [initProject] Git remote URL:', gitUrl);
1156
1338
  gitSteps.push('Pushing to GitLab to trigger deployment');
1157
1339
  console.error('Executing: git push -u mlgym main');
1158
1340
  await execAsync('git push -u mlgym main', { cwd: absolutePath });
1159
1341
  gitSteps.push('✅ Successfully pushed to GitLab');
1160
1342
  pushSucceeded = true;
1161
- console.error('✅ Git push completed successfully');
1343
+ log.success('MCP >>> [initProject] ✅ Git push completed successfully!');
1162
1344
 
1163
1345
  } catch (pushError) {
1164
1346
  console.error('❌ Git push failed:', pushError.message);
@@ -1534,27 +1716,35 @@ class DeploymentWorkflow {
1534
1716
  async prepareProject(localPath, projectType, framework, packageManager) {
1535
1717
  this.currentStep = 'preparation';
1536
1718
  this.addStep('prepare_project', 'running');
1719
+ log.info('MCP >>> [prepareProject] Starting with localPath:', localPath);
1537
1720
 
1538
1721
  try {
1539
1722
  // Check if Dockerfile exists
1540
1723
  const analysis = this.projectAnalysis || await analyzeProject(localPath);
1724
+ log.debug('MCP >>> [prepareProject] has_dockerfile:', analysis.has_dockerfile);
1541
1725
 
1542
1726
  if (analysis.has_dockerfile) {
1727
+ log.info('MCP >>> [prepareProject] Dockerfile already exists, skipping generation');
1543
1728
  this.updateLastStep('skipped', 'Dockerfile already exists');
1544
1729
  return { skipped: true, reason: 'Dockerfile already exists' };
1545
1730
  }
1546
1731
 
1547
1732
  // Generate Dockerfile
1733
+ log.info('MCP >>> [prepareProject] Generating Dockerfile...');
1734
+ log.debug('MCP >>> [prepareProject] Type:', projectType || analysis.project_type, 'Framework:', framework || analysis.framework);
1548
1735
  const prepResult = await prepareProject({
1549
1736
  local_path: localPath,
1550
1737
  project_type: projectType || analysis.project_type,
1551
1738
  framework: framework || analysis.framework,
1552
1739
  package_manager: packageManager || 'npm'
1553
1740
  });
1741
+ log.success('MCP >>> [prepareProject] Dockerfile generated successfully');
1742
+ log.debug('MCP >>> [prepareProject] Actions:', prepResult.actions);
1554
1743
 
1555
1744
  this.updateLastStep('completed', prepResult.actions);
1556
1745
  return prepResult;
1557
1746
  } catch (error) {
1747
+ log.error('MCP >>> [prepareProject] FAILED:', error.message);
1558
1748
  this.updateLastStep('failed', error.message);
1559
1749
  throw error;
1560
1750
  }
@@ -1631,13 +1821,21 @@ async function deployProject(args) {
1631
1821
  package_manager = 'npm'
1632
1822
  } = args;
1633
1823
 
1824
+ log.info('MCP >>> Starting deployment workflow');
1825
+ log.debug('MCP >>> Arguments:', { project_name, hostname, local_path, project_type, framework, package_manager });
1826
+
1634
1827
  try {
1635
1828
  // Step 1: Ensure authenticated
1829
+ log.info('MCP >>> STEP 1: Authenticating...');
1636
1830
  await workflow.ensureAuth(email, password);
1831
+ log.success('MCP >>> STEP 1: Authentication complete');
1637
1832
 
1638
1833
  // Step 2: Check if project already exists
1834
+ log.info('MCP >>> STEP 2: Checking for existing project in', local_path);
1639
1835
  const existing = await workflow.checkExisting(local_path);
1640
1836
  if (existing && existing.exists) {
1837
+ log.warning('MCP >>> Project already exists:', existing.name);
1838
+
1641
1839
  return {
1642
1840
  content: [{
1643
1841
  type: 'text',
@@ -1659,22 +1857,35 @@ async function deployProject(args) {
1659
1857
  }]
1660
1858
  };
1661
1859
  }
1860
+ log.success('MCP >>> STEP 2: No existing project found, proceeding with deployment');
1662
1861
 
1663
1862
  // Step 3: Analyze project (auto-detect type and framework)
1863
+ log.info('MCP >>> STEP 3: Analyzing project...');
1664
1864
  const analysis = await workflow.analyzeProject(local_path);
1865
+ log.success('MCP >>> STEP 3: Analysis complete -', analysis.project_type, '/', analysis.framework);
1866
+ log.debug('MCP >>> Detected files:', analysis.detected_files);
1665
1867
 
1666
1868
  // Step 4: Prepare project (generate Dockerfile if needed)
1667
- await workflow.prepareProject(local_path, project_type, framework, package_manager);
1869
+ log.info('MCP >>> STEP 4: Preparing project (Dockerfile generation)...');
1870
+ log.debug('MCP >>> Using local_path:', local_path);
1871
+ const prepResult = await workflow.prepareProject(local_path, project_type, framework, package_manager);
1872
+ log.success('MCP >>> STEP 4: Project preparation complete');
1873
+ log.debug('MCP >>> Preparation actions:', prepResult);
1668
1874
 
1669
1875
  // Step 5: Create GitLab project and deploy
1876
+ log.info('MCP >>> STEP 5: Creating GitLab project and deploying...');
1877
+ log.debug('MCP >>> Project params:', { name: project_name, hostname: hostname || project_name, local_path });
1670
1878
  const deployResult = await workflow.createAndDeployProject({
1671
1879
  name: project_name,
1672
1880
  description: project_description,
1673
1881
  hostname: hostname || project_name,
1674
1882
  local_path
1675
1883
  });
1884
+ log.success('MCP >>> STEP 5: Deployment complete!');
1885
+ log.debug('MCP >>> Deploy result:', { project_id: deployResult.project?.id, deployment_url: deployResult.project?.deployment_url });
1676
1886
 
1677
1887
  // Success response
1888
+ log.success('MCP >>> ✅ Full deployment workflow completed successfully!');
1678
1889
  return {
1679
1890
  content: [{
1680
1891
  type: 'text',
@@ -1773,6 +1984,121 @@ async function getStatus(args) {
1773
1984
  }
1774
1985
  }
1775
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
+
1776
2102
  // Create the MCP server
1777
2103
  const server = new Server(
1778
2104
  {
@@ -1864,6 +2190,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1864
2190
  }
1865
2191
  }
1866
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
+ },
1867
2220
  // ========== LEGACY TOOLS (Re-enabled for specific use cases) ==========
1868
2221
  {
1869
2222
  name: 'mlgym_user_create',
@@ -1941,6 +2294,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1941
2294
  result = await getStatus(args);
1942
2295
  break;
1943
2296
 
2297
+ case 'mlgym_deploy_logs':
2298
+ log.info(`Retrieving deployment logs...`);
2299
+ result = await getDeploymentLogs(args);
2300
+ break;
2301
+
1944
2302
  case 'mlgym_user_create':
1945
2303
  log.info(`Creating user account...`);
1946
2304
  result = await createUser(args);
@@ -1952,7 +2310,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1952
2310
  break;
1953
2311
 
1954
2312
  default:
1955
- 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`);
1956
2314
  }
1957
2315
 
1958
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.7",
4
- "description": "MCP server for MLGym - Fixed Dockerfile generation path bug",
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": {