buildmymcpserver/apps/web/app/(dashboard)/servers/new/page.tsx
Marco Sadjadi 8334de13a8 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

662 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input, Label, Textarea } from '@/components/input';
import { StreamingLogs } from '@/components/streaming-logs';
import { InstallSnippets } from '@/components/install-snippets';
import { CodeBlock } from '@/components/code-block';
import { Loader2, RotateCcw, X } from 'lucide-react';
const EXAMPLE_PROMPTS = [
{
label: 'Echo / demo (no external API)',
text: 'Build a simple echo server with two tools: echo (string in, same string back) and now (returns current UTC timestamp). No external API needed.',
},
{
label: 'Notion search',
text: 'Search and read pages from our Notion workspace via the Notion API. Tools: search_pages(query), get_page_content(page_id). Auth: NOTION_API_KEY.',
},
{
label: 'Postgres reader',
text: 'Read-only Postgres reader for the users and orders tables at db.example.com. One tool per table: list_users(limit), list_orders(limit, customer_id?). Auth: DATABASE_URL.',
},
{
label: 'Wrap our REST API',
text: 'Wrap our internal HTTP API at api.acme.com — endpoints /search?q= and /lookup?id=. Tools: search(query), lookup(id). Auth: ACME_API_TOKEN.',
},
{
label: 'Stripe charges (read-only)',
text: 'Stripe charges and customers, read-only. Tools: list_recent_charges(limit), get_customer(customer_id). Auth: STRIPE_SECRET_KEY.',
},
];
type Step = 'prompt' | 'analyzing' | 'confirm' | 'building' | 'done';
interface PreviewTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
interface PreviewResponse {
previewId: string;
source: 'claude' | 'mock';
spec: {
name: string;
description?: string;
tools: PreviewTool[];
requiredSecrets: string[];
scopes: string[];
};
}
interface EditableTool {
name: string;
description: string;
inputSchemaJson: string;
schemaError: string | null;
}
interface EditableSpec {
tools: EditableTool[];
requiredSecrets: string[];
}
interface BuildResult {
serverId: string;
publicUrl: string | null;
}
function specToEditable(spec: PreviewResponse['spec']): EditableSpec {
return {
tools: spec.tools.map((t) => ({
name: t.name,
description: t.description,
inputSchemaJson: JSON.stringify(t.inputSchema, null, 2),
schemaError: null,
})),
requiredSecrets: [...spec.requiredSecrets],
};
}
export default function NewServerPage() {
const router = useRouter();
const [step, setStep] = useState<Step>('prompt');
const [prompt, setPrompt] = useState('');
const [name, setName] = useState('');
const [slug, setSlug] = useState('');
const [preview, setPreview] = useState<PreviewResponse | null>(null);
const [editable, setEditable] = useState<EditableSpec | null>(null);
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [buildId, setBuildId] = useState<string | null>(null);
const [serverId, setServerId] = useState<string | null>(null);
const [result, setResult] = useState<BuildResult | null>(null);
const [forkedTemplateId, setForkedTemplateId] = useState<string | null>(null);
const [forkedTemplateTitle, setForkedTemplateTitle] = useState<string | null>(null);
const searchParams = useSearchParams();
const templateSlug = searchParams.get('template');
const trySlug = (n: string) =>
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
useEffect(() => {
if (!templateSlug || preview) return;
let cancelled = false;
setStep('analyzing');
(async () => {
try {
const res = await apiFetch<{
previewId: string;
templateId: string;
template: {
slug: string;
title: string;
shortDescription: string;
tools: PreviewTool[];
requiredSecrets: Array<{
key: string;
description: string;
howToGetUrl?: string;
}>;
};
}>(`/v1/templates/${templateSlug}/fork`, { method: 'POST', body: '{}' });
if (cancelled) return;
setName(res.template.title);
setSlug(trySlug(res.template.title));
setPrompt(`Fork of "${res.template.title}" template.`);
setForkedTemplateId(res.templateId);
setForkedTemplateTitle(res.template.title);
setPreview({
previewId: res.previewId,
source: 'mock',
spec: {
name: res.template.title,
description: res.template.shortDescription,
tools: res.template.tools,
requiredSecrets: res.template.requiredSecrets.map((s) => s.key),
scopes: [],
},
});
setEditable(null);
setStep('confirm');
} catch (e) {
if (cancelled) return;
const detail = (e as { detail?: { error?: string } }).detail;
setError(detail?.error ?? (e as Error).message);
setStep('prompt');
}
})();
return () => {
cancelled = true;
};
}, [templateSlug, preview]);
useEffect(() => {
if (preview && !editable) {
const e = specToEditable(preview.spec);
setEditable(e);
const initial: Record<string, string> = {};
for (const key of e.requiredSecrets) initial[key] = '';
setSecretValues(initial);
}
}, [preview, editable]);
async function analyze() {
setError(null);
if (prompt.trim().length < 10) {
setError('Prompt must be at least 10 characters.');
return;
}
if (!name || !slug) {
setError('Name and slug are required.');
return;
}
setStep('analyzing');
try {
const res = await apiFetch<PreviewResponse>('/v1/servers/preview', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
setPreview(res);
setEditable(null); // will re-init via useEffect
setStep('confirm');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
setStep('prompt');
}
}
function updateTool(i: number, patch: Partial<EditableTool>) {
setEditable((prev) => {
if (!prev) return prev;
const tools = [...prev.tools];
const cur = tools[i];
if (!cur) return prev;
tools[i] = { ...cur, ...patch };
return { ...prev, tools };
});
}
function updateToolSchema(i: number, json: string) {
let schemaError: string | null = null;
try {
const parsed = JSON.parse(json);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
schemaError = 'Schema must be a JSON object.';
}
} catch (e) {
schemaError = `JSON parse error: ${(e as Error).message}`;
}
updateTool(i, { inputSchemaJson: json, schemaError });
}
function addSecret() {
setEditable((prev) =>
prev ? { ...prev, requiredSecrets: [...prev.requiredSecrets, ''] } : prev,
);
}
function removeSecret(idx: number) {
setEditable((prev) => {
if (!prev) return prev;
const removedKey = prev.requiredSecrets[idx];
const next = prev.requiredSecrets.filter((_, i) => i !== idx);
const sv = { ...secretValues };
if (removedKey) delete sv[removedKey];
setSecretValues(sv);
return { ...prev, requiredSecrets: next };
});
}
function updateSecretKey(idx: number, newKey: string) {
const cleaned = newKey.toUpperCase().replace(/[^A-Z0-9_]/g, '');
setEditable((prev) => {
if (!prev) return prev;
const prevKey = prev.requiredSecrets[idx];
const next = [...prev.requiredSecrets];
next[idx] = cleaned;
const sv = { ...secretValues };
if (prevKey && prevKey in sv) {
sv[cleaned] = sv[prevKey] ?? '';
if (prevKey !== cleaned) delete sv[prevKey];
}
setSecretValues(sv);
return { ...prev, requiredSecrets: next };
});
}
function resetEdits() {
if (preview) setEditable(specToEditable(preview.spec));
}
async function build() {
setError(null);
if (!preview || !editable) return;
// Validate every schema parses
const badTool = editable.tools.find((t) => t.schemaError);
if (badTool) {
setError(`Fix schema for "${badTool.name}": ${badTool.schemaError}`);
return;
}
// Validate secret keys: non-empty + UPPER_SNAKE_CASE
for (const key of editable.requiredSecrets) {
if (!key) {
setError('Empty secret name. Remove the row or fill it in.');
return;
}
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
setError(`Secret name "${key}" must be UPPER_SNAKE_CASE.`);
return;
}
}
// Validate filled secret values for present keys
const filledSecrets: Record<string, string> = {};
for (const k of editable.requiredSecrets) {
const v = secretValues[k] ?? '';
if (v.trim()) filledSecrets[k] = v;
}
const missing = editable.requiredSecrets.filter((k) => !filledSecrets[k]);
if (missing.length > 0) {
setError(`Fill values for: ${missing.join(', ')} — or remove from the list.`);
return;
}
const specEdit = {
tools: editable.tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: JSON.parse(t.inputSchemaJson),
})),
requiredSecrets: editable.requiredSecrets,
};
try {
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
'/v1/servers',
{
method: 'POST',
body: JSON.stringify({
name,
slug,
prompt,
secrets: filledSecrets,
previewId: preview.previewId,
// Don't send specEdit when forking — the template's spec + pre-rendered code
// are already in the Redis cache. Edits would invalidate the impls.
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
}),
},
);
setBuildId(res.build.id);
setServerId(res.server.id);
setStep('building');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: unknown } }).detail;
setError(detail?.error ?? (e as Error).message);
}
}
const stepNumber = step === 'prompt' ? 1 : step === 'analyzing' || step === 'confirm' ? 2 : 3;
const hasSchemaErrors = editable?.tools.some((t) => t.schemaError);
const editsDirty =
preview && editable
? JSON.stringify(specToEditable(preview.spec)) !== JSON.stringify(editable)
: false;
return (
<div className="mx-auto max-w-3xl px-6 py-8">
<div className="flex items-baseline justify-between">
<h1 className="text-[22px] font-semibold tracking-tight">New MCP server</h1>
<div className="mono text-[11px] tracking-wider text-[--color-fg-subtle]">
STEP {stepNumber} / 3
</div>
</div>
{step === 'prompt' && (
<div className="mt-7 space-y-5">
<div className="space-y-1.5">
<Label htmlFor="prompt">Describe what your tool should do</Label>
<Textarea
id="prompt"
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="A sentence is enough. Mention which APIs you need, which scopes, what the AI client should be able to do."
/>
<p className="text-[12px] leading-relaxed text-[--color-fg-subtle]">
Next step we&apos;ll show you exactly which tools we&apos;ll expose and let you tweak
the spec before we build.
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{EXAMPLE_PROMPTS.map((p) => (
<button
type="button"
key={p.label}
onClick={() => setPrompt(p.text)}
className="rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2.5 py-1 text-[11.5px] text-[--color-fg-muted] transition-colors hover:border-[--color-border-strong] hover:text-[--color-fg]"
>
{p.label}
</button>
))}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (!slug || slug === trySlug(name)) setSlug(trySlug(e.target.value));
}}
placeholder="Notion Reader"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slug" hint="becomes subdomain / id">Slug</Label>
<Input
id="slug"
value={slug}
onChange={(e) => setSlug(trySlug(e.target.value))}
placeholder="notion-reader"
/>
</div>
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="md" onClick={() => router.push('/servers')}>
Cancel
</Button>
<Button variant="primary" size="md" onClick={analyze}>
Analyze
</Button>
</div>
</div>
)}
{step === 'analyzing' && (
<div className="mt-10 panel p-8 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
<p className="mt-4 text-[13px]">Analyzing your prompt</p>
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
Claude Opus 4.7 is parsing the spec. Usually 2040 seconds.
</p>
</div>
)}
{step === 'confirm' && preview && editable && (
<div className="mt-7 space-y-6">
{forkedTemplateTitle && (
<div className="panel-subtle p-3 flex items-center justify-between">
<div className="text-[12.5px]">
Forking <span className="mono font-semibold">{forkedTemplateTitle}</span> fill in
your own credentials below. The template author never sees them.
</div>
<a
href={`/templates/${templateSlug}`}
target="_blank"
rel="noreferrer"
className="text-[11.5px] text-[--color-fg-muted] underline hover:text-[--color-fg]"
>
Template
</a>
</div>
)}
<div className="panel p-4">
<div className="flex items-baseline justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we&apos;ll build</h2>
<div className="flex items-center gap-3">
{editsDirty && (
<button
type="button"
onClick={resetEdits}
className="inline-flex items-center gap-1 text-[11px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
<RotateCcw size={11} /> reset edits
</button>
)}
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
spec via {preview.source}
</span>
</div>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
<p className="mt-3 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
Edit tool names, descriptions or input schemas inline. Renaming parameters may
require an <span className="mono">Iterate</span> after build to update the
implementation the existing impl references the original names.
</p>
</div>
<div>
<h3 className="text-[13px] font-semibold tracking-tight">
Tools ({editable.tools.length})
</h3>
<div className="mt-2 space-y-3">
{editable.tools.map((tool, i) => (
<div key={i} className="panel p-3 space-y-2.5">
<div className="grid gap-2 md:grid-cols-[1fr_auto]">
<Input
value={tool.name}
onChange={(e) => updateTool(i, { name: e.target.value })}
placeholder="snake_case_tool_name"
className="mono"
/>
<span className="mono self-center text-[10.5px] text-[--color-fg-subtle]">
{Object.keys(safeJsonObject(tool.inputSchemaJson)).length} param
{Object.keys(safeJsonObject(tool.inputSchemaJson)).length === 1 ? '' : 's'}
</span>
</div>
<Textarea
rows={2}
value={tool.description}
onChange={(e) => updateTool(i, { description: e.target.value })}
placeholder="What the AI client sees — one clear sentence."
/>
<div className="space-y-1">
<Label hint="JSON object — keys are param names, values describe each param">
Input schema
</Label>
<Textarea
rows={Math.min(12, Math.max(3, tool.inputSchemaJson.split('\n').length))}
value={tool.inputSchemaJson}
onChange={(e) => updateToolSchema(i, e.target.value)}
className="mono text-[12px]"
spellCheck={false}
/>
{tool.schemaError && (
<p className="text-[11.5px] text-[--color-danger]">{tool.schemaError}</p>
)}
</div>
</div>
))}
</div>
</div>
<div>
<h3 className="text-[13px] font-semibold tracking-tight">
Credentials we need
</h3>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
implementation doesn&apos;t actually use one.
</p>
<div className="mt-3 space-y-2">
{editable.requiredSecrets.length === 0 && (
<p className="text-[12.5px] text-[--color-fg-muted]">
No credentials. This server runs self-contained.
</p>
)}
{editable.requiredSecrets.map((key, idx) => (
<div key={idx} className="grid grid-cols-[180px_1fr_auto] items-start gap-2">
<Input
value={key}
onChange={(e) => updateSecretKey(idx, e.target.value)}
placeholder="MY_API_KEY"
className="mono"
/>
<Input
type="password"
value={secretValues[key] ?? ''}
onChange={(e) => setSecretValues((s) => ({ ...s, [key]: e.target.value }))}
placeholder="paste value"
/>
<button
type="button"
onClick={() => removeSecret(idx)}
aria-label="Remove"
className="inline-flex h-8 items-center justify-center rounded-md border border-[--color-border] px-2 text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
<X size={13} />
</button>
</div>
))}
<Button variant="ghost" size="sm" onClick={addSecret}>
+ Add credential
</Button>
</div>
</div>
{preview.spec.scopes.length > 0 && (
<div>
<h3 className="text-[13px] font-semibold tracking-tight">OAuth scopes</h3>
<div className="mt-2 flex flex-wrap gap-1.5">
{preview.spec.scopes.map((s) => (
<span
key={s}
className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px] text-[--color-fg-muted]"
>
{s}
</span>
))}
</div>
</div>
)}
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
Back
</Button>
<Button
variant="primary"
size="md"
onClick={build}
disabled={Boolean(hasSchemaErrors)}
>
Build server
</Button>
</div>
</div>
)}
{step === 'building' && buildId && (
<div className="mt-7 space-y-4">
<p className="text-[13px] text-[--color-fg-muted]">
Building your server. Logs stream live over WebSocket from the generator.
</p>
<StreamingLogs
buildId={buildId}
onDone={(status, publicUrl) => {
if (status === 'success') {
setResult({ serverId: serverId!, publicUrl });
setStep('done');
} else {
setError(`Build ${status}`);
}
}}
/>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
</div>
)}
{step === 'done' && result && (
<div className="mt-7 space-y-6">
<div className="panel p-4">
<div className="flex items-baseline justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">Your server is live</h2>
<span className="mono text-[11px] text-emerald-300">200 OK</span>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
OAuth-protected Streamable HTTP endpoint:
</p>
<div className="mt-3">
<CodeBlock code={`${result.publicUrl}/mcp`} label="endpoint" />
</div>
</div>
{result.publicUrl && (
<div>
<h2 className="text-[14px] font-semibold tracking-tight">Install</h2>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Drop this into your client. OAuth handshake runs automatically on first use.
</p>
<div className="mt-3">
<InstallSnippets input={{ name, slug, publicUrl: result.publicUrl }} />
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="secondary"
size="md"
onClick={() => router.push(`/servers/${result.serverId}`)}
>
Open server
</Button>
</div>
</div>
)}
</div>
);
}
function safeJsonObject(s: string): Record<string, unknown> {
try {
const parsed = JSON.parse(s);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {}
return {};
}