Persist jobs, push repos, systemd for generated MCPs
This commit is contained in:
@@ -107,10 +107,8 @@ Example config:
|
|||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- Add persistent storage for job state
|
|
||||||
- Repo creation + git push for generated MCPs
|
|
||||||
- Systemd services per generated MCP
|
|
||||||
- Security controls / rate limits
|
- Security controls / rate limits
|
||||||
|
- More advanced tool scaffolding (multi-tool, custom APIs)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ export type MetaMcpConfig = {
|
|||||||
gitea?: {
|
gitea?: {
|
||||||
baseUrl: string; // e.g. https://git.schlaustronics.com
|
baseUrl: string; // e.g. https://git.schlaustronics.com
|
||||||
owner: string; // username or org
|
owner: string; // username or org
|
||||||
|
ownerType?: 'user' | 'org';
|
||||||
token: string; // personal access token
|
token: string; // personal access token
|
||||||
defaultVisibility?: 'private' | 'public';
|
defaultVisibility?: 'private' | 'public';
|
||||||
};
|
};
|
||||||
|
git?: {
|
||||||
|
userName?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
};
|
||||||
outputDir?: string; // where new MCP repos are generated
|
outputDir?: string; // where new MCP repos are generated
|
||||||
cvmi?: {
|
cvmi?: {
|
||||||
seedPath?: string; // path to seed.json with nsec + relays
|
seedPath?: string; // path to seed.json with nsec + relays
|
||||||
|
|||||||
@@ -5,6 +5,67 @@ import { loadConfig, ensureDir } from './config.js';
|
|||||||
import type { MetaRequest, MetaResponse } from './types.js';
|
import type { MetaRequest, MetaResponse } from './types.js';
|
||||||
import { updateJob } from './storage.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<string> {
|
function run(cmd: string, args: string[], cwd?: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
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}` });
|
updateJob(jobId, { status: 'building', message: `Building ${repoName}` });
|
||||||
await run('npm', ['run', 'build'], repoDir);
|
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 seedPath = config.cvmi?.seedPath ?? '/root/.cvmi/seed.json';
|
||||||
const seedRaw = fs.readFileSync(seedPath, 'utf8');
|
const seedRaw = fs.readFileSync(seedPath, 'utf8');
|
||||||
const seed = JSON.parse(seedRaw) as { nsec: string; relays: string[] };
|
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}` });
|
updateJob(jobId, { status: 'running', message: `Starting CVMI for ${repoName}` });
|
||||||
const env = { ...process.env, CVMI_SERVE_PRIVATE_KEY: seed.nsec };
|
const env = { ...process.env, CVMI_SERVE_PRIVATE_KEY: seed.nsec };
|
||||||
const cvmiArgs = ['-y', 'cvmi', 'serve', '--', 'node', 'dist/server.js'];
|
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] });
|
updateJob(jobId, { logs: [text] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// create a systemd service to keep the MCP alive
|
||||||
|
await createSystemdService(repoName, repoDir, repoSeedPath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
message: `Started ${repoName}. Awaiting pubkey...`,
|
message: `Started ${repoName}. Awaiting pubkey...`,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import type { MetaRequest, MetaResponse } from './types.js';
|
import type { MetaRequest, MetaResponse } from './types.js';
|
||||||
|
|
||||||
export type Job = {
|
export type Job = {
|
||||||
@@ -9,8 +11,26 @@ export type Job = {
|
|||||||
response: MetaResponse;
|
response: MetaResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const storagePath = '/root/.meta-mcp/jobs.json';
|
||||||
const jobs = new Map<string, Job>();
|
const jobs = new Map<string, Job>();
|
||||||
|
|
||||||
|
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 {
|
export function createJob(request: MetaRequest): Job {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -21,6 +41,7 @@ export function createJob(request: MetaRequest): Job {
|
|||||||
};
|
};
|
||||||
const job: Job = { id, request, createdAt: now, updatedAt: now, response };
|
const job: Job = { id, request, createdAt: now, updatedAt: now, response };
|
||||||
jobs.set(id, job);
|
jobs.set(id, job);
|
||||||
|
persist();
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +49,12 @@ export function updateJob(id: string, patch: Partial<MetaResponse>): Job | null
|
|||||||
const job = jobs.get(id);
|
const job = jobs.get(id);
|
||||||
if (!job) return null;
|
if (!job) return null;
|
||||||
job.updatedAt = Date.now();
|
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 };
|
job.response = { ...job.response, ...patch };
|
||||||
|
persist();
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user