UNPKG

mlgym-deploy

Version:

MCP server for GitLab Backend - User creation and project deployment

1,279 lines (1,156 loc) 38.1 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import axios from 'axios'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { exec } from 'child_process'; import { promisify } from 'util'; import crypto from 'crypto'; const execAsync = promisify(exec); // Current version of this MCP server - INCREMENT FOR WORKFLOW FIXES const CURRENT_VERSION = '2.7.1'; const PACKAGE_NAME = 'mlgym-deploy'; // Version check state let versionCheckResult = null; let lastVersionCheck = 0; const VERSION_CHECK_INTERVAL = 3600000; // Check once per hour // Configuration const CONFIG = { backend_url: process.env.MLGYM_API_ENDPOINT || 'https://backend.eu.ezb.net', gitlab_url: 'https://git.mlgym.io', coolify_url: 'https://coolify.eu.ezb.net', config_file: path.join(os.homedir(), '.mlgym', 'mcp_config.json') }; // Helper to load/save authentication async function loadAuth() { try { const data = await fs.readFile(CONFIG.config_file, 'utf8'); return JSON.parse(data); } catch { return { token: null, email: null }; } } async function saveAuth(email, token) { const dir = path.dirname(CONFIG.config_file); await fs.mkdir(dir, { recursive: true }); await fs.writeFile( CONFIG.config_file, JSON.stringify({ email, token, timestamp: new Date().toISOString() }, null, 2) ); } // API client helper async function apiRequest(method, endpoint, data = null, useAuth = true) { const config = { method, url: `${CONFIG.backend_url}${endpoint}`, headers: { 'Content-Type': 'application/json' } }; if (useAuth) { const auth = await loadAuth(); if (auth.token) { config.headers['Authorization'] = `Bearer ${auth.token}`; } } if (data) { config.data = data; } try { const response = await axios(config); return { success: true, data: response.data }; } catch (error) { const errorData = error.response?.data || { error: error.message }; return { success: false, error: errorData.error || errorData.message || 'Request failed' }; } } // Helper to generate random password function generateRandomPassword() { const chars = 'ABCDEFGHJKLMNPQRSTWXYZabcdefghjkmnpqrstwxyz23456789!@#$%^&*'; let password = ''; for (let i = 0; i < 16; i++) { password += chars.charAt(Math.floor(Math.random() * chars.length)); } return password; } // SSH key generation async function generateSSHKeyPair(email) { const sshDir = path.join(os.homedir(), '.ssh'); await fs.mkdir(sshDir, { recursive: true, mode: 0o700 }); const sanitizedEmail = email.replace('@', '_at_').replace(/[^a-zA-Z0-9_-]/g, '_'); const keyPath = path.join(sshDir, `mlgym_${sanitizedEmail}`); try { await fs.access(keyPath); console.error(`SSH key already exists at ${keyPath}, using existing key`); const publicKey = await fs.readFile(`${keyPath}.pub`, 'utf8'); return { publicKey: publicKey.trim(), privateKeyPath: keyPath }; } catch { // Key doesn't exist, generate new one } const { stdout, stderr } = await execAsync( `ssh-keygen -t ed25519 -f "${keyPath}" -N "" -C "${email}"`, { timeout: 10000 } ); if (stderr && !stderr.includes('Generating public/private')) { throw new Error(`SSH key generation failed: ${stderr}`); } await execAsync(`chmod 600 "${keyPath}"`); await execAsync(`chmod 644 "${keyPath}.pub"`); const publicKey = await fs.readFile(`${keyPath}.pub`, 'utf8'); // Add to SSH config const configPath = path.join(sshDir, 'config'); const configEntry = ` # MLGym GitLab (added by mlgym-deploy) Host git.mlgym.io User git Port 22 IdentityFile ${keyPath} StrictHostKeyChecking no `; try { const existingConfig = await fs.readFile(configPath, 'utf8'); if (!existingConfig.includes('Host git.mlgym.io')) { await fs.appendFile(configPath, configEntry); } } catch { await fs.writeFile(configPath, configEntry, { mode: 0o600 }); } return { publicKey: publicKey.trim(), privateKeyPath: keyPath }; } // SECURE CONSOLIDATED AUTHENTICATION FUNCTION async function authenticate(args) { const { email, password, create_if_not_exists = false, full_name, accept_terms } = args; // Validate required fields if (!email || !password) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Email and password are required', required_fields: { email: email ? '✓ provided' : '✗ missing', password: password ? '✓ provided' : '✗ missing' } }, null, 2) }] }; } console.error(`Attempting authentication for: ${email}`); // Step 1: Always try login first (never reveal if account exists) try { const loginResult = await apiRequest('POST', '/api/v1/auth/login', { email, password }, false); if (loginResult.success && loginResult.data.token) { // Login successful - save token and setup SSH if needed await saveAuth(email, loginResult.data.token); // Check for SSH keys const keysResult = await apiRequest('GET', '/api/v1/keys', null, true); const sshKeys = keysResult.success ? keysResult.data : []; // If no SSH keys, generate and add one if (sshKeys.length === 0) { console.error('No SSH keys found, generating new key pair...'); const { publicKey, privateKeyPath } = await generateSSHKeyPair(email); const keyTitle = `mlgym-${new Date().toISOString().split('T')[0]}`; const keyResult = await apiRequest('POST', '/api/v1/keys', { title: keyTitle, key: publicKey }, true); if (keyResult.success) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'authenticated', message: 'Successfully logged in and SSH key configured', email: email, user_id: loginResult.data.user?.user_id, gitlab_username: loginResult.data.user?.gitlab_username, ssh_key_added: true, ssh_key_path: privateKeyPath, next_steps: [ 'Authentication successful', `SSH key generated at: ${privateKeyPath}`, 'You can now create projects and deploy' ] }, null, 2) }] }; } } // User has SSH keys already return { content: [{ type: 'text', text: JSON.stringify({ status: 'authenticated', message: 'Successfully logged in', email: email, user_id: loginResult.data.user?.user_id, gitlab_username: loginResult.data.user?.gitlab_username, ssh_keys_count: sshKeys.length, next_steps: [ 'Authentication successful', 'You can now create projects and deploy' ] }, null, 2) }] }; } } catch (error) { // Login failed - continue to next step console.error('Login attempt failed:', error.message); } // Step 2: Login failed - check if we should create account if (create_if_not_exists) { // Validate required fields for account creation if (!full_name) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Account creation requires full_name', note: 'Login failed. To create a new account, provide full_name and accept_terms=true' }, null, 2) }] }; } if (!accept_terms) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'You must accept the terms and conditions to create an account', note: 'Set accept_terms=true to proceed with account creation' }, null, 2) }] }; } console.error(`Creating new account for: ${email}`); // Try to create account try { const createResult = await apiRequest('POST', '/api/v1/users', { email, name: full_name, password }, false); if (createResult.success) { // Account created, now login const loginResult = await apiRequest('POST', '/api/v1/auth/login', { email, password }, false); if (loginResult.success && loginResult.data.token) { await saveAuth(email, loginResult.data.token); // Generate and add SSH key for new user console.error('Generating SSH key for new user...'); const { publicKey, privateKeyPath } = await generateSSHKeyPair(email); const keyTitle = `mlgym-${new Date().toISOString().split('T')[0]}`; await apiRequest('POST', '/api/v1/keys', { title: keyTitle, key: publicKey }, true); return { content: [{ type: 'text', text: JSON.stringify({ status: 'authenticated', message: 'Account created successfully and logged in', email: email, user_id: loginResult.data.user?.user_id, gitlab_username: loginResult.data.user?.gitlab_username, ssh_key_path: privateKeyPath, next_steps: [ 'New account created', `SSH key generated at: ${privateKeyPath}`, 'You can now create projects and deploy' ] }, null, 2) }] }; } } } catch (error) { // Account creation failed - generic error console.error('Account creation failed:', error.message); return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Account creation failed. The email may already be in use or password requirements not met.', requirements: { password: 'Minimum 8 characters with mixed case, numbers, and special characters', email: 'Valid email address not already registered' } }, null, 2) }] }; } } // Step 3: Generic authentication failure (never reveal if account exists) return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Authentication failed. Invalid credentials.', hint: 'If you need to create a new account, set create_if_not_exists=true and provide full_name and accept_terms=true' }, null, 2) }] }; } // Analyze project to detect type, framework, and configuration async function analyzeProject(local_path = '.') { const absolutePath = path.resolve(local_path); const dirName = path.basename(absolutePath); const analysis = { project_type: 'unknown', detected_files: [], suggested_name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'), has_dockerfile: false, has_git: false, framework: null, build_command: null, start_command: null, package_manager: null }; try { // Check for git try { await execAsync('git status', { cwd: absolutePath }); analysis.has_git = true; } catch {} // Check for Dockerfile try { await fs.access(path.join(absolutePath, 'Dockerfile')); analysis.has_dockerfile = true; analysis.detected_files.push('Dockerfile'); } catch {} // Check for Node.js project try { const packageJsonPath = path.join(absolutePath, 'package.json'); await fs.access(packageJsonPath); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); analysis.project_type = 'nodejs'; analysis.detected_files.push('package.json'); analysis.suggested_name = packageJson.name || analysis.suggested_name; // Detect package manager try { await fs.access(path.join(absolutePath, 'package-lock.json')); analysis.package_manager = 'npm'; analysis.detected_files.push('package-lock.json'); } catch { try { await fs.access(path.join(absolutePath, 'yarn.lock')); analysis.package_manager = 'yarn'; analysis.detected_files.push('yarn.lock'); } catch { analysis.package_manager = 'npm'; // default } } // Detect framework const deps = { ...packageJson.dependencies, ...packageJson.devDependencies }; if (deps.next) { analysis.framework = 'nextjs'; analysis.build_command = packageJson.scripts?.build || 'npm run build'; analysis.start_command = packageJson.scripts?.start || 'npm start'; } else if (deps.express) { analysis.framework = 'express'; analysis.start_command = packageJson.scripts?.start || 'node index.js'; } else if (deps.react) { analysis.framework = 'react'; analysis.build_command = packageJson.scripts?.build || 'npm run build'; } else if (deps.vue) { analysis.framework = 'vue'; analysis.build_command = packageJson.scripts?.build || 'npm run build'; } // Use package.json scripts as fallback if (!analysis.build_command && packageJson.scripts?.build) { analysis.build_command = 'npm run build'; } if (!analysis.start_command && packageJson.scripts?.start) { analysis.start_command = 'npm start'; } } catch {} // Check for Python project if (analysis.project_type === 'unknown') { try { await fs.access(path.join(absolutePath, 'requirements.txt')); analysis.project_type = 'python'; analysis.detected_files.push('requirements.txt'); // Check for specific Python files try { await fs.access(path.join(absolutePath, 'app.py')); analysis.framework = 'flask'; analysis.start_command = 'python app.py'; analysis.detected_files.push('app.py'); } catch { try { await fs.access(path.join(absolutePath, 'main.py')); analysis.framework = 'fastapi'; analysis.start_command = 'uvicorn main:app --host 0.0.0.0'; analysis.detected_files.push('main.py'); } catch {} } } catch {} } // Check for static HTML project if (analysis.project_type === 'unknown') { try { await fs.access(path.join(absolutePath, 'index.html')); analysis.project_type = 'static'; analysis.framework = 'html'; analysis.detected_files.push('index.html'); } catch {} } // Check for Go project if (analysis.project_type === 'unknown') { try { await fs.access(path.join(absolutePath, 'go.mod')); analysis.project_type = 'go'; analysis.detected_files.push('go.mod'); analysis.build_command = 'go build -o app'; analysis.start_command = './app'; } catch {} } } catch (error) { console.error('Project analysis error:', error); } return analysis; } // Check for existing MLGym project in current directory async function checkExistingProject(local_path = '.') { const absolutePath = path.resolve(local_path); // Check for git repository try { const { stdout: remotes } = await execAsync('git remote -v', { cwd: absolutePath }); // Check for mlgym remote if (remotes.includes('mlgym') && remotes.includes('git.mlgym.io')) { // Extract project info from remote URL const match = remotes.match(/mlgym\s+git@git\.mlgym\.io:([^\/]+)\/([^\.]+)\.git/); if (match) { const [, namespace, projectName] = match; return { exists: true, name: projectName, namespace: namespace, configured: true, message: `Project '${projectName}' already configured for MLGym deployment` }; } } // Git repo exists but no mlgym remote return { exists: false, has_git: true, configured: false, message: 'Git repository exists but no MLGym remote configured' }; } catch { // No git repository return { exists: false, has_git: false, configured: false, message: 'No git repository found in current directory' }; } } // Generate appropriate Dockerfile based on project type function generateDockerfile(projectType, framework, packageManager = 'npm') { let dockerfile = ''; if (projectType === 'nodejs') { if (framework === 'nextjs') { dockerfile = `# Build stage FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN ${packageManager} ${packageManager === 'npm' ? 'ci' : 'install --frozen-lockfile'} COPY . . RUN ${packageManager} run build # Production stage FROM node:18-alpine WORKDIR /app COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./ COPY --from=builder /app/public ./public EXPOSE 3000 CMD ["${packageManager}", "start"]`; } else if (framework === 'express') { dockerfile = `FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN ${packageManager} ${packageManager === 'npm' ? 'ci --only=production' : 'install --frozen-lockfile --production'} COPY . . EXPOSE 3000 CMD ["node", "index.js"]`; } else if (framework === 'react' || framework === 'vue') { dockerfile = `# Build stage FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN ${packageManager} ${packageManager === 'npm' ? 'ci' : 'install --frozen-lockfile'} COPY . . RUN ${packageManager} run build # Production stage FROM nginx:alpine COPY --from=builder /app/${framework === 'react' ? 'build' : 'dist'} /usr/share/nginx/html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]`; } else { // Generic Node.js dockerfile = `FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN ${packageManager} ${packageManager === 'npm' ? 'ci --only=production' : 'install --frozen-lockfile --production'} COPY . . EXPOSE 3000 CMD ["${packageManager}", "start"]`; } } else if (projectType === 'python') { if (framework === 'flask') { dockerfile = `FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 5000 CMD ["python", "app.py"]`; } else if (framework === 'fastapi') { dockerfile = `FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]`; } else { // Generic Python dockerfile = `FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["python", "main.py"]`; } } else if (projectType === 'static') { dockerfile = `FROM nginx:alpine COPY . /usr/share/nginx/html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]`; } else if (projectType === 'go') { dockerfile = `# Build stage FROM golang:1.21-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o app # Production stage FROM alpine:latest RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /app/app . EXPOSE 8080 CMD ["./app"]`; } else { // Unknown type - basic Alpine with shell dockerfile = `FROM alpine:latest WORKDIR /app COPY . . RUN echo "Unknown project type - please configure manually" CMD ["/bin/sh"]`; } return dockerfile; } // Prepare project for deployment async function prepareProject(args) { const { local_path = '.', project_type, framework, package_manager } = args; const absolutePath = path.resolve(local_path); const actions = []; try { // Check if Dockerfile exists const dockerfilePath = path.join(absolutePath, 'Dockerfile'); let dockerfileExists = false; try { await fs.access(dockerfilePath); dockerfileExists = true; actions.push('Dockerfile already exists - skipping generation'); } catch {} // Generate Dockerfile if missing if (!dockerfileExists && project_type !== 'unknown') { const dockerfile = generateDockerfile(project_type, framework, package_manager); await fs.writeFile(dockerfilePath, dockerfile); actions.push(`Generated Dockerfile for ${project_type}/${framework || 'generic'}`); } // Check/create .gitignore const gitignorePath = path.join(absolutePath, '.gitignore'); let gitignoreExists = false; try { await fs.access(gitignorePath); gitignoreExists = true; } catch {} if (!gitignoreExists) { let gitignoreContent = ''; if (project_type === 'nodejs') { gitignoreContent = `node_modules/ .env .env.local dist/ build/ .next/ *.log`; } else if (project_type === 'python') { gitignoreContent = `__pycache__/ *.py[cod] *$py.class .env venv/ env/ .venv/`; } else { gitignoreContent = `.env *.log .DS_Store`; } await fs.writeFile(gitignorePath, gitignoreContent); actions.push('Created .gitignore file'); } return { status: 'success', message: 'Project prepared for deployment', actions: actions, dockerfile_created: !dockerfileExists && project_type !== 'unknown', project_type: project_type, framework: framework }; } catch (error) { return { status: 'error', message: 'Failed to prepare project', error: error.message, actions: actions }; } } // Check authentication status async function checkAuthStatus() { const auth = await loadAuth(); if (!auth.token) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'not_authenticated', message: 'No authentication found. Please use mlgym_authenticate first.', next_step: 'Call mlgym_authenticate with email and password' }, null, 2) }] }; } // Verify token is still valid try { const result = await apiRequest('GET', '/api/v1/user', null, true); if (result.success) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'authenticated', email: auth.email, message: 'Authentication valid', user: result.data, next_step: 'You can now use mlgym_project_init to create a project' }, null, 2) }] }; } } catch (error) { // Token might be expired } return { content: [{ type: 'text', text: JSON.stringify({ status: 'token_expired', message: 'Authentication token expired. Please authenticate again.', next_step: 'Call mlgym_authenticate with email and password' }, null, 2) }] }; } // Smart deployment initialization that follows the correct workflow async function smartDeploy(args) { const { local_path = '.' } = args; const absolutePath = path.resolve(local_path); const steps = []; try { // Step 1: Check authentication steps.push({ step: 'auth_check', status: 'running' }); const auth = await loadAuth(); if (!auth.token) { steps[steps.length - 1].status = 'failed'; return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Not authenticated. Please use mlgym_authenticate first', workflow_steps: steps }, null, 2) }] }; } steps[steps.length - 1].status = 'completed'; // Step 2: Analyze project steps.push({ step: 'project_analysis', status: 'running' }); const analysis = await analyzeProject(local_path); steps[steps.length - 1].status = 'completed'; steps[steps.length - 1].result = { type: analysis.project_type, framework: analysis.framework, suggested_name: analysis.suggested_name }; // Step 3: Check existing project steps.push({ step: 'check_existing', status: 'running' }); const projectStatus = await checkExistingProject(local_path); steps[steps.length - 1].status = 'completed'; if (projectStatus.configured) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'info', message: projectStatus.message, project_name: projectStatus.name, git_remote: `git@git.mlgym.io:${projectStatus.namespace}/${projectStatus.name}.git`, next_steps: [ 'Project already configured', 'Run: git push mlgym main' ], workflow_steps: steps }, null, 2) }] }; } // Step 4: Prepare project (generate Dockerfile if needed) steps.push({ step: 'prepare_project', status: 'running' }); if (!analysis.has_dockerfile && analysis.project_type !== 'unknown') { const prepResult = await prepareProject({ local_path, project_type: analysis.project_type, framework: analysis.framework, package_manager: analysis.package_manager }); steps[steps.length - 1].status = 'completed'; steps[steps.length - 1].result = prepResult.actions; } else { steps[steps.length - 1].status = 'skipped'; steps[steps.length - 1].result = 'Dockerfile already exists or project type unknown'; } // Return analysis and next steps return { content: [{ type: 'text', text: JSON.stringify({ status: 'ready', message: 'Project analyzed and prepared. Ready for MLGym initialization.', analysis: { project_type: analysis.project_type, framework: analysis.framework, suggested_name: analysis.suggested_name, has_dockerfile: analysis.has_dockerfile || true }, next_step: 'Use mlgym_project_init with project details to create MLGym project', suggested_params: { name: analysis.suggested_name, description: `${analysis.framework || analysis.project_type} application`, enable_deployment: true, hostname: analysis.suggested_name }, workflow_steps: steps }, null, 2) }] }; } catch (error) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Smart deploy failed', error: error.message, workflow_steps: steps }, null, 2) }] }; } } // Initialize Project (requires authentication) async function initProject(args) { let { name, description, enable_deployment = true, hostname, local_path = '.' } = args; // Validate required fields if (!name || !description) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Project name and description are required', required_fields: { name: name ? '✓ provided' : '✗ missing', description: description ? '✓ provided' : '✗ missing' } }, null, 2) }] }; } if (enable_deployment && !hostname) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Hostname is required when deployment is enabled', required_fields: { hostname: '✗ missing - will be used as subdomain (e.g., "myapp" for myapp.ezb.net)' } }, null, 2) }] }; } // Check authentication const auth = await loadAuth(); if (!auth.token) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Not authenticated. Please use mlgym_authenticate first' }, null, 2) }] }; } console.error(`Creating project: ${name}`); // Create project via backend API const projectData = { name: name, description: description, enable_deployment: enable_deployment }; if (enable_deployment) { // Generate a secure webhook secret for deployments const webhookSecret = Array.from( crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0') ).join(''); projectData.webhook_secret = webhookSecret; if (hostname) { projectData.hostname = hostname; } } const result = await apiRequest('POST', '/api/v1/projects', projectData, true); if (!result.success) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: 'Failed to create project', error: result.error }, null, 2) }] }; } const project = result.data; // Initialize local git repository if needed const absolutePath = path.resolve(local_path); try { await execAsync('git status', { cwd: absolutePath }); console.error('Directory is already a git repository'); } catch { console.error('Initializing git repository...'); await execAsync('git init', { cwd: absolutePath }); await execAsync('git branch -M main', { cwd: absolutePath }); } // Add GitLab remote const gitUrl = project.ssh_url || `git@git.mlgym.io:${project.namespace}/${project.name}.git`; try { await execAsync('git remote remove mlgym', { cwd: absolutePath }); } catch {} await execAsync(`git remote add mlgym ${gitUrl}`, { cwd: absolutePath }); return { content: [{ type: 'text', text: JSON.stringify({ status: 'success', message: 'Project created successfully', project: { id: project.id, name: project.name, description: project.description, git_url: gitUrl, deployment_enabled: enable_deployment, deployment_url: hostname ? `https://${hostname}.ezb.net` : null }, next_steps: [ 'Add your code files', 'git add .', `git commit -m "Initial commit"`, 'git push mlgym main', enable_deployment ? 'Your app will be automatically deployed after push' : null ].filter(Boolean) }, null, 2) }] }; } // Create the MCP server const server = new Server( { name: 'mlgym-deploy', version: CURRENT_VERSION, }, { capabilities: { tools: {}, }, } ); // Handle tool listing server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'mlgym_auth_status', description: 'ALWAYS CALL THIS FIRST! Check authentication status before any other operation.', inputSchema: { type: 'object', properties: {} } }, { name: 'mlgym_authenticate', description: 'PHASE 1: Authentication ONLY. Get email, password, and existing account status in ONE interaction. Never ask for project details here!', inputSchema: { type: 'object', properties: { email: { type: 'string', description: 'Email address', pattern: '^[^@]+@[^@]+\\.[^@]+$' }, password: { type: 'string', description: 'Password (min 8 characters)', minLength: 8 }, create_if_not_exists: { type: 'boolean', description: 'If true and login fails, attempt to create new account', default: false }, full_name: { type: 'string', description: 'Full name (required only for new account creation)', minLength: 2 }, accept_terms: { type: 'boolean', description: 'Accept terms and conditions (required only for new account creation)', default: false } }, required: ['email', 'password'] } }, { name: 'mlgym_project_analyze', description: 'PHASE 2: Analyze project to detect type, framework, and configuration. Call BEFORE creating project.', inputSchema: { type: 'object', properties: { local_path: { type: 'string', description: 'Local directory path (defaults to current directory)', default: '.' } } } }, { name: 'mlgym_project_status', description: 'PHASE 2: Check if MLGym project exists in current directory.', inputSchema: { type: 'object', properties: { local_path: { type: 'string', description: 'Local directory path (defaults to current directory)', default: '.' } } } }, { name: 'mlgym_project_init', description: 'PHASE 2: Create project ONLY after checking project status. Never ask for email/password here - only project details!', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Project name (lowercase alphanumeric with hyphens)', pattern: '^[a-z0-9][a-z0-9-]*[a-z0-9]$', minLength: 3 }, description: { type: 'string', description: 'Project description', minLength: 10 }, enable_deployment: { type: 'boolean', description: 'Enable automatic deployment via Coolify', default: true }, hostname: { type: 'string', description: 'Hostname for deployment (required if deployment enabled, will be subdomain)', pattern: '^[a-z][a-z0-9-]*[a-z0-9]$', minLength: 3, maxLength: 63 }, local_path: { type: 'string', description: 'Local directory path (defaults to current directory)', default: '.' } }, required: ['name', 'description'] } }, { name: 'mlgym_project_prepare', description: 'PHASE 2: Prepare project for deployment by generating Dockerfile and config files.', inputSchema: { type: 'object', properties: { local_path: { type: 'string', description: 'Local directory path (defaults to current directory)', default: '.' }, project_type: { type: 'string', description: 'Project type from analysis', enum: ['nodejs', 'python', 'static', 'go', 'unknown'] }, framework: { type: 'string', description: 'Framework from analysis', enum: ['nextjs', 'express', 'react', 'vue', 'flask', 'fastapi', 'html', null] }, package_manager: { type: 'string', description: 'Package manager for Node.js projects', enum: ['npm', 'yarn'], default: 'npm' } } } }, { name: 'mlgym_smart_deploy', description: 'RECOMMENDED: Smart deployment workflow that automatically analyzes, prepares, and guides you through the entire deployment process. Use this for new projects!', inputSchema: { type: 'object', properties: { local_path: { type: 'string', description: 'Local directory path (defaults to current directory)', default: '.' } } } } ] }; }); // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; console.error(`Tool called: ${name}`); try { switch (name) { case 'mlgym_auth_status': return await checkAuthStatus(); case 'mlgym_authenticate': return await authenticate(args); case 'mlgym_project_analyze': const analysis = await analyzeProject(args.local_path); return { content: [{ type: 'text', text: JSON.stringify(analysis, null, 2) }] }; case 'mlgym_project_status': const projectStatus = await checkExistingProject(args.local_path); return { content: [{ type: 'text', text: JSON.stringify(projectStatus, null, 2) }] }; case 'mlgym_project_init': return await initProject(args); case 'mlgym_project_prepare': const prepResult = await prepareProject(args); return { content: [{ type: 'text', text: JSON.stringify(prepResult, null, 2) }] }; case 'mlgym_smart_deploy': return await smartDeploy(args); default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { console.error(`Tool execution failed:`, error); return { content: [{ type: 'text', text: JSON.stringify({ status: 'error', message: `Tool execution failed: ${error.message}` }, null, 2) }] }; } }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error(`MLGym MCP Server v${CURRENT_VERSION} started`); } main().catch((error) => { console.error('Server error:', error); process.exit(1); });