mlgym-deploy 3.3.42 → 3.3.44

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 (35) hide show
  1. package/ADD_TO_CURSOR_SETTINGS.json +1 -1
  2. package/ADD_TO_CURSOR_SETTINGS.json.example +28 -0
  3. package/CHANGELOG-v3.3.42.md +707 -0
  4. package/Dockerfile +7 -0
  5. package/README.md +35 -0
  6. package/claude-desktop-config.json +1 -1
  7. package/claude-desktop-config.json.example +8 -0
  8. package/cursor-config.json +1 -1
  9. package/cursor-config.json.example +13 -0
  10. package/docs/CURSOR_SETUP.md +204 -0
  11. package/docs/NPM_RELEASE.md +230 -0
  12. package/index.js +9 -4
  13. package/mcp.json.example +13 -0
  14. package/package.json +8 -4
  15. package/tests/TEST_RESULTS.md +518 -0
  16. package/tests/archived/README.md +71 -0
  17. package/{deploy-hello-world.sh → tests/archived/deploy-hello-world.sh} +9 -6
  18. package/tests/deploy_dollie_test.sh +11 -7
  19. package/tests/misc/check-sdk-version.js +50 -0
  20. package/tests/mlgym_auth_login_test.sh +13 -9
  21. package/tests/mlgym_deploy_logs_test.sh +339 -0
  22. package/tests/mlgym_deploy_test.sh +341 -0
  23. package/tests/mlgym_status_test.sh +281 -0
  24. package/tests/mlgym_user_create_test.sh +35 -29
  25. package/tests/run-all-tests.sh +135 -41
  26. package/CURSOR_SETUP.md +0 -119
  27. package/index.js.backup-atomic +0 -1358
  28. /package/{DEBUG.md → docs/DEBUG.md} +0 -0
  29. /package/{SECURITY-UPDATE-v2.4.0.md → tests/archived/SECURITY-UPDATE-v2.4.0.md} +0 -0
  30. /package/{cursor-integration.js → tests/archived/cursor-integration.js} +0 -0
  31. /package/tests/{mlgym_auth_logout_test.sh → archived/mlgym_auth_logout_test.sh} +0 -0
  32. /package/tests/{mlgym_deployments_test.sh → archived/mlgym_deployments_test.sh} +0 -0
  33. /package/tests/{mlgym_project_init_test.sh → archived/mlgym_project_init_test.sh} +0 -0
  34. /package/tests/{mlgym_projects_get_test.sh → archived/mlgym_projects_get_test.sh} +0 -0
  35. /package/tests/{mlgym_projects_list_test.sh → archived/mlgym_projects_list_test.sh} +0 -0
