mlgym-deploy 2.4.1 → 2.6.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 +463 -5
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -16,8 +16,8 @@ import crypto from 'crypto';
16
16
 
17
17
  const execAsync = promisify(exec);
18
18
 
19
- // Current version of this MCP server - INCREMENT FOR WORKFLOW FIX
20
- const CURRENT_VERSION = '2.4.1';
19
+ // Current version of this MCP server - INCREMENT FOR PROJECT ANALYSIS
20
+ const CURRENT_VERSION = '2.6.0';
21
21
  const PACKAGE_NAME = 'mlgym-deploy';
22
22
 
23
23
  // Version check state
@@ -353,6 +353,379 @@ async function authenticate(args) {
353
353
  };
354
354
  }
355
355
 
356
+ // Analyze project to detect type, framework, and configuration
357
+ async function analyzeProject(local_path = '.') {
358
+ const absolutePath = path.resolve(local_path);
359
+ const dirName = path.basename(absolutePath);
360
+
361
+ const analysis = {
362
+ project_type: 'unknown',
363
+ detected_files: [],
364
+ suggested_name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
365
+ has_dockerfile: false,
366
+ has_git: false,
367
+ framework: null,
368
+ build_command: null,
369
+ start_command: null,
370
+ package_manager: null
371
+ };
372
+
373
+ try {
374
+ // Check for git
375
+ try {
376
+ await execAsync('git status', { cwd: absolutePath });
377
+ analysis.has_git = true;
378
+ } catch {}
379
+
380
+ // Check for Dockerfile
381
+ try {
382
+ await fs.access(path.join(absolutePath, 'Dockerfile'));
383
+ analysis.has_dockerfile = true;
384
+ analysis.detected_files.push('Dockerfile');
385
+ } catch {}
386
+
387
+ // Check for Node.js project
388
+ try {
389
+ const packageJsonPath = path.join(absolutePath, 'package.json');
390
+ await fs.access(packageJsonPath);
391
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
392
+
393
+ analysis.project_type = 'nodejs';
394
+ analysis.detected_files.push('package.json');
395
+ analysis.suggested_name = packageJson.name || analysis.suggested_name;
396
+
397
+ // Detect package manager
398
+ try {
399
+ await fs.access(path.join(absolutePath, 'package-lock.json'));
400
+ analysis.package_manager = 'npm';
401
+ analysis.detected_files.push('package-lock.json');
402
+ } catch {
403
+ try {
404
+ await fs.access(path.join(absolutePath, 'yarn.lock'));
405
+ analysis.package_manager = 'yarn';
406
+ analysis.detected_files.push('yarn.lock');
407
+ } catch {
408
+ analysis.package_manager = 'npm'; // default
409
+ }
410
+ }
411
+
412
+ // Detect framework
413
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
414
+ if (deps.next) {
415
+ analysis.framework = 'nextjs';
416
+ analysis.build_command = packageJson.scripts?.build || 'npm run build';
417
+ analysis.start_command = packageJson.scripts?.start || 'npm start';
418
+ } else if (deps.express) {
419
+ analysis.framework = 'express';
420
+ analysis.start_command = packageJson.scripts?.start || 'node index.js';
421
+ } else if (deps.react) {
422
+ analysis.framework = 'react';
423
+ analysis.build_command = packageJson.scripts?.build || 'npm run build';
424
+ } else if (deps.vue) {
425
+ analysis.framework = 'vue';
426
+ analysis.build_command = packageJson.scripts?.build || 'npm run build';
427
+ }
428
+
429
+ // Use package.json scripts as fallback
430
+ if (!analysis.build_command && packageJson.scripts?.build) {
431
+ analysis.build_command = 'npm run build';
432
+ }
433
+ if (!analysis.start_command && packageJson.scripts?.start) {
434
+ analysis.start_command = 'npm start';
435
+ }
436
+ } catch {}
437
+
438
+ // Check for Python project
439
+ if (analysis.project_type === 'unknown') {
440
+ try {
441
+ await fs.access(path.join(absolutePath, 'requirements.txt'));
442
+ analysis.project_type = 'python';
443
+ analysis.detected_files.push('requirements.txt');
444
+
445
+ // Check for specific Python files
446
+ try {
447
+ await fs.access(path.join(absolutePath, 'app.py'));
448
+ analysis.framework = 'flask';
449
+ analysis.start_command = 'python app.py';
450
+ analysis.detected_files.push('app.py');
451
+ } catch {
452
+ try {
453
+ await fs.access(path.join(absolutePath, 'main.py'));
454
+ analysis.framework = 'fastapi';
455
+ analysis.start_command = 'uvicorn main:app --host 0.0.0.0';
456
+ analysis.detected_files.push('main.py');
457
+ } catch {}
458
+ }
459
+ } catch {}
460
+ }
461
+
462
+ // Check for static HTML project
463
+ if (analysis.project_type === 'unknown') {
464
+ try {
465
+ await fs.access(path.join(absolutePath, 'index.html'));
466
+ analysis.project_type = 'static';
467
+ analysis.framework = 'html';
468
+ analysis.detected_files.push('index.html');
469
+ } catch {}
470
+ }
471
+
472
+ // Check for Go project
473
+ if (analysis.project_type === 'unknown') {
474
+ try {
475
+ await fs.access(path.join(absolutePath, 'go.mod'));
476
+ analysis.project_type = 'go';
477
+ analysis.detected_files.push('go.mod');
478
+ analysis.build_command = 'go build -o app';
479
+ analysis.start_command = './app';
480
+ } catch {}
481
+ }
482
+
483
+ } catch (error) {
484
+ console.error('Project analysis error:', error);
485
+ }
486
+
487
+ return analysis;
488
+ }
489
+
490
+ // Check for existing MLGym project in current directory
491
+ async function checkExistingProject(local_path = '.') {
492
+ const absolutePath = path.resolve(local_path);
493
+
494
+ // Check for git repository
495
+ try {
496
+ const { stdout: remotes } = await execAsync('git remote -v', { cwd: absolutePath });
497
+
498
+ // Check for mlgym remote
499
+ if (remotes.includes('mlgym') && remotes.includes('git.mlgym.io')) {
500
+ // Extract project info from remote URL
501
+ const match = remotes.match(/mlgym\s+git@git\.mlgym\.io:([^\/]+)\/([^\.]+)\.git/);
502
+ if (match) {
503
+ const [, namespace, projectName] = match;
504
+ return {
505
+ exists: true,
506
+ name: projectName,
507
+ namespace: namespace,
508
+ configured: true,
509
+ message: `Project '${projectName}' already configured for MLGym deployment`
510
+ };
511
+ }
512
+ }
513
+
514
+ // Git repo exists but no mlgym remote
515
+ return {
516
+ exists: false,
517
+ has_git: true,
518
+ configured: false,
519
+ message: 'Git repository exists but no MLGym remote configured'
520
+ };
521
+ } catch {
522
+ // No git repository
523
+ return {
524
+ exists: false,
525
+ has_git: false,
526
+ configured: false,
527
+ message: 'No git repository found in current directory'
528
+ };
529
+ }
530
+ }
531
+
532
+ // Generate appropriate Dockerfile based on project type
533
+ function generateDockerfile(projectType, framework, packageManager = 'npm') {
534
+ let dockerfile = '';
535
+
536
+ if (projectType === 'nodejs') {
537
+ if (framework === 'nextjs') {
538
+ dockerfile = `# Build stage
539
+ FROM node:18-alpine AS builder
540
+ WORKDIR /app
541
+ COPY package*.json ./
542
+ RUN ${packageManager} ${packageManager === 'npm' ? 'ci' : 'install --frozen-lockfile'}
543
+ COPY . .
544
+ RUN ${packageManager} run build
545
+
546
+ # Production stage
547
+ FROM node:18-alpine
548
+ WORKDIR /app
549
+ COPY --from=builder /app/.next ./.next
550
+ COPY --from=builder /app/node_modules ./node_modules
551
+ COPY --from=builder /app/package.json ./
552
+ COPY --from=builder /app/public ./public
553
+ EXPOSE 3000
554
+ CMD ["${packageManager}", "start"]`;
555
+ } else if (framework === 'express') {
556
+ dockerfile = `FROM node:18-alpine
557
+ WORKDIR /app
558
+ COPY package*.json ./
559
+ RUN ${packageManager} ${packageManager === 'npm' ? 'ci --only=production' : 'install --frozen-lockfile --production'}
560
+ COPY . .
561
+ EXPOSE 3000
562
+ CMD ["node", "index.js"]`;
563
+ } else if (framework === 'react' || framework === 'vue') {
564
+ dockerfile = `# Build stage
565
+ FROM node:18-alpine AS builder
566
+ WORKDIR /app
567
+ COPY package*.json ./
568
+ RUN ${packageManager} ${packageManager === 'npm' ? 'ci' : 'install --frozen-lockfile'}
569
+ COPY . .
570
+ RUN ${packageManager} run build
571
+
572
+ # Production stage
573
+ FROM nginx:alpine
574
+ COPY --from=builder /app/${framework === 'react' ? 'build' : 'dist'} /usr/share/nginx/html
575
+ EXPOSE 80
576
+ CMD ["nginx", "-g", "daemon off;"]`;
577
+ } else {
578
+ // Generic Node.js
579
+ dockerfile = `FROM node:18-alpine
580
+ WORKDIR /app
581
+ COPY package*.json ./
582
+ RUN ${packageManager} ${packageManager === 'npm' ? 'ci --only=production' : 'install --frozen-lockfile --production'}
583
+ COPY . .
584
+ EXPOSE 3000
585
+ CMD ["${packageManager}", "start"]`;
586
+ }
587
+ } else if (projectType === 'python') {
588
+ if (framework === 'flask') {
589
+ dockerfile = `FROM python:3.11-slim
590
+ WORKDIR /app
591
+ COPY requirements.txt .
592
+ RUN pip install --no-cache-dir -r requirements.txt
593
+ COPY . .
594
+ EXPOSE 5000
595
+ CMD ["python", "app.py"]`;
596
+ } else if (framework === 'fastapi') {
597
+ dockerfile = `FROM python:3.11-slim
598
+ WORKDIR /app
599
+ COPY requirements.txt .
600
+ RUN pip install --no-cache-dir -r requirements.txt
601
+ COPY . .
602
+ EXPOSE 8000
603
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]`;
604
+ } else {
605
+ // Generic Python
606
+ dockerfile = `FROM python:3.11-slim
607
+ WORKDIR /app
608
+ COPY requirements.txt .
609
+ RUN pip install --no-cache-dir -r requirements.txt
610
+ COPY . .
611
+ EXPOSE 8000
612
+ CMD ["python", "main.py"]`;
613
+ }
614
+ } else if (projectType === 'static') {
615
+ dockerfile = `FROM nginx:alpine
616
+ COPY . /usr/share/nginx/html
617
+ EXPOSE 80
618
+ CMD ["nginx", "-g", "daemon off;"]`;
619
+ } else if (projectType === 'go') {
620
+ dockerfile = `# Build stage
621
+ FROM golang:1.21-alpine AS builder
622
+ WORKDIR /app
623
+ COPY go.mod go.sum ./
624
+ RUN go mod download
625
+ COPY . .
626
+ RUN go build -o app
627
+
628
+ # Production stage
629
+ FROM alpine:latest
630
+ RUN apk --no-cache add ca-certificates
631
+ WORKDIR /root/
632
+ COPY --from=builder /app/app .
633
+ EXPOSE 8080
634
+ CMD ["./app"]`;
635
+ } else {
636
+ // Unknown type - basic Alpine with shell
637
+ dockerfile = `FROM alpine:latest
638
+ WORKDIR /app
639
+ COPY . .
640
+ RUN echo "Unknown project type - please configure manually"
641
+ CMD ["/bin/sh"]`;
642
+ }
643
+
644
+ return dockerfile;
645
+ }
646
+
647
+ // Prepare project for deployment
648
+ async function prepareProject(args) {
649
+ const { local_path = '.', project_type, framework, package_manager } = args;
650
+ const absolutePath = path.resolve(local_path);
651
+
652
+ const actions = [];
653
+
654
+ try {
655
+ // Check if Dockerfile exists
656
+ const dockerfilePath = path.join(absolutePath, 'Dockerfile');
657
+ let dockerfileExists = false;
658
+
659
+ try {
660
+ await fs.access(dockerfilePath);
661
+ dockerfileExists = true;
662
+ actions.push('Dockerfile already exists - skipping generation');
663
+ } catch {}
664
+
665
+ // Generate Dockerfile if missing
666
+ if (!dockerfileExists && project_type !== 'unknown') {
667
+ const dockerfile = generateDockerfile(project_type, framework, package_manager);
668
+ await fs.writeFile(dockerfilePath, dockerfile);
669
+ actions.push(`Generated Dockerfile for ${project_type}/${framework || 'generic'}`);
670
+ }
671
+
672
+ // Check/create .gitignore
673
+ const gitignorePath = path.join(absolutePath, '.gitignore');
674
+ let gitignoreExists = false;
675
+
676
+ try {
677
+ await fs.access(gitignorePath);
678
+ gitignoreExists = true;
679
+ } catch {}
680
+
681
+ if (!gitignoreExists) {
682
+ let gitignoreContent = '';
683
+
684
+ if (project_type === 'nodejs') {
685
+ gitignoreContent = `node_modules/
686
+ .env
687
+ .env.local
688
+ dist/
689
+ build/
690
+ .next/
691
+ *.log`;
692
+ } else if (project_type === 'python') {
693
+ gitignoreContent = `__pycache__/
694
+ *.py[cod]
695
+ *$py.class
696
+ .env
697
+ venv/
698
+ env/
699
+ .venv/`;
700
+ } else {
701
+ gitignoreContent = `.env
702
+ *.log
703
+ .DS_Store`;
704
+ }
705
+
706
+ await fs.writeFile(gitignorePath, gitignoreContent);
707
+ actions.push('Created .gitignore file');
708
+ }
709
+
710
+ return {
711
+ status: 'success',
712
+ message: 'Project prepared for deployment',
713
+ actions: actions,
714
+ dockerfile_created: !dockerfileExists && project_type !== 'unknown',
715
+ project_type: project_type,
716
+ framework: framework
717
+ };
718
+
719
+ } catch (error) {
720
+ return {
721
+ status: 'error',
722
+ message: 'Failed to prepare project',
723
+ error: error.message,
724
+ actions: actions
725
+ };
726
+ }
727
+ }
728
+
356
729
  // Check authentication status
357
730
  async function checkAuthStatus() {
358
731
  const auth = await loadAuth();
@@ -548,7 +921,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
548
921
  tools: [
549
922
  {
550
923
  name: 'mlgym_auth_status',
551
- description: 'Check if you are authenticated. Always call this first to see if authentication is needed.',
924
+ description: 'ALWAYS CALL THIS FIRST! Check authentication status before any other operation.',
552
925
  inputSchema: {
553
926
  type: 'object',
554
927
  properties: {}
@@ -556,7 +929,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
556
929
  },
557
930
  {
558
931
  name: 'mlgym_authenticate',
559
- description: 'Step 1: Authenticate with MLGym. Just provide email and password. For new users, optionally set create_if_not_exists=true with full_name.',
932
+ description: 'PHASE 1: Authentication ONLY. Get email, password, and existing account status in ONE interaction. Never ask for project details here!',
560
933
  inputSchema: {
561
934
  type: 'object',
562
935
  properties: {
@@ -589,9 +962,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
589
962
  required: ['email', 'password']
590
963
  }
591
964
  },
965
+ {
966
+ name: 'mlgym_project_analyze',
967
+ description: 'PHASE 2: Analyze project to detect type, framework, and configuration. Call BEFORE creating project.',
968
+ inputSchema: {
969
+ type: 'object',
970
+ properties: {
971
+ local_path: {
972
+ type: 'string',
973
+ description: 'Local directory path (defaults to current directory)',
974
+ default: '.'
975
+ }
976
+ }
977
+ }
978
+ },
979
+ {
980
+ name: 'mlgym_project_status',
981
+ description: 'PHASE 2: Check if MLGym project exists in current directory.',
982
+ inputSchema: {
983
+ type: 'object',
984
+ properties: {
985
+ local_path: {
986
+ type: 'string',
987
+ description: 'Local directory path (defaults to current directory)',
988
+ default: '.'
989
+ }
990
+ }
991
+ }
992
+ },
592
993
  {
593
994
  name: 'mlgym_project_init',
594
- description: 'Step 2: After authentication, create project. Provide name, description, and optionally hostname for deployment.',
995
+ description: 'PHASE 2: Create project ONLY after checking project status. Never ask for email/password here - only project details!',
595
996
  inputSchema: {
596
997
  type: 'object',
597
998
  properties: {
@@ -626,6 +1027,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
626
1027
  },
627
1028
  required: ['name', 'description']
628
1029
  }
1030
+ },
1031
+ {
1032
+ name: 'mlgym_project_prepare',
1033
+ description: 'PHASE 2: Prepare project for deployment by generating Dockerfile and config files.',
1034
+ inputSchema: {
1035
+ type: 'object',
1036
+ properties: {
1037
+ local_path: {
1038
+ type: 'string',
1039
+ description: 'Local directory path (defaults to current directory)',
1040
+ default: '.'
1041
+ },
1042
+ project_type: {
1043
+ type: 'string',
1044
+ description: 'Project type from analysis',
1045
+ enum: ['nodejs', 'python', 'static', 'go', 'unknown']
1046
+ },
1047
+ framework: {
1048
+ type: 'string',
1049
+ description: 'Framework from analysis',
1050
+ enum: ['nextjs', 'express', 'react', 'vue', 'flask', 'fastapi', 'html', null]
1051
+ },
1052
+ package_manager: {
1053
+ type: 'string',
1054
+ description: 'Package manager for Node.js projects',
1055
+ enum: ['npm', 'yarn'],
1056
+ default: 'npm'
1057
+ }
1058
+ }
1059
+ }
629
1060
  }
630
1061
  ]
631
1062
  };
@@ -645,9 +1076,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
645
1076
  case 'mlgym_authenticate':
646
1077
  return await authenticate(args);
647
1078
 
1079
+ case 'mlgym_project_analyze':
1080
+ const analysis = await analyzeProject(args.local_path);
1081
+ return {
1082
+ content: [{
1083
+ type: 'text',
1084
+ text: JSON.stringify(analysis, null, 2)
1085
+ }]
1086
+ };
1087
+
1088
+ case 'mlgym_project_status':
1089
+ const projectStatus = await checkExistingProject(args.local_path);
1090
+ return {
1091
+ content: [{
1092
+ type: 'text',
1093
+ text: JSON.stringify(projectStatus, null, 2)
1094
+ }]
1095
+ };
1096
+
648
1097
  case 'mlgym_project_init':
649
1098
  return await initProject(args);
650
1099
 
1100
+ case 'mlgym_project_prepare':
1101
+ const prepResult = await prepareProject(args);
1102
+ return {
1103
+ content: [{
1104
+ type: 'text',
1105
+ text: JSON.stringify(prepResult, null, 2)
1106
+ }]
1107
+ };
1108
+
651
1109
  default:
652
1110
  throw new Error(`Unknown tool: ${name}`);
653
1111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlgym-deploy",
3
- "version": "2.4.1",
3
+ "version": "2.6.0",
4
4
  "description": "MCP server for GitLab Backend - User creation and project deployment",
5
5
  "main": "index.js",
6
6
  "type": "module",