Files
meta-mcp/src/pipeline.ts
2026-03-12 10:19:11 +00:00

144 lines
5.3 KiB
TypeScript

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<string> {
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<MetaResponse> {
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,
};
}