@@ -1,1358 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import {
6
- CallToolRequestSchema,
7
- ListToolsRequestSchema
8
- } from '@modelcontextprotocol/sdk/types.js';
9
- import axios from 'axios';
10
- import fs from 'fs/promises';
11
- import path from 'path';
12
- import os from 'os';
13
- import { exec } from 'child_process';
14
- import { promisify } from 'util';
15
- import crypto from 'crypto';
16
-
17
- const execAsync = promisify(exec);
18
-
19
- // Current version of this MCP server - INCREMENT FOR WORKFLOW FIXES
20
- const CURRENT_VERSION = '2.10.0'; // Fixed SSH key handling: generate and include in user creation request
21
- const PACKAGE_NAME = 'mlgym-deploy';
22
-
23
- // Version check state
24
- let versionCheckResult = null;
25
- let lastVersionCheck = 0;
26
- const VERSION_CHECK_INTERVAL = 3600000; // Check once per hour
27
-
28
- // Configuration
29
- const CONFIG = {
30
- backend_url: process.env.MLGYM_API_ENDPOINT || 'https://backend.eu.ezb.net',
31
- gitlab_url: 'https://git.mlgym.io',
32
- coolify_url: 'https://coolify.eu.ezb.net',
33
- config_file: path.join(os.homedir(), '.mlgym', 'mcp_config.json')
34
- };
35
-
36
- // Helper to load/save authentication
37
- async function loadAuth() {
38
- try {
39
- const data = await fs.readFile(CONFIG.config_file, 'utf8');
40
- return JSON.parse(data);
41
- } catch {
42
- return { token: null, email: null };
43
- }
44
- }
45
-
46
- async function saveAuth(email, token) {
47
- const dir = path.dirname(CONFIG.config_file);
48
- await fs.mkdir(dir, { recursive: true });
49
- await fs.writeFile(
50
- CONFIG.config_file,
51
- JSON.stringify({ email, token, timestamp: new Date().toISOString() }, null, 2)
52
- );
53
- }
54
-
55
- // API client helper
56
- async function apiRequest(method, endpoint, data = null, useAuth = true) {
57
- const config = {
58
- method,
59
- url: `${CONFIG.backend_url}${endpoint}`,
60
- headers: {
61
- 'Content-Type': 'application/json'
62
- }
63
- };
64
-
65
- if (useAuth) {
66
- const auth = await loadAuth();
67
- if (auth.token) {
68
- config.headers['Authorization'] = `Bearer ${auth.token}`;
69
- }
70
- }
71
-
72
- if (data) {
73
- config.data = data;
74
- }
75
-
76
- try {
77
- const response = await axios(config);
78
- return { success: true, data: response.data };
79
- } catch (error) {
80
- const errorData = error.response?.data || { error: error.message };
81
- return { success: false, error: errorData.error || errorData.message || 'Request failed' };
82
- }
83
- }
84
-
85
- // Helper to generate random password
86
- function generateRandomPassword() {
87
- const chars = 'ABCDEFGHJKLMNPQRSTWXYZabcdefghjkmnpqrstwxyz23456789!@#$%^&*';
88
- let password = '';
89
- for (let i = 0; i < 16; i++) {
90
- password += chars.charAt(Math.floor(Math.random() * chars.length));
91
- }
92
- return password;
93
- }
94
-
95
- // SSH key generation
96
- async function generateSSHKeyPair(email) {
97
- const sshDir = path.join(os.homedir(), '.ssh');
98
- await fs.mkdir(sshDir, { recursive: true, mode: 0o700 });
99
-
100
- const sanitizedEmail = email.replace('@', '_at_').replace(/[^a-zA-Z0-9_-]/g, '_');
101
- const keyPath = path.join(sshDir, `mlgym_${sanitizedEmail}`);
102
-
103
- try {
104
- await fs.access(keyPath);
105
- console.error(`SSH key already exists at ${keyPath}, using existing key`);
106
- const publicKey = await fs.readFile(`${keyPath}.pub`, 'utf8');
107
- return { publicKey: publicKey.trim(), privateKeyPath: keyPath };
108
- } catch {
109
- // Key doesn't exist, generate new one
110
- }
111
-
112
- const { stdout, stderr } = await execAsync(
113
- `ssh-keygen -t ed25519 -f "${keyPath}" -N "" -C "${email}"`,
114
- { timeout: 10000 }
115
- );
116
-
117
- if (stderr && !stderr.includes('Generating public/private')) {
118
- throw new Error(`SSH key generation failed: ${stderr}`);
119
- }
120
-
121
- await execAsync(`chmod 600 "${keyPath}"`);
122
- await execAsync(`chmod 644 "${keyPath}.pub"`);
123
-
124
- const publicKey = await fs.readFile(`${keyPath}.pub`, 'utf8');
125
-
126
- // Add to SSH config
127
- const configPath = path.join(sshDir, 'config');
128
- const configEntry = `
129
- # MLGym GitLab (added by mlgym-deploy)
130
- Host git.mlgym.io
131
- User git
132
- Port 22
133
- IdentityFile ${keyPath}
134
- StrictHostKeyChecking no
135
- `;
136
-
137
- try {
138
- const existingConfig = await fs.readFile(configPath, 'utf8');
139
- if (!existingConfig.includes('Host git.mlgym.io')) {
140
- await fs.appendFile(configPath, configEntry);
141
- }
142
- } catch {
143
- await fs.writeFile(configPath, configEntry, { mode: 0o600 });
144
- }
145
-
146
- return { publicKey: publicKey.trim(), privateKeyPath: keyPath };
147
- }
148
-
149
- // SECURE CONSOLIDATED AUTHENTICATION FUNCTION
150
- async function authenticate(args) {
151
- const { email, password, create_if_not_exists = false, full_name, accept_terms } = args;
152
-
153
- // Validate required fields
154
- if (!email || !password) {
155
- return {
156
- content: [{
157
- type: 'text',
158
- text: JSON.stringify({
159
- status: 'error',
160
- message: 'Email and password are required',
161
- required_fields: {
162
- email: email ? '✓ provided' : '✗ missing',
163
- password: password ? '✓ provided' : '✗ missing'
164
- }
165
- }, null, 2)
166
- }]
167
- };
168
- }
169
-
170
- console.error(`Attempting authentication for: ${email}`);
171
-
172
- // Step 1: Always try login first (never reveal if account exists)
173
- try {
174
- const loginResult = await apiRequest('POST', '/api/v1/auth/login', {
175
- email,
176
- password
177
- }, false);
178
-
179
- if (loginResult.success && loginResult.data.token) {
180
- // Login successful - save token and setup SSH if needed
181
- await saveAuth(email, loginResult.data.token);
182
-
183
- // Check for SSH keys
184
- const keysResult = await apiRequest('GET', '/api/v1/keys', null, true);
185
- const sshKeys = keysResult.success ? keysResult.data : [];
186
-
187
- // If no SSH keys, generate and add one
188
- if (sshKeys.length === 0) {
189
- console.error('No SSH keys found, generating new key pair...');
190
- const { publicKey, privateKeyPath } = await generateSSHKeyPair(email);
191
-
192
- const keyTitle = `mlgym-${new Date().toISOString().split('T')[0]}`;
193
- const keyResult = await apiRequest('POST', '/api/v1/keys', {
194
- title: keyTitle,
195
- key: publicKey
196
- }, true);
197
-
198
- if (keyResult.success) {
199
- return {
200
- content: [{
201
- type: 'text',
202
- text: JSON.stringify({
203
- status: 'authenticated',
204
- message: 'Successfully logged in and SSH key configured',
205
- email: email,
206
- user_id: loginResult.data.user?.user_id,
207
- gitlab_username: loginResult.data.user?.gitlab_username,
208
- ssh_key_added: true,
209
- ssh_key_path: privateKeyPath,
210
- next_steps: [
211
- 'Authentication successful',
212
- `SSH key generated at: ${privateKeyPath}`,
213
- 'You can now create projects and deploy'
214
- ]
215
- }, null, 2)
216
- }]
217
- };
218
- }
219
- }
220
-
221
- // User has SSH keys already
222
- return {
223
- content: [{
224
- type: 'text',
225
- text: JSON.stringify({
226
- status: 'authenticated',
227
- message: 'Successfully logged in',
228
- email: email,
229
- user_id: loginResult.data.user?.user_id,
230
- gitlab_username: loginResult.data.user?.gitlab_username,
231
- ssh_keys_count: sshKeys.length,
232
- next_steps: [
233
- 'Authentication successful',
234
- 'You can now create projects and deploy'
235
- ]
236
- }, null, 2)
237
- }]
238
- };
239
- }
240
- } catch (error) {
241
- // Login failed - continue to next step
242
- console.error('Login attempt failed:', error.message);
243
- }
244
-
245
- // Step 2: Login failed - check if we should create account
246
- if (create_if_not_exists) {
247
- // Validate required fields for account creation
248
- if (!full_name) {
249
- return {
250
- content: [{
251
- type: 'text',
252
- text: JSON.stringify({
253
- status: 'error',
254
- message: 'Account creation requires full_name',
255
- note: 'Login failed. To create a new account, provide full_name and accept_terms=true'
256
- }, null, 2)
257
- }]
258
- };
259
- }
260
-
261
- if (!accept_terms) {
262
- return {
263
- content: [{
264
- type: 'text',
265
- text: JSON.stringify({
266
- status: 'error',
267
- message: 'You must accept the terms and conditions to create an account',
268
- note: 'Set accept_terms=true to proceed with account creation'
269
- }, null, 2)
270
- }]
271
- };
272
- }
273
-
274
- console.error(`Creating new account for: ${email}`);
275
-
276
- // Try to create account
277
- try {
278
- // Generate SSH key BEFORE creating user
279
- console.error('Generating SSH key for new user...');
280
- const { publicKey, privateKeyPath } = await generateSSHKeyPair(email);
281
-
282
- // Create user with SSH key included
283
- const createResult = await apiRequest('POST', '/api/v1/users', {
284
- email,
285
- name: full_name,
286
- password,
287
- ssh_key: publicKey // Include SSH key in user creation
288
- }, false);
289
-
290
- if (createResult.success) {
291
- // Account created, now login
292
- const loginResult = await apiRequest('POST', '/api/v1/auth/login', {
293
- email,
294
- password
295
- }, false);
296
-
297
- if (loginResult.success && loginResult.data.token) {
298
- await saveAuth(email, loginResult.data.token);
299
-
300
- return {
301
- content: [{
302
- type: 'text',
303
- text: JSON.stringify({
304
- status: 'authenticated',
305
- message: 'Account created successfully and logged in',
306
- email: email,
307
- user_id: loginResult.data.user?.user_id,
308
- gitlab_username: loginResult.data.user?.gitlab_username,
309
- ssh_key_path: privateKeyPath,
310
- ssh_key_status: createResult.data.ssh_key_status || 'SSH key configured',
311
- next_steps: [
312
- 'New account created',
313
- `SSH key generated at: ${privateKeyPath}`,
314
- 'SSH key automatically uploaded to GitLab',
315
- 'You can now create projects and deploy'
316
- ]
317
- }, null, 2)
318
- }]
319
- };
320
- }
321
- }
322
- } catch (error) {
323
- // Account creation failed - generic error
324
- console.error('Account creation failed:', error.message);
325
- return {
326
- content: [{
327
- type: 'text',
328
- text: JSON.stringify({
329
- status: 'error',
330
- message: 'Account creation failed. The email may already be in use or password requirements not met.',
331
- requirements: {
332
- password: 'Minimum 8 characters with mixed case, numbers, and special characters',
333
- email: 'Valid email address not already registered'
334
- }
335
- }, null, 2)
336
- }]
337
- };
338
- }
339
- }
340
-
341
- // Step 3: Generic authentication failure (never reveal if account exists)
342
- return {
343
- content: [{
344
- type: 'text',
345
- text: JSON.stringify({
346
- status: 'error',
347
- message: 'Authentication failed. Invalid credentials.',
348
- hint: 'If you need to create a new account, set create_if_not_exists=true and provide full_name and accept_terms=true'
349
- }, null, 2)
350
- }]
351
- };
352
- }
353
-
354
- // Analyze project to detect type, framework, and configuration
355
- async function analyzeProject(local_path = '.') {
356
- const absolutePath = path.resolve(local_path);
357
- const dirName = path.basename(absolutePath);
358
-
359
- const analysis = {
360
- project_type: 'unknown',
361
- detected_files: [],
362
- suggested_name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
363
- has_dockerfile: false,
364
- has_git: false,
365
- framework: null,
366
- build_command: null,
367
- start_command: null,
368
- package_manager: null
369
- };
370
-
371
- try {
372
- // Check for git
373
- try {
374
- await execAsync('git status', { cwd: absolutePath });
375
- analysis.has_git = true;
376
- } catch {}
377
-
378
- // Check for Dockerfile
379
- try {
380
- await fs.access(path.join(absolutePath, 'Dockerfile'));
381
- analysis.has_dockerfile = true;
382
- analysis.detected_files.push('Dockerfile');
383
- } catch {}
384
-
385
- // Check for Node.js project
386
- try {
387
- const packageJsonPath = path.join(absolutePath, 'package.json');
388
- await fs.access(packageJsonPath);
389
- const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
390
-
391
- analysis.project_type = 'nodejs';
392
- analysis.detected_files.push('package.json');
393
- analysis.suggested_name = packageJson.name || analysis.suggested_name;
394
-
395
- // Detect package manager
396
- try {
397
- await fs.access(path.join(absolutePath, 'package-lock.json'));
398
- analysis.package_manager = 'npm';
399
- analysis.detected_files.push('package-lock.json');
400
- } catch {
401
- try {
402
- await fs.access(path.join(absolutePath, 'yarn.lock'));
403
- analysis.package_manager = 'yarn';
404
- analysis.detected_files.push('yarn.lock');
405
- } catch {
406
- analysis.package_manager = 'npm'; // default
407
- }
408
- }
409
-
410
- // Detect framework
411
- const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
412
- if (deps.next) {
413
- analysis.framework = 'nextjs';
414
- analysis.build_command = packageJson.scripts?.build || 'npm run build';
415
- analysis.start_command = packageJson.scripts?.start || 'npm start';
416
- } else if (deps.express) {
417
- analysis.framework = 'express';
418
- analysis.start_command = packageJson.scripts?.start || 'node index.js';
419
- } else if (deps.react) {
420
- analysis.framework = 'react';
421
- analysis.build_command = packageJson.scripts?.build || 'npm run build';
422
- } else if (deps.vue) {
423
- analysis.framework = 'vue';
424
- analysis.build_command = packageJson.scripts?.build || 'npm run build';
425
- }
426
-
427
- // Use package.json scripts as fallback
428
- if (!analysis.build_command && packageJson.scripts?.build) {
429
- analysis.build_command = 'npm run build';
430
- }
431
- if (!analysis.start_command && packageJson.scripts?.start) {
432
- analysis.start_command = 'npm start';
433
- }
434
- } catch {}
435
-
436
- // Check for Python project
437
- if (analysis.project_type === 'unknown') {
438
- try {
439
- await fs.access(path.join(absolutePath, 'requirements.txt'));
440
- analysis.project_type = 'python';
441
- analysis.detected_files.push('requirements.txt');
442
-
443
- // Check for specific Python files
444
- try {
445
- await fs.access(path.join(absolutePath, 'app.py'));
446
- analysis.framework = 'flask';
447
- analysis.start_command = 'python app.py';
448
- analysis.detected_files.push('app.py');
449
- } catch {
450
- try {
451
- await fs.access(path.join(absolutePath, 'main.py'));
452
- analysis.framework = 'fastapi';
453
- analysis.start_command = 'uvicorn main:app --host 0.0.0.0';
454
- analysis.detected_files.push('main.py');
455
- } catch {}
456
- }
457
- } catch {}
458
- }
459
-
460
- // Check for static HTML project
461
- if (analysis.project_type === 'unknown') {
462
- try {
463
- await fs.access(path.join(absolutePath, 'index.html'));
464
- analysis.project_type = 'static';
465
- analysis.framework = 'html';
466
- analysis.detected_files.push('index.html');
467
- } catch {}
468
- }
469
-
470
- // Check for Go project
471
- if (analysis.project_type === 'unknown') {
472
- try {
473
- await fs.access(path.join(absolutePath, 'go.mod'));
474
- analysis.project_type = 'go';
475
- analysis.detected_files.push('go.mod');
476
- analysis.build_command = 'go build -o app';
477
- analysis.start_command = './app';
478
- } catch {}
479
- }
480
-
481
- } catch (error) {
482
- console.error('Project analysis error:', error);
483
- }
484
-
485
- return analysis;
486
- }
487
-
488
- // Check for existing MLGym project in current directory
489
- async function checkExistingProject(local_path = '.') {
490
- const absolutePath = path.resolve(local_path);
491
-
492
- // Check for git repository
493
- try {
494
- const { stdout: remotes } = await execAsync('git remote -v', { cwd: absolutePath });
495
-
496
- // Check for mlgym remote
497
- if (remotes.includes('mlgym') && remotes.includes('git.mlgym.io')) {
498
- // Extract project info from remote URL
499
- const match = remotes.match(/mlgym\s+git@git\.mlgym\.io:([^\/]+)\/([^\.]+)\.git/);
500
- if (match) {
501
- const [, namespace, projectName] = match;
502
- return {
503
- exists: true,
504
- name: projectName,
505
- namespace: namespace,
506
- configured: true,
507
- message: `Project '${projectName}' already configured for MLGym deployment`
508
- };
509
- }
510
- }
511
-
512
- // Git repo exists but no mlgym remote
513
- return {
514
- exists: false,
515
- has_git: true,
516
- configured: false,
517
- message: 'Git repository exists but no MLGym remote configured'
518
- };
519
- } catch {
520
- // No git repository
521
- return {
522
- exists: false,
523
- has_git: false,
524
- configured: false,
525
- message: 'No git repository found in current directory'
526
- };
527
- }
528
- }
529
-
530
- // Generate appropriate Dockerfile based on project type
531
- function generateDockerfile(projectType, framework, packageManager = 'npm') {
532
- let dockerfile = '';
533
-
534
- if (projectType === 'nodejs') {
535
- if (framework === 'nextjs') {
536
- dockerfile = `# Build stage
537
- FROM node:18-alpine AS builder
538
- WORKDIR /app
539
- COPY package*.json ./
540
- RUN ${packageManager} ${packageManager === 'npm' ? 'ci' : 'install --frozen-lockfile'}
541
- COPY . .
542
- RUN ${packageManager} run build
543
-
544
- # Production stage
545
- FROM node:18-alpine
546
- WORKDIR /app
547
- COPY --from=builder /app/.next ./.next
548
- COPY --from=builder /app/node_modules ./node_modules
549
- COPY --from=builder /app/package.json ./
550
- COPY --from=builder /app/public ./public
551
- EXPOSE 3000
552
- CMD ["${packageManager}", "start"]`;
553
- } else if (framework === 'express') {
554
- dockerfile = `FROM node:18-alpine
555
- WORKDIR /app
556
- COPY package*.json ./
557
- RUN ${packageManager} ${packageManager === 'npm' ? 'ci --only=production' : 'install --frozen-lockfile --production'}
558
- COPY . .
559
- EXPOSE 3000
560
- CMD ["node", "index.js"]`;
561
- } else if (framework === 'react' || framework === 'vue') {
562
- dockerfile = `# Build stage
563
- FROM node:18-alpine AS builder
564
- WORKDIR /app
565
- COPY package*.json ./
566
- RUN ${packageManager} ${packageManager === 'npm' ? 'ci' : 'install --frozen-lockfile'}
567
- COPY . .
568
- RUN ${packageManager} run build
569
-
570
- # Production stage
571
- FROM nginx:alpine
572
- COPY --from=builder /app/${framework === 'react' ? 'build' : 'dist'} /usr/share/nginx/html
573
- EXPOSE 80
574
- CMD ["nginx", "-g", "daemon off;"]`;
575
- } else {
576
- // Generic Node.js
577
- dockerfile = `FROM node:18-alpine
578
- WORKDIR /app
579
- COPY package*.json ./
580
- RUN ${packageManager} ${packageManager === 'npm' ? 'ci --only=production' : 'install --frozen-lockfile --production'}
581
- COPY . .
582
- EXPOSE 3000
583
- CMD ["${packageManager}", "start"]`;
584
- }
585
- } else if (projectType === 'python') {
586
- if (framework === 'flask') {
587
- dockerfile = `FROM python:3.11-slim
588
- WORKDIR /app
589
- COPY requirements.txt .
590
- RUN pip install --no-cache-dir -r requirements.txt
591
- COPY . .
592
- EXPOSE 5000
593
- CMD ["python", "app.py"]`;
594
- } else if (framework === 'fastapi') {
595
- dockerfile = `FROM python:3.11-slim
596
- WORKDIR /app
597
- COPY requirements.txt .
598
- RUN pip install --no-cache-dir -r requirements.txt
599
- COPY . .
600
- EXPOSE 8000
601
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]`;
602
- } else {
603
- // Generic Python
604
- dockerfile = `FROM python:3.11-slim
605
- WORKDIR /app
606
- COPY requirements.txt .
607
- RUN pip install --no-cache-dir -r requirements.txt
608
- COPY . .
609
- EXPOSE 8000
610
- CMD ["python", "main.py"]`;
611
- }
612
- } else if (projectType === 'static') {
613
- dockerfile = `FROM nginx:alpine
614
- COPY . /usr/share/nginx/html
615
- EXPOSE 80
616
- CMD ["nginx", "-g", "daemon off;"]`;
617
- } else if (projectType === 'go') {
618
- dockerfile = `# Build stage
619
- FROM golang:1.21-alpine AS builder
620
- WORKDIR /app
621
- COPY go.mod go.sum ./
622
- RUN go mod download
623
- COPY . .
624
- RUN go build -o app
625
-
626
- # Production stage
627
- FROM alpine:latest
628
- RUN apk --no-cache add ca-certificates
629
- WORKDIR /root/
630
- COPY --from=builder /app/app .
631
- EXPOSE 8080
632
- CMD ["./app"]`;
633
- } else {
634
- // Unknown type - basic Alpine with shell
635
- dockerfile = `FROM alpine:latest
636
- WORKDIR /app
637
- COPY . .
638
- RUN echo "Unknown project type - please configure manually"
639
- CMD ["/bin/sh"]`;
640
- }
641
-
642
- return dockerfile;
643
- }
644
-
645
- // Prepare project for deployment
646
- async function prepareProject(args) {
647
- const { local_path = '.', project_type, framework, package_manager } = args;
648
- const absolutePath = path.resolve(local_path);
649
-
650
- const actions = [];
651
-
652
- try {
653
- // Check if Dockerfile exists
654
- const dockerfilePath = path.join(absolutePath, 'Dockerfile');
655
- let dockerfileExists = false;
656
-
657
- try {
658
- await fs.access(dockerfilePath);
659
- dockerfileExists = true;
660
- actions.push('Dockerfile already exists - skipping generation');
661
- } catch {}
662
-
663
- // Generate Dockerfile if missing
664
- if (!dockerfileExists && project_type !== 'unknown') {
665
- const dockerfile = generateDockerfile(project_type, framework, package_manager);
666
- await fs.writeFile(dockerfilePath, dockerfile);
667
- actions.push(`Generated Dockerfile for ${project_type}/${framework || 'generic'}`);
668
- }
669
-
670
- // Check/create .gitignore
671
- const gitignorePath = path.join(absolutePath, '.gitignore');
672
- let gitignoreExists = false;
673
-
674
- try {
675
- await fs.access(gitignorePath);
676
- gitignoreExists = true;
677
- } catch {}
678
-
679
- if (!gitignoreExists) {
680
- let gitignoreContent = '';
681
-
682
- if (project_type === 'nodejs') {
683
- gitignoreContent = `node_modules/
684
- .env
685
- .env.local
686
- dist/
687
- build/
688
- .next/
689
- *.log`;
690
- } else if (project_type === 'python') {
691
- gitignoreContent = `__pycache__/
692
- *.py[cod]
693
- *$py.class
694
- .env
695
- venv/
696
- env/
697
- .venv/`;
698
- } else {
699
- gitignoreContent = `.env
700
- *.log
701
- .DS_Store`;
702
- }
703
-
704
- await fs.writeFile(gitignorePath, gitignoreContent);
705
- actions.push('Created .gitignore file');
706
- }
707
-
708
- return {
709
- status: 'success',
710
- message: 'Project prepared for deployment',
711
- actions: actions,
712
- dockerfile_created: !dockerfileExists && project_type !== 'unknown',
713
- project_type: project_type,
714
- framework: framework
715
- };
716
-
717
- } catch (error) {
718
- return {
719
- status: 'error',
720
- message: 'Failed to prepare project',
721
- error: error.message,
722
- actions: actions
723
- };
724
- }
725
- }
726
-
727
- // Check authentication status
728
- async function checkAuthStatus() {
729
- const auth = await loadAuth();
730
-
731
- if (!auth.token) {
732
- return {
733
- content: [{
734
- type: 'text',
735
- text: JSON.stringify({
736
- status: 'not_authenticated',
737
- message: 'No authentication found. Please use mlgym_authenticate first.',
738
- next_step: 'Call mlgym_authenticate with email and password'
739
- }, null, 2)
740
- }]
741
- };
742
- }
743
-
744
- // Verify token is still valid
745
- try {
746
- const result = await apiRequest('GET', '/api/v1/user', null, true);
747
-
748
- if (result.success) {
749
- return {
750
- content: [{
751
- type: 'text',
752
- text: JSON.stringify({
753
- status: 'authenticated',
754
- email: auth.email,
755
- message: 'Authentication valid',
756
- user: result.data,
757
- next_step: 'You can now use mlgym_project_init to create a project'
758
- }, null, 2)
759
- }]
760
- };
761
- }
762
- } catch (error) {
763
- // Token might be expired
764
- }
765
-
766
- return {
767
- content: [{
768
- type: 'text',
769
- text: JSON.stringify({
770
- status: 'token_expired',
771
- message: 'Authentication token expired. Please authenticate again.',
772
- next_step: 'Call mlgym_authenticate with email and password'
773
- }, null, 2)
774
- }]
775
- };
776
- }
777
-
778
- // Smart deployment initialization that follows the correct workflow
779
- async function smartDeploy(args) {
780
- const { local_path = '.' } = args;
781
- const absolutePath = path.resolve(local_path);
782
-
783
- const steps = [];
784
-
785
- try {
786
- // Step 1: Check authentication
787
- steps.push({ step: 'auth_check', status: 'running' });
788
- const auth = await loadAuth();
789
- if (!auth.token) {
790
- steps[steps.length - 1].status = 'failed';
791
- return {
792
- content: [{
793
- type: 'text',
794
- text: JSON.stringify({
795
- status: 'error',
796
- message: 'Not authenticated. Please use mlgym_authenticate first',
797
- workflow_steps: steps
798
- }, null, 2)
799
- }]
800
- };
801
- }
802
- steps[steps.length - 1].status = 'completed';
803
-
804
- // Step 2: Analyze project
805
- steps.push({ step: 'project_analysis', status: 'running' });
806
- const analysis = await analyzeProject(local_path);
807
- steps[steps.length - 1].status = 'completed';
808
- steps[steps.length - 1].result = {
809
- type: analysis.project_type,
810
- framework: analysis.framework,
811
- suggested_name: analysis.suggested_name
812
- };
813
-
814
- // Step 3: Check existing project
815
- steps.push({ step: 'check_existing', status: 'running' });
816
- const projectStatus = await checkExistingProject(local_path);
817
- steps[steps.length - 1].status = 'completed';
818
-
819
- if (projectStatus.configured) {
820
- return {
821
- content: [{
822
- type: 'text',
823
- text: JSON.stringify({
824
- status: 'info',
825
- message: projectStatus.message,
826
- project_name: projectStatus.name,
827
- git_remote: `git@git.mlgym.io:${projectStatus.namespace}/${projectStatus.name}.git`,
828
- next_steps: [
829
- 'Project already configured',
830
- 'Run: git push mlgym main'
831
- ],
832
- workflow_steps: steps
833
- }, null, 2)
834
- }]
835
- };
836
- }
837
-
838
- // Step 4: Prepare project (generate Dockerfile if needed)
839
- steps.push({ step: 'prepare_project', status: 'running' });
840
- if (!analysis.has_dockerfile && analysis.project_type !== 'unknown') {
841
- const prepResult = await prepareProject({
842
- local_path,
843
- project_type: analysis.project_type,
844
- framework: analysis.framework,
845
- package_manager: analysis.package_manager
846
- });
847
- steps[steps.length - 1].status = 'completed';
848
- steps[steps.length - 1].result = prepResult.actions;
849
- } else {
850
- steps[steps.length - 1].status = 'skipped';
851
- steps[steps.length - 1].result = 'Dockerfile already exists or project type unknown';
852
- }
853
-
854
- // Return analysis and next steps
855
- return {
856
- content: [{
857
- type: 'text',
858
- text: JSON.stringify({
859
- status: 'ready',
860
- message: 'Project analyzed and prepared. Ready for MLGym initialization.',
861
- analysis: {
862
- project_type: analysis.project_type,
863
- framework: analysis.framework,
864
- suggested_name: analysis.suggested_name,
865
- has_dockerfile: analysis.has_dockerfile || true
866
- },
867
- next_step: 'Use mlgym_project_init with project details to create MLGym project',
868
- suggested_params: {
869
- name: analysis.suggested_name,
870
- description: `${analysis.framework || analysis.project_type} application`,
871
- enable_deployment: true,
872
- hostname: analysis.suggested_name
873
- },
874
- workflow_steps: steps
875
- }, null, 2)
876
- }]
877
- };
878
-
879
- } catch (error) {
880
- return {
881
- content: [{
882
- type: 'text',
883
- text: JSON.stringify({
884
- status: 'error',
885
- message: 'Smart deploy failed',
886
- error: error.message,
887
- workflow_steps: steps
888
- }, null, 2)
889
- }]
890
- };
891
- }
892
- }
893
-
894
- // Initialize Project (requires authentication)
895
- async function initProject(args) {
896
- let { name, description, enable_deployment = true, hostname, local_path = '.' } = args;
897
-
898
- // Validate required fields
899
- if (!name || !description) {
900
- return {
901
- content: [{
902
- type: 'text',
903
- text: JSON.stringify({
904
- status: 'error',
905
- message: 'Project name and description are required',
906
- required_fields: {
907
- name: name ? '✓ provided' : '✗ missing',
908
- description: description ? '✓ provided' : '✗ missing'
909
- }
910
- }, null, 2)
911
- }]
912
- };
913
- }
914
-
915
- if (enable_deployment && !hostname) {
916
- return {
917
- content: [{
918
- type: 'text',
919
- text: JSON.stringify({
920
- status: 'error',
921
- message: 'Hostname is required when deployment is enabled',
922
- required_fields: {
923
- hostname: '✗ missing - will be used as subdomain (e.g., "myapp" for myapp.ezb.net)'
924
- }
925
- }, null, 2)
926
- }]
927
- };
928
- }
929
-
930
- // Check authentication
931
- const auth = await loadAuth();
932
- if (!auth.token) {
933
- return {
934
- content: [{
935
- type: 'text',
936
- text: JSON.stringify({
937
- status: 'error',
938
- message: 'Not authenticated. Please use mlgym_authenticate first'
939
- }, null, 2)
940
- }]
941
- };
942
- }
943
-
944
- console.error(`Creating project: ${name}`);
945
-
946
- // Create project via backend API with FLAT structure (matching CLI)
947
- const projectData = {
948
- name: name,
949
- description: description
950
- };
951
-
952
- if (enable_deployment) {
953
- // Generate a secure webhook secret for deployments
954
- const webhookSecret = Array.from(
955
- crypto.getRandomValues(new Uint8Array(32)),
956
- byte => byte.toString(16).padStart(2, '0')
957
- ).join('');
958
-
959
- // Use FLAT structure exactly like CLI does - no nested deployment_info
960
- projectData.enable_deployment = true;
961
- projectData.webhook_secret = webhookSecret;
962
- projectData.hostname = hostname;
963
- projectData.local_path = local_path;
964
- }
965
-
966
- const result = await apiRequest('POST', '/api/v1/projects', projectData, true);
967
-
968
- if (!result.success) {
969
- return {
970
- content: [{
971
- type: 'text',
972
- text: JSON.stringify({
973
- status: 'error',
974
- message: 'Failed to create project',
975
- error: result.error
976
- }, null, 2)
977
- }]
978
- };
979
- }
980
-
981
- const project = result.data;
982
-
983
- // Initialize local git repository if needed
984
- const absolutePath = path.resolve(local_path);
985
- try {
986
- await execAsync('git status', { cwd: absolutePath });
987
- console.error('Directory is already a git repository');
988
- } catch {
989
- console.error('Initializing git repository...');
990
- await execAsync('git init', { cwd: absolutePath });
991
- await execAsync('git branch -M main', { cwd: absolutePath });
992
- }
993
-
994
- // Add GitLab remote
995
- const gitUrl = project.ssh_url || `git@git.mlgym.io:${project.namespace}/${project.name}.git`;
996
- try {
997
- await execAsync('git remote remove mlgym', { cwd: absolutePath });
998
- } catch {}
999
-
1000
- await execAsync(`git remote add mlgym ${gitUrl}`, { cwd: absolutePath });
1001
-
1002
- // Create initial commit and push (like CLI does)
1003
- const gitSteps = [];
1004
-
1005
- try {
1006
- // Check if there are any files to commit
1007
- const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: absolutePath });
1008
-
1009
- if (statusOutput.trim()) {
1010
- // There are uncommitted changes
1011
- gitSteps.push('Adding files to git');
1012
- await execAsync('git add .', { cwd: absolutePath });
1013
-
1014
- gitSteps.push('Creating initial commit');
1015
- await execAsync('git commit -m "Initial MLGym deployment"', { cwd: absolutePath });
1016
- } else {
1017
- // Check if there are any commits at all
1018
- try {
1019
- await execAsync('git rev-parse HEAD', { cwd: absolutePath });
1020
- gitSteps.push('Repository already has commits');
1021
- } catch {
1022
- // No commits yet, create an initial one
1023
- gitSteps.push('Creating README for initial commit');
1024
- const readmePath = path.join(absolutePath, 'README.md');
1025
- if (!await fs.access(readmePath).then(() => true).catch(() => false)) {
1026
- await fs.writeFile(readmePath, `# ${name}\n\n${description}\n\nDeployed with MLGym\n`);
1027
- }
1028
- await execAsync('git add .', { cwd: absolutePath });
1029
- await execAsync('git commit -m "Initial MLGym deployment"', { cwd: absolutePath });
1030
- }
1031
- }
1032
-
1033
- // Push to trigger webhook and deployment
1034
- gitSteps.push('Pushing to GitLab to trigger deployment');
1035
- await execAsync('git push -u mlgym main', { cwd: absolutePath });
1036
-
1037
- } catch (pushError) {
1038
- console.error('Git operations warning:', pushError.message);
1039
- gitSteps.push(`Warning: ${pushError.message}`);
1040
- }
1041
-
1042
- // Monitor deployment if enabled (like CLI does)
1043
- let deploymentStatus = null;
1044
- if (enable_deployment) {
1045
- console.error('Monitoring deployment status...');
1046
-
1047
- // Poll for deployment status (up to 60 seconds)
1048
- for (let i = 0; i < 12; i++) {
1049
- await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds
1050
-
1051
- const statusResult = await apiRequest('GET', `/api/v1/projects/${project.id}/deployment`, null, true);
1052
- if (statusResult.success && statusResult.data) {
1053
- const status = statusResult.data.status;
1054
- console.error(`Deployment status: ${status}`);
1055
-
1056
- if (status === 'deployed' || status === 'running') {
1057
- deploymentStatus = {
1058
- status: 'deployed',
1059
- url: statusResult.data.url || `https://${hostname}.ezb.net`,
1060
- message: 'Application successfully deployed'
1061
- };
1062
- break;
1063
- } else if (status === 'failed') {
1064
- deploymentStatus = {
1065
- status: 'failed',
1066
- message: 'Deployment failed. Check Coolify logs for details.'
1067
- };
1068
- break;
1069
- }
1070
- }
1071
- }
1072
-
1073
- if (!deploymentStatus) {
1074
- deploymentStatus = {
1075
- status: 'pending',
1076
- message: 'Deployment is still in progress. Check status later.',
1077
- expected_url: `https://${hostname}.ezb.net`
1078
- };
1079
- }
1080
- }
1081
-
1082
- return {
1083
- content: [{
1084
- type: 'text',
1085
- text: JSON.stringify({
1086
- status: 'success',
1087
- message: 'Project created and pushed successfully',
1088
- project: {
1089
- id: project.id,
1090
- name: project.name,
1091
- description: project.description,
1092
- git_url: gitUrl,
1093
- deployment_enabled: enable_deployment,
1094
- deployment_url: hostname ? `https://${hostname}.ezb.net` : null
1095
- },
1096
- git_operations: gitSteps,
1097
- deployment: deploymentStatus,
1098
- next_steps: enable_deployment && deploymentStatus?.status === 'deployed' ? [
1099
- `✅ Your application is live at: ${deploymentStatus.url}`,
1100
- 'Future updates: git push mlgym main'
1101
- ] : [
1102
- 'To update: git push mlgym main',
1103
- enable_deployment ? 'Deployment will trigger automatically' : null
1104
- ].filter(Boolean)
1105
- }, null, 2)
1106
- }]
1107
- };
1108
- }
1109
-
1110
- // Create the MCP server
1111
- const server = new Server(
1112
- {
1113
- name: 'mlgym-deploy',
1114
- version: CURRENT_VERSION,
1115
- },
1116
- {
1117
- capabilities: {
1118
- tools: {},
1119
- },
1120
- }
1121
- );
1122
-
1123
- // Handle tool listing
1124
- server.setRequestHandler(ListToolsRequestSchema, async () => {
1125
- return {
1126
- tools: [
1127
- {
1128
- name: 'mlgym_auth_status',
1129
- description: 'ALWAYS CALL THIS FIRST! Check authentication status before any other operation.',
1130
- inputSchema: {
1131
- type: 'object',
1132
- properties: {}
1133
- }
1134
- },
1135
- {
1136
- name: 'mlgym_authenticate',
1137
- description: 'PHASE 1: Authentication ONLY. Get email, password, and existing account status in ONE interaction. Never ask for project details here!',
1138
- inputSchema: {
1139
- type: 'object',
1140
- properties: {
1141
- email: {
1142
- type: 'string',
1143
- description: 'Email address',
1144
- pattern: '^[^@]+@[^@]+\\.[^@]+$'
1145
- },
1146
- password: {
1147
- type: 'string',
1148
- description: 'Password (min 8 characters)',
1149
- minLength: 8
1150
- },
1151
- create_if_not_exists: {
1152
- type: 'boolean',
1153
- description: 'If true and login fails, attempt to create new account',
1154
- default: false
1155
- },
1156
- full_name: {
1157
- type: 'string',
1158
- description: 'Full name (required only for new account creation)',
1159
- minLength: 2
1160
- },
1161
- accept_terms: {
1162
- type: 'boolean',
1163
- description: 'Accept terms and conditions (required only for new account creation)',
1164
- default: false
1165
- }
1166
- },
1167
- required: ['email', 'password']
1168
- }
1169
- },
1170
- {
1171
- name: 'mlgym_project_analyze',
1172
- description: 'PHASE 2: Analyze project to detect type, framework, and configuration. Call BEFORE creating project.',
1173
- inputSchema: {
1174
- type: 'object',
1175
- properties: {
1176
- local_path: {
1177
- type: 'string',
1178
- description: 'Local directory path (defaults to current directory)',
1179
- default: '.'
1180
- }
1181
- }
1182
- }
1183
- },
1184
- {
1185
- name: 'mlgym_project_status',
1186
- description: 'PHASE 2: Check if MLGym project exists in current directory.',
1187
- inputSchema: {
1188
- type: 'object',
1189
- properties: {
1190
- local_path: {
1191
- type: 'string',
1192
- description: 'Local directory path (defaults to current directory)',
1193
- default: '.'
1194
- }
1195
- }
1196
- }
1197
- },
1198
- {
1199
- name: 'mlgym_project_init',
1200
- description: 'PHASE 2: Create project ONLY after checking project status. Never ask for email/password here - only project details!',
1201
- inputSchema: {
1202
- type: 'object',
1203
- properties: {
1204
- name: {
1205
- type: 'string',
1206
- description: 'Project name (lowercase alphanumeric with hyphens)',
1207
- pattern: '^[a-z0-9][a-z0-9-]*[a-z0-9]$',
1208
- minLength: 3
1209
- },
1210
- description: {
1211
- type: 'string',
1212
- description: 'Project description',
1213
- minLength: 10
1214
- },
1215
- enable_deployment: {
1216
- type: 'boolean',
1217
- description: 'Enable automatic deployment via Coolify',
1218
- default: true
1219
- },
1220
- hostname: {
1221
- type: 'string',
1222
- description: 'Hostname for deployment (required if deployment enabled, will be subdomain)',
1223
- pattern: '^[a-z][a-z0-9-]*[a-z0-9]$',
1224
- minLength: 3,
1225
- maxLength: 63
1226
- },
1227
- local_path: {
1228
- type: 'string',
1229
- description: 'Local directory path (defaults to current directory)',
1230
- default: '.'
1231
- }
1232
- },
1233
- required: ['name', 'description']
1234
- }
1235
- },
1236
- {
1237
- name: 'mlgym_project_prepare',
1238
- description: 'PHASE 2: Prepare project for deployment by generating Dockerfile and config files.',
1239
- inputSchema: {
1240
- type: 'object',
1241
- properties: {
1242
- local_path: {
1243
- type: 'string',
1244
- description: 'Local directory path (defaults to current directory)',
1245
- default: '.'
1246
- },
1247
- project_type: {
1248
- type: 'string',
1249
- description: 'Project type from analysis',
1250
- enum: ['nodejs', 'python', 'static', 'go', 'unknown']
1251
- },
1252
- framework: {
1253
- type: 'string',
1254
- description: 'Framework from analysis',
1255
- enum: ['nextjs', 'express', 'react', 'vue', 'flask', 'fastapi', 'html', null]
1256
- },
1257
- package_manager: {
1258
- type: 'string',
1259
- description: 'Package manager for Node.js projects',
1260
- enum: ['npm', 'yarn'],
1261
- default: 'npm'
1262
- }
1263
- }
1264
- }
1265
- },
1266
- {
1267
- name: 'mlgym_smart_deploy',
1268
- description: 'RECOMMENDED: Smart deployment workflow that automatically analyzes, prepares, and guides you through the entire deployment process. Use this for new projects!',
1269
- inputSchema: {
1270
- type: 'object',
1271
- properties: {
1272
- local_path: {
1273
- type: 'string',
1274
- description: 'Local directory path (defaults to current directory)',
1275
- default: '.'
1276
- }
1277
- }
1278
- }
1279
- }
1280
- ]
1281
- };
1282
- });
1283
-
1284
- // Handle tool execution
1285
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
1286
- const { name, arguments: args } = request.params;
1287
-
1288
- console.error(`Tool called: ${name}`);
1289
-
1290
- try {
1291
- switch (name) {
1292
- case 'mlgym_auth_status':
1293
- return await checkAuthStatus();
1294
-
1295
- case 'mlgym_authenticate':
1296
- return await authenticate(args);
1297
-
1298
- case 'mlgym_project_analyze':
1299
- const analysis = await analyzeProject(args.local_path);
1300
- return {
1301
- content: [{
1302
- type: 'text',
1303
- text: JSON.stringify(analysis, null, 2)
1304
- }]
1305
- };
1306
-
1307
- case 'mlgym_project_status':
1308
- const projectStatus = await checkExistingProject(args.local_path);
1309
- return {
1310
- content: [{
1311
- type: 'text',
1312
- text: JSON.stringify(projectStatus, null, 2)
1313
- }]
1314
- };
1315
-
1316
- case 'mlgym_project_init':
1317
- return await initProject(args);
1318
-
1319
- case 'mlgym_project_prepare':
1320
- const prepResult = await prepareProject(args);
1321
- return {
1322
- content: [{
1323
- type: 'text',
1324
- text: JSON.stringify(prepResult, null, 2)
1325
- }]
1326
- };
1327
-
1328
- case 'mlgym_smart_deploy':
1329
- return await smartDeploy(args);
1330
-
1331
- default:
1332
- throw new Error(`Unknown tool: ${name}`);
1333
- }
1334
- } catch (error) {
1335
- console.error(`Tool execution failed:`, error);
1336
- return {
1337
- content: [{
1338
- type: 'text',
1339
- text: JSON.stringify({
1340
- status: 'error',
1341
- message: `Tool execution failed: ${error.message}`
1342
- }, null, 2)
1343
- }]
1344
- };
1345
- }
1346
- });
1347
-
1348
- // Start the server
1349
- async function main() {
1350
- const transport = new StdioServerTransport();
1351
- await server.connect(transport);
1352
- console.error(`MLGym MCP Server v${CURRENT_VERSION} started`);
1353
- }
1354
-
1355
- main().catch((error) => {
1356
- console.error('Server error:', error);
1357
- process.exit(1);
1358
- });