import fs from 'fs'; import path from 'path'; import { spawn } from 'child_process'; import { loadConfig, ensureDir } from './config.js'; import type { MetaRequest, MetaResponse } from './types.js'; import { updateJob } from './storage.js'; async function createGiteaRepo(name: string) { const config = loadConfig(); if (!config.gitea) return null; const owner = config.gitea.owner; const ownerType = config.gitea.ownerType ?? 'user'; const baseUrl = config.gitea.baseUrl.replace(/\/$/, ''); const apiBase = `${baseUrl}/api/v1`; const headers = { 'Content-Type': 'application/json', Authorization: `token ${config.gitea.token}`, } as const; const body = JSON.stringify({ name, private: config.gitea.defaultVisibility !== 'public', auto_init: false, }); const url = ownerType === 'org' ? `${apiBase}/orgs/${owner}/repos` : `${apiBase}/user/repos`; const res = await fetch(url, { method: 'POST', headers, body }); if (!res.ok) { const text = await res.text(); throw new Error(`Gitea repo create failed: ${res.status} ${text}`); } return `${baseUrl}/${owner}/${name}`; } async function gitInitAndPush(repoDir: string, repoUrl: string, token: string) { const config = loadConfig(); await run('git', ['init', '-b', 'main'], repoDir); if (config.git?.userName) await run('git', ['config', 'user.name', config.git.userName], repoDir); if (config.git?.userEmail) await run('git', ['config', 'user.email', config.git.userEmail], repoDir); await run('git', ['add', '.'], repoDir); await run('git', ['commit', '-m', 'Initial generated MCP server'], repoDir); const remote = repoUrl.replace('https://', `https://${encodeURIComponent(token)}@`); await run('git', ['remote', 'add', 'origin', remote], repoDir); await run('git', ['push', '-u', 'origin', 'main'], repoDir); } function toServiceName(repoName: string) { return `mcp-${repoName}`.replace(/[^a-zA-Z0-9_.-]/g, '-'); } async function createSystemdService(repoName: string, repoDir: string, seedPath: string) { const serviceName = toServiceName(repoName); const servicePath = `/etc/systemd/system/${serviceName}.service`; const seed = JSON.parse(fs.readFileSync(seedPath, 'utf8')) as { nsec: string; relays: string[] }; const service = `[Unit]\nDescription=Generated MCP ${repoName}\nAfter=network.target\n\n[Service]\nWorkingDirectory=${repoDir}\nExecStart=/usr/bin/npx -y cvmi serve -- /usr/bin/node ${repoDir}/dist/server.js\nRestart=always\nRestartSec=3\nStandardOutput=journal\nStandardError=journal\n\n[Install]\nWantedBy=multi-user.target\n`; fs.writeFileSync(servicePath, service); await run('systemctl', ['daemon-reload']); await run('systemctl', ['enable', serviceName]); await run('systemctl', ['restart', serviceName]); return serviceName; } function appendNotification(payload: { serviceName: string; description: string; pubkey?: string }) { const file = '/root/.meta-mcp/notifications.jsonl'; const dir = path.dirname(file); fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...payload, }) + '\n'); } function run(cmd: string, args: string[], cwd?: string): Promise { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; let err = ''; child.stdout.on('data', (d) => (out += d.toString())); child.stderr.on('data', (d) => (err += d.toString())); child.on('close', (code) => { if (code === 0) return resolve(out.trim()); reject(new Error(`${cmd} ${args.join(' ')} failed (${code})\n${err}`)); }); }); } function slugify(value: string) { return value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)+/g, '') .slice(0, 48); } function scaffoldServer(repoDir: string, request: MetaRequest) { const name = request.requirements[0]?.name ?? 'generated-tool'; const toolDesc = request.requirements[0]?.description ?? 'Generated tool'; const pkg = { name: name.replace(/[^a-z0-9\-]/gi, '-').toLowerCase(), version: '0.1.0', type: 'module', scripts: { build: 'tsc -p tsconfig.json', start: 'node dist/server.js', dev: 'bun --watch src/server.ts', }, dependencies: { '@modelcontextprotocol/sdk': '^1.27.1', zod: '^3.25.0', }, devDependencies: { typescript: '^5.6.3', '@types/node': '^22.12.0' }, }; const tsconfig = { compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', outDir: 'dist', rootDir: 'src', strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, types: ['node'], }, include: ['src/**/*.ts'], }; const toolName = name; const rawSchema = request.requirements[0]?.inputSchema ?? { type: 'object', properties: {}, }; const inputSchemaJson = JSON.stringify(rawSchema, null, 2); const serverCode = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { z } from 'zod';\n\nconst server = new McpServer({ name: '${name}', version: '0.1.0' });\n\n// NOTE: JSON Schema -> Zod conversion is not implemented yet.\n// We accept any input for now and keep the JSON Schema for reference.\nconst InputSchema = z.any();\nconst InputSchemaJson = ${inputSchemaJson};\n\nserver.registerTool('${toolName}', {\n description: '${toolDesc.replace(/'/g, "\\'")}',\n inputSchema: InputSchema\n}, async (args) => {\n // TODO: implement tool logic\n return {\n content: [{ type: 'text', text: JSON.stringify({ ok: true, args, schema: InputSchemaJson }, null, 2) }]\n };\n});\n\nasync function main() {\n const transport = new StdioServerTransport();\n await server.connect(transport);\n}\n\nmain().catch((err) => {\n console.error('Server failed', err);\n process.exit(1);\n});\n`; const readme = `# ${name}\n\nAuto-generated MCP server from Meta MCP.\n\n## Tool\n- **${toolName}**: ${toolDesc}\n\n## Dev\n\n\ \ \ \ \`\`\`bash\nnpm install\nnpm run dev\n\`\`\`\n`; ensureDir(path.join(repoDir, 'src')); fs.writeFileSync(path.join(repoDir, 'package.json'), JSON.stringify(pkg, null, 2)); fs.writeFileSync(path.join(repoDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); fs.writeFileSync(path.join(repoDir, 'README.md'), readme); fs.writeFileSync(path.join(repoDir, 'src/server.ts'), serverCode); fs.writeFileSync(path.join(repoDir, '.gitignore'), 'node_modules\n/dist\n.env\n'); } export async function runPipeline(jobId: string, request: MetaRequest): Promise { const config = loadConfig(); const outputDir = config.outputDir ?? '/root/.openclaw/workspace/generated'; ensureDir(outputDir); const baseName = request.requirements[0]?.name ?? 'generated-tool'; const repoName = `${slugify(baseName)}-${jobId.slice(0, 8)}`; const repoDir = path.join(outputDir, repoName); updateJob(jobId, { status: 'building', message: `Scaffolding ${repoName}` }); scaffoldServer(repoDir, request); updateJob(jobId, { status: 'building', message: `Installing dependencies in ${repoName}` }); await run('bun', ['install'], repoDir); updateJob(jobId, { status: 'building', message: `Building ${repoName}` }); await run('bun', ['run', 'build'], repoDir); // Create repo in Gitea + push if (config.gitea) { updateJob(jobId, { status: 'building', message: `Creating repo ${repoName}` }); const repoUrl = await createGiteaRepo(repoName); if (repoUrl) { await gitInitAndPush(repoDir, repoUrl, config.gitea.token); updateJob(jobId, { status: 'building', message: `Pushed ${repoUrl}`, repoUrl }); } } const seedPath = config.cvmi?.seedPath ?? '/root/.cvmi/seed.json'; const seedRaw = fs.readFileSync(seedPath, 'utf8'); const seed = JSON.parse(seedRaw) as { nsec: string; relays: string[] }; // write per-repo seed file so systemd can read it const repoSeedPath = path.join(repoDir, '.cvmi-seed.json'); fs.writeFileSync(repoSeedPath, JSON.stringify(seed, null, 2)); updateJob(jobId, { status: 'running', message: `Starting CVMI for ${repoName} (auto-generated keys)` }); const env = { ...process.env }; const cvmiArgs = ['-y', 'cvmi', 'serve', '--', 'node', 'dist/server.js']; const child = spawn('npx', cvmiArgs, { cwd: repoDir, env, stdio: 'pipe' }); const serviceName = toServiceName(repoName); const desc = request.requirements[0]?.description ?? repoName; let pubkey: string | undefined; let notified = false; child.stdout.on('data', (d) => { const text = d.toString(); if (!pubkey) { const match = text.match(/Public key:\s*([0-9a-f]{64})/i); if (match) { pubkey = match[1]; updateJob(jobId, { status: 'running', message: `Server running for ${repoName}`, serverPubkey: pubkey, }); if (!notified) { appendNotification({ serviceName, description: desc, pubkey }); notified = true; } } } }); child.stderr.on('data', (d) => { const text = d.toString(); updateJob(jobId, { logs: [text] }); }); // create a systemd service to keep the MCP alive await createSystemdService(repoName, repoDir, repoSeedPath); if (!notified) { appendNotification({ serviceName, description: desc }); } return { status: 'running', message: `Started ${repoName}. Awaiting pubkey...`, jobId, serverPubkey: pubkey, }; }