From 6eabd3d3de5308937c57906d1a58368bdc9aa000 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Mar 2026 10:19:11 +0000 Subject: [PATCH] Add basic build/deploy pipeline --- README.md | 27 +++++++-- src/config.ts | 44 +++++++++++++++ src/pipeline.ts | 143 ++++++++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 16 ++++-- 4 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 src/config.ts create mode 100644 src/pipeline.ts diff --git a/README.md b/README.md index 52f8d7b..994140b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Meta MCP is a ContextVM server that accepts **requirements** for new MCP tools and returns a job id. It is intended to be used by other AIs that only have access to a single "meta" tool. The meta MCP then builds, deploys, and announces a new MCP server via Nostr (ContextVM). -This repo contains the **server-side** MCP for the meta tool. It currently implements the request + job tracking surface; the build pipeline is stubbed and will be wired to the ContextVM/CVMI deployment flow next. +This repo contains the **server-side** MCP for the meta tool. It implements the request + job tracking surface and a simple build/deploy pipeline (scaffold → build → announce via CVMI). --- @@ -86,12 +86,31 @@ You can set `CVMI_SERVE_*` env vars to configure relays, encryption, and keys. --- +## Pipeline + +The pipeline is currently minimal and runs locally: + +1) Scaffold a new MCP server in `outputDir` +2) `npm install` + `npm run build` +3) Run `npx cvmi serve -- node dist/server.js` +4) Parse and store the announced pubkey + +Configuration is read from `/root/.meta-mcp/config.json` (or `META_MCP_CONFIG`). + +Example config: +```json +{ + "outputDir": "/root/.openclaw/workspace/generated", + "cvmi": { "seedPath": "/root/.cvmi/seed.json" } +} +``` + ## Next Steps -- Wire this server to a build/deploy pipeline - Add persistent storage for job state -- Auto-create repos + scaffolds for new MCPs -- Auto-start new MCP servers and announce via CVMI +- Repo creation + git push for generated MCPs +- Systemd services per generated MCP +- Security controls / rate limits --- diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d66654b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,44 @@ +import fs from 'fs'; +import path from 'path'; + +export type MetaMcpConfig = { + gitea?: { + baseUrl: string; // e.g. https://git.schlaustronics.com + owner: string; // username or org + token: string; // personal access token + defaultVisibility?: 'private' | 'public'; + }; + outputDir?: string; // where new MCP repos are generated + cvmi?: { + seedPath?: string; // path to seed.json with nsec + relays + }; +}; + +const DEFAULTS: MetaMcpConfig = { + outputDir: '/root/.openclaw/workspace/generated', + cvmi: { + seedPath: '/root/.cvmi/seed.json', + }, +}; + +export function loadConfig(): MetaMcpConfig { + const configPath = process.env.META_MCP_CONFIG || '/root/.meta-mcp/config.json'; + if (!fs.existsSync(configPath)) { + return DEFAULTS; + } + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw) as MetaMcpConfig; + return { + ...DEFAULTS, + ...parsed, + cvmi: { ...DEFAULTS.cvmi, ...parsed.cvmi }, + }; +} + +export function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +export function resolvePath(...parts: string[]) { + return path.resolve(...parts); +} diff --git a/src/pipeline.ts b/src/pipeline.ts new file mode 100644 index 0000000..3ddc015 --- /dev/null +++ b/src/pipeline.ts @@ -0,0 +1,143 @@ +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'; + +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: 'ts-node --esm src/server.ts', + }, + dependencies: { + '@modelcontextprotocol/sdk': '^1.27.1', + zod: '^3.25.0', + }, + devDependencies: { + 'ts-node': '^10.9.2', + typescript: '^5.6.3', + }, + }; + + const tsconfig = { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + outDir: 'dist', + rootDir: 'src', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + }, + include: ['src/**/*.ts'], + }; + + const toolName = name; + const inputSchema = JSON.stringify(request.requirements[0]?.inputSchema ?? { + type: 'object', + properties: {}, + }); + + 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\nconst InputSchema = z.object(${inputSchema});\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 }, 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('npm', ['install'], repoDir); + + updateJob(jobId, { status: 'building', message: `Building ${repoName}` }); + await run('npm', ['run', 'build'], repoDir); + + 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[] }; + + updateJob(jobId, { status: 'running', message: `Starting CVMI for ${repoName}` }); + const env = { ...process.env, CVMI_SERVE_PRIVATE_KEY: seed.nsec }; + const cvmiArgs = ['-y', 'cvmi', 'serve', '--', 'node', 'dist/server.js']; + const child = spawn('npx', cvmiArgs, { cwd: repoDir, env, stdio: 'pipe' }); + + let pubkey: string | undefined; + 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, + }); + } + } + }); + + child.stderr.on('data', (d) => { + const text = d.toString(); + updateJob(jobId, { logs: [text] }); + }); + + return { + status: 'running', + message: `Started ${repoName}. Awaiting pubkey...`, + jobId, + serverPubkey: pubkey, + }; +} diff --git a/src/server.ts b/src/server.ts index 658a2bb..97754ce 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,11 +3,12 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { createJob, getJob, listJobs, updateJob } from './storage.js'; import type { MetaRequest, MetaResponse } from './types.js'; +import { runPipeline } from './pipeline.js'; const server = new McpServer( { name: 'meta-mcp', - version: '0.1.0', + version: '0.2.0', }, { capabilities: { @@ -48,15 +49,22 @@ server.registerTool( const parsed = MetaRequestSchema.parse(args); const job = createJob(parsed as MetaRequest); - // TODO: Hook into build/deploy pipeline updateJob(job.id, { status: 'building', - message: 'Accepted. Building MCP server (pipeline stub).', + message: 'Accepted. Starting build pipeline.', + }); + + // Run pipeline asynchronously + runPipeline(job.id, parsed as MetaRequest).catch((err) => { + updateJob(job.id, { + status: 'failed', + message: err instanceof Error ? err.message : String(err), + }); }); const response: MetaResponse = { status: 'building', - message: 'Accepted. Building MCP server (pipeline stub).', + message: 'Accepted. Starting build pipeline.', jobId: job.id, };