247 lines
9.4 KiB
TypeScript
247 lines
9.4 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';
|
|
|
|
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<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: '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<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('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,
|
|
};
|
|
}
|