#!/usr/bin/env node /** * AI Stack Deployer MCP Server * * Exposes deployment functionality through the Model Context Protocol, * allowing Claude Code and other MCP clients to deploy and manage AI stacks. */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, } from '@modelcontextprotocol/sdk/types.js'; import { createDokployClient } from './api/dokploy.js'; // Deployment state tracking interface DeploymentState { id: string; name: string; status: 'initializing' | 'creating_project' | 'creating_application' | 'deploying' | 'completed' | 'failed'; url?: string; error?: string; createdAt: Date; projectId?: string; applicationId?: string; } const deployments = new Map(); // Generate a unique deployment ID function generateDeploymentId(): string { return `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } // Validate stack name function validateStackName(name: string): { valid: boolean; error?: string } { if (!name || typeof name !== 'string') { return { valid: false, error: 'Name is required' }; } const trimmedName = name.trim().toLowerCase(); if (trimmedName.length < 3 || trimmedName.length > 20) { return { valid: false, error: 'Name must be between 3 and 20 characters' }; } if (!/^[a-z0-9-]+$/.test(trimmedName)) { return { valid: false, error: 'Name can only contain lowercase letters, numbers, and hyphens' }; } if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) { return { valid: false, error: 'Name cannot start or end with a hyphen' }; } const reservedNames = (process.env.RESERVED_NAMES || 'admin,api,www,root,system,test,demo,portal').split(','); if (reservedNames.includes(trimmedName)) { return { valid: false, error: `Name "${trimmedName}" is reserved` }; } return { valid: true }; } // Main deployment orchestration async function deployStack(name: string): Promise { const validation = validateStackName(name); if (!validation.valid) { throw new Error(validation.error); } const normalizedName = name.trim().toLowerCase(); const deploymentId = generateDeploymentId(); const deployment: DeploymentState = { id: deploymentId, name: normalizedName, status: 'initializing', createdAt: new Date(), }; deployments.set(deploymentId, deployment); try { // Initialize Dokploy client const dokployClient = createDokployClient(); const domain = `${normalizedName}.${process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl'}`; // Step 1: Create Dokploy project deployment.status = 'creating_project'; deployments.set(deploymentId, { ...deployment }); const projectName = `ai-stack-${normalizedName}`; let project = await dokployClient.findProjectByName(projectName); if (!project) { project = await dokployClient.createProject( projectName, `AI Stack for ${normalizedName}` ); } deployment.projectId = project.projectId; deployments.set(deploymentId, { ...deployment }); // Step 2: Create application deployment.status = 'creating_application'; deployments.set(deploymentId, { ...deployment }); const dockerImage = process.env.STACK_IMAGE || 'git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest'; const application = await dokployClient.createApplication( `opencode-${normalizedName}`, project.projectId, dockerImage ); deployment.applicationId = application.applicationId; deployments.set(deploymentId, { ...deployment }); // Step 3: Configure domain await dokployClient.createDomain( domain, application.applicationId, true, 8080 ); // Step 4: Deploy application deployment.status = 'deploying'; deployments.set(deploymentId, { ...deployment }); await dokployClient.deployApplication(application.applicationId); // Mark as completed deployment.status = 'completed'; deployment.url = `https://${domain}`; deployments.set(deploymentId, { ...deployment }); return deployment; } catch (error) { deployment.status = 'failed'; deployment.error = error instanceof Error ? error.message : 'Unknown error'; deployments.set(deploymentId, { ...deployment }); throw error; } } // MCP Server setup const server = new Server( { name: 'ai-stack-deployer', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); // Define available tools const tools: Tool[] = [ { name: 'deploy_stack', description: 'Deploy a new AI coding assistant stack for a user. Creates Dokploy project and application, configures domain (leverages pre-configured wildcard DNS and SSL), and deploys the OpenCode server.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Username for the stack (3-20 chars, lowercase alphanumeric and hyphens only)', }, }, required: ['name'], }, }, { name: 'check_deployment_status', description: 'Check the status of a deployment by its ID', inputSchema: { type: 'object', properties: { deploymentId: { type: 'string', description: 'The deployment ID returned from deploy_stack', }, }, required: ['deploymentId'], }, }, { name: 'list_deployments', description: 'List all recent deployments and their statuses', inputSchema: { type: 'object', properties: {}, }, }, { name: 'check_name_availability', description: 'Check if a stack name is available and valid', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'The name to check', }, }, required: ['name'], }, }, { name: 'test_api_connections', description: 'Test connection to Dokploy API', inputSchema: { type: 'object', properties: {}, }, }, ]; // Handle list_tools request server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools }; }); // Handle call_tool request server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'deploy_stack': { const { name: stackName } = args as { name: string }; const deployment = await deployStack(stackName); return { content: [ { type: 'text', text: JSON.stringify({ success: true, deploymentId: deployment.id, name: deployment.name, status: deployment.status, url: deployment.url, message: deployment.status === 'completed' ? `Stack successfully deployed at ${deployment.url}` : `Deployment in progress (status: ${deployment.status})`, }, null, 2), }, ], }; } case 'check_deployment_status': { const { deploymentId } = args as { deploymentId: string }; const deployment = deployments.get(deploymentId); if (!deployment) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: 'Deployment not found', }, null, 2), }, ], }; } return { content: [ { type: 'text', text: JSON.stringify({ success: true, deployment: { id: deployment.id, name: deployment.name, status: deployment.status, url: deployment.url, error: deployment.error, createdAt: deployment.createdAt, }, }, null, 2), }, ], }; } case 'list_deployments': { const allDeployments = Array.from(deployments.values()).map(d => ({ id: d.id, name: d.name, status: d.status, url: d.url, error: d.error, createdAt: d.createdAt, })); return { content: [ { type: 'text', text: JSON.stringify({ success: true, deployments: allDeployments, total: allDeployments.length, }, null, 2), }, ], }; } case 'check_name_availability': { const { name: stackName } = args as { name: string }; const validation = validateStackName(stackName); if (!validation.valid) { return { content: [ { type: 'text', text: JSON.stringify({ available: false, valid: false, error: validation.error, }, null, 2), }, ], }; } const normalizedName = stackName.trim().toLowerCase(); const dokployClient = createDokployClient(); const projectName = `ai-stack-${normalizedName}`; const existingProject = await dokployClient.findProjectByName(projectName); return { content: [ { type: 'text', text: JSON.stringify({ available: !existingProject, valid: true, name: normalizedName, }, null, 2), }, ], }; } case 'test_api_connections': { const dokployClient = createDokployClient(); const dokployTest = await dokployClient.testConnection(); return { content: [ { type: 'text', text: JSON.stringify({ dokploy: dokployTest, overall: dokployTest.success, note: 'Hetzner DNS is pre-configured with wildcard - no per-deployment DNS needed' }, null, 2), }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error', }, null, 2), }, ], isError: true, }; } }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('AI Stack Deployer MCP Server running on stdio'); } main().catch((error) => { console.error('Fatal error:', error); process.exit(1); });