buildmymcpserver/apps/generator/src/worker.ts

167 lines
6.6 KiB
TypeScript
Raw Normal View History

import { Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { GeneratorSpec } from '@bmm/types';
import { builds, createDb, eq, mcpServers } from '@bmm/db';
import { config } from './config.js';
import { generateSpec } from './lib/claude.js';
import { renderServerCode } from './lib/render.js';
import { dockerBuild, prepareBuildContext, staticCheck } from './lib/build.js';
import { allocatePort, deployContainer, dockerAvailable } from './lib/deploy.js';
import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
const db = createDb();
const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
const cacheReader = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
interface JobData {
buildId: string;
serverId: string;
orgId: string;
prompt: string;
version: number;
slug: string;
serverName: string;
secrets: Record<string, string>;
previewId?: string;
}
async function loadCachedSpec(previewId: string): Promise<GeneratorSpec | null> {
const raw = await cacheReader.get(`preview:${previewId}`);
if (!raw) return null;
try {
const parsed = GeneratorSpec.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
feat(marketplace): template publish + fork + voting/ranking + admin moderation What this enables: - A user builds an MCP server. If others would benefit, they click 'Publish as template' on their server detail page. The spec + pre-rendered TypeScript snapshot is preserved. - Visitors browse /templates, filter by category, sort by trending/top/newest. Each template card shows fork count + active deployment count as natural manipulation-resistant popularity signal. - /templates/[slug] shows the full plan: tool list with input schemas, required-credential explanations (with 'how to get one' deep links), and a collapsible code preview so users can audit before forking. - Fork is one click → /servers/new?template=slug. The wizard skips Step 1 and pre-fills Step 2 with the template's parsed spec. Forker only fills in their own credentials. mcp_servers.template_id is recorded; template.fork_count is bumped atomically. Each fork gets its own isolated container with its own port, its own AES-256 secrets — the template author has zero visibility into the fork's traffic or data. - Admin /admin/templates moderation: verify quality templates (shows shield badge in marketplace), hide low-effort ones, takedown anything malicious. Takedowns cascade-pause every fork container — owners must re-deploy. Why template+fork instead of shared-container: - Shared containers would mean the publisher's quota + their secrets + their logs are exposed to forkers. Bad ergonomics, bad security, bad ownership. - Templates/forks decouple the spec (shared, vouched-for) from the runtime (isolated per user). Network-effect moat without the trust collapse. Why no 5-star voting in v1: - Manipulation-anfällig, empty lists without adoption. We use fork count + active deploys + verified badge. Trending algorithm: score = (activeDeploys * 3 + forks) / sqrt(ageDays + 1) Real signal, no brigading attack surface. Backend: - New schema: templates table (16 cols incl. tools_schema, generated_code, required_secrets, allowedDomains, status enum, verified, fork_count). - mcp_servers.template_id FK + idx for fork lookup. - @bmm/types: SpecEdit unchanged, CreateServerInput accepts optional templateId. - preview-cache.ts: new cachePrebuiltCode/loadPrebuiltCode for storing the template's full rendered server.ts alongside the spec. Generator worker detects this and skips the render step — uses the audited pre-built code verbatim. Banned-pattern re-scan at publish time. - routes/templates.ts: 5 public/auth routes + 2 admin routes. Banned-pattern re-scan before publish. Slug auto-uniqued. forkCount atomic-increment via SQL. UI: - /templates marketplace with trending/top/newest tabs, category filter, search. Cards show forks + live count + author + verified badge. - /templates/[slug] full detail with tools, credentials-with-hints, expandable code preview, fork CTA, ownership + stats sidebar, 'forking is safe' explainer. - /servers/new?template=slug — wizard auto-jumps to Step 2 with template spec pre-filled, fork banner at top with link back to template. - /servers/[id] new Publish tab with title, category, descriptions, per-secret hint fields (description + howToGetUrl per UPPER_SNAKE_CASE key). - /admin/templates moderation with verify/hide/takedown actions. - Marketing nav now includes /templates. Verified end-to-end: - Published Echo Demo Template from marco@test.local's live server - Marketplace lists it correctly with stats - Detail page renders with all sections - Fork CTA navigates to wizard with ?template= param - Wizard skips Step 1, shows fork banner, pre-fills spec - Build succeeds in ~10s (cached spec + prebuilt code path skips Claude AND render), container live on :4109 with proper OAuth 401 → token → 200 flow - DB: templates.fork_count=1, activeDeployments=1, mcp_servers.template_id populated on the fork - /admin/templates shows the new template with verify/hide/takedown controls
2026-05-19 23:22:35 +02:00
async function loadPrebuiltCode(previewId: string): Promise<string | null> {
return (await cacheReader.get(`prebuilt:${previewId}`)) ?? null;
}
export const worker = new Worker<JobData>(
'build',
async (job) => {
const { buildId, serverId, prompt, version, slug, secrets, previewId } = job.data;
const log = (level: 'info' | 'warn' | 'error', msg: string) => emitLog(buildId, level, msg);
try {
await db.update(builds).set({ status: 'generating', startedAt: new Date() }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'generating', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'generating');
let spec: GeneratorSpec | null = null;
let source: 'claude' | 'mock' | 'cached' = 'mock';
if (previewId) {
spec = await loadCachedSpec(previewId);
if (spec) {
source = 'cached';
await log('info', `Re-using preview spec ${previewId} (skipping Claude call)`);
} else {
await log('warn', `Preview ${previewId} cache miss — regenerating`);
}
}
if (!spec) {
await log('info', 'Generating MCP server spec...');
const result = await generateSpec(prompt);
spec = result.spec;
source = result.source;
}
await log('info', `Spec ready via ${source} (${spec.tools.length} tool(s))`);
feat(marketplace): template publish + fork + voting/ranking + admin moderation What this enables: - A user builds an MCP server. If others would benefit, they click 'Publish as template' on their server detail page. The spec + pre-rendered TypeScript snapshot is preserved. - Visitors browse /templates, filter by category, sort by trending/top/newest. Each template card shows fork count + active deployment count as natural manipulation-resistant popularity signal. - /templates/[slug] shows the full plan: tool list with input schemas, required-credential explanations (with 'how to get one' deep links), and a collapsible code preview so users can audit before forking. - Fork is one click → /servers/new?template=slug. The wizard skips Step 1 and pre-fills Step 2 with the template's parsed spec. Forker only fills in their own credentials. mcp_servers.template_id is recorded; template.fork_count is bumped atomically. Each fork gets its own isolated container with its own port, its own AES-256 secrets — the template author has zero visibility into the fork's traffic or data. - Admin /admin/templates moderation: verify quality templates (shows shield badge in marketplace), hide low-effort ones, takedown anything malicious. Takedowns cascade-pause every fork container — owners must re-deploy. Why template+fork instead of shared-container: - Shared containers would mean the publisher's quota + their secrets + their logs are exposed to forkers. Bad ergonomics, bad security, bad ownership. - Templates/forks decouple the spec (shared, vouched-for) from the runtime (isolated per user). Network-effect moat without the trust collapse. Why no 5-star voting in v1: - Manipulation-anfällig, empty lists without adoption. We use fork count + active deploys + verified badge. Trending algorithm: score = (activeDeploys * 3 + forks) / sqrt(ageDays + 1) Real signal, no brigading attack surface. Backend: - New schema: templates table (16 cols incl. tools_schema, generated_code, required_secrets, allowedDomains, status enum, verified, fork_count). - mcp_servers.template_id FK + idx for fork lookup. - @bmm/types: SpecEdit unchanged, CreateServerInput accepts optional templateId. - preview-cache.ts: new cachePrebuiltCode/loadPrebuiltCode for storing the template's full rendered server.ts alongside the spec. Generator worker detects this and skips the render step — uses the audited pre-built code verbatim. Banned-pattern re-scan at publish time. - routes/templates.ts: 5 public/auth routes + 2 admin routes. Banned-pattern re-scan before publish. Slug auto-uniqued. forkCount atomic-increment via SQL. UI: - /templates marketplace with trending/top/newest tabs, category filter, search. Cards show forks + live count + author + verified badge. - /templates/[slug] full detail with tools, credentials-with-hints, expandable code preview, fork CTA, ownership + stats sidebar, 'forking is safe' explainer. - /servers/new?template=slug — wizard auto-jumps to Step 2 with template spec pre-filled, fork banner at top with link back to template. - /servers/[id] new Publish tab with title, category, descriptions, per-secret hint fields (description + howToGetUrl per UPPER_SNAKE_CASE key). - /admin/templates moderation with verify/hide/takedown actions. - Marketing nav now includes /templates. Verified end-to-end: - Published Echo Demo Template from marco@test.local's live server - Marketplace lists it correctly with stats - Detail page renders with all sections - Fork CTA navigates to wizard with ?template= param - Wizard skips Step 1, shows fork banner, pre-fills spec - Build succeeds in ~10s (cached spec + prebuilt code path skips Claude AND render), container live on :4109 with proper OAuth 401 → token → 200 flow - DB: templates.fork_count=1, activeDeployments=1, mcp_servers.template_id populated on the fork - /admin/templates shows the new template with verify/hide/takedown controls
2026-05-19 23:22:35 +02:00
// Forks supply pre-rendered code via Redis. If present, use it verbatim.
let generatedCode: string;
const prebuilt = previewId ? await loadPrebuiltCode(previewId) : null;
if (prebuilt) {
await log('info', `Using pre-rendered template code (${prebuilt.length} chars) — skipping render`);
generatedCode = prebuilt;
} else {
generatedCode = renderServerCode(spec);
}
await db
.update(builds)
.set({ generatedSpec: spec, generatedCode })
.where(eq(builds.id, buildId));
await db.update(builds).set({ status: 'building' }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'building', toolsSchema: spec.tools, updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'building');
await log('info', 'Preparing build context...');
const { contextDir, imageTag } = await prepareBuildContext(serverId, version, slug, generatedCode, spec);
await log('info', `Build context at ${contextDir}`);
await log('info', 'Running static checks...');
await staticCheck(contextDir);
await log('info', 'Static checks passed.');
const hasDocker = await dockerAvailable();
if (!hasDocker) {
await log('warn', 'Docker not available — skipping build/deploy. Server marked draft.');
await db.update(builds).set({ status: 'failed', errorMessage: 'docker_unavailable', finishedAt: new Date() }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'failed', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await emitDone(buildId, 'failed', serverId, null);
return;
}
await log('info', `Building Docker image ${imageTag}...`);
await dockerBuild(contextDir, imageTag, (line) => {
emitLog(buildId, 'info', line).catch(() => undefined);
});
await log('info', 'Image built.');
await db.update(builds).set({ status: 'deploying' }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'deploying', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'deploying');
const port = await allocatePort();
const publicUrl = `http://${config.RUNNER_HOST}:${port}`;
const envVars: Record<string, string> = {
...secrets,
PUBLIC_URL: publicUrl,
CONTROL_PLANE_URL: config.CONTROL_PLANE_URL,
OAUTH_ISSUER: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
PORT: '3000',
SERVER_ID: serverId,
};
const handle = await deployContainer({ serverId, slug, hostPort: port, imageTag, envVars });
await log('info', `Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`);
await db
.update(builds)
.set({ status: 'success', finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'live', currentVersion: version, publicUrl: handle.publicUrl, updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'success');
await emitDone(buildId, 'success', serverId, handle.publicUrl);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[worker] build failed:', err);
await db
.update(builds)
.set({ status: 'failed', errorMessage: msg, finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'failed', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitError(buildId, msg);
await emitDone(buildId, 'failed', serverId, null);
}
},
{ connection, concurrency: 2 },
);
worker.on('ready', () => console.log('[generator] worker ready'));
worker.on('failed', (job, err) => console.error('[generator] job failed', job?.id, err?.message));
worker.on('error', (err) => console.error('[generator] worker error', err.message));