diff --git a/README.md b/README.md index 994140b..0ef446c 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,8 @@ Example config: ## Next Steps -- Add persistent storage for job state -- Repo creation + git push for generated MCPs -- Systemd services per generated MCP - Security controls / rate limits +- More advanced tool scaffolding (multi-tool, custom APIs) --- diff --git a/src/config.ts b/src/config.ts index d66654b..9edd789 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,9 +5,14 @@ export type MetaMcpConfig = { gitea?: { baseUrl: string; // e.g. https://git.schlaustronics.com owner: string; // username or org + ownerType?: 'user' | 'org'; token: string; // personal access token defaultVisibility?: 'private' | 'public'; }; + git?: { + userName?: string; + userEmail?: string; + }; outputDir?: string; // where new MCP repos are generated cvmi?: { seedPath?: string; // path to seed.json with nsec + relays diff --git a/src/pipeline.ts b/src/pipeline.ts index 3ddc015..a9a471b 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -5,6 +5,67 @@ 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(); + 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', ['init', '-b', 'main'], 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}\nEnvironment=CVMI_SERVE_PRIVATE_KEY=${seed.nsec}\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 run(cmd: string, args: string[], cwd?: string): Promise { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -104,10 +165,24 @@ export async function runPipeline(jobId: string, request: MetaRequest): Promise< updateJob(jobId, { status: 'building', message: `Building ${repoName}` }); await run('npm', ['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}` }); const env = { ...process.env, CVMI_SERVE_PRIVATE_KEY: seed.nsec }; const cvmiArgs = ['-y', 'cvmi', 'serve', '--', 'node', 'dist/server.js']; @@ -134,6 +209,9 @@ export async function runPipeline(jobId: string, request: MetaRequest): Promise< updateJob(jobId, { logs: [text] }); }); + // create a systemd service to keep the MCP alive + await createSystemdService(repoName, repoDir, repoSeedPath); + return { status: 'running', message: `Started ${repoName}. Awaiting pubkey...`, diff --git a/src/storage.ts b/src/storage.ts index 0fbcc88..0ea0006 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,4 +1,6 @@ import { randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; import type { MetaRequest, MetaResponse } from './types.js'; export type Job = { @@ -9,8 +11,26 @@ export type Job = { response: MetaResponse; }; +const storagePath = '/root/.meta-mcp/jobs.json'; const jobs = new Map(); +function persist() { + const dir = path.dirname(storagePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(storagePath, JSON.stringify([...jobs.values()], null, 2)); +} + +function hydrate() { + if (!fs.existsSync(storagePath)) return; + const raw = fs.readFileSync(storagePath, 'utf8'); + const parsed = JSON.parse(raw) as Job[]; + for (const job of parsed) { + jobs.set(job.id, job); + } +} + +hydrate(); + export function createJob(request: MetaRequest): Job { const id = randomUUID(); const now = Date.now(); @@ -21,6 +41,7 @@ export function createJob(request: MetaRequest): Job { }; const job: Job = { id, request, createdAt: now, updatedAt: now, response }; jobs.set(id, job); + persist(); return job; } @@ -28,7 +49,12 @@ export function updateJob(id: string, patch: Partial): Job | null const job = jobs.get(id); if (!job) return null; job.updatedAt = Date.now(); + if (patch.logs?.length) { + const existing = job.response.logs ?? []; + patch.logs = [...existing, ...patch.logs].slice(-200); + } job.response = { ...job.response, ...patch }; + persist(); return job; }