buildmymcpserver/apps/web/app/(dashboard)/servers/new/page.tsx
Marco Sadjadi e198d44e1e
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
fix(preview): stop spec generation timing out behind the edge proxy
The /v1/servers/preview route ran claude-opus-4-7 synchronously; full spec
generation routinely exceeded Cloudflare's ~100s proxy cap, so the browser
received a headerless 524 and reported it as a CORS failure.

- preview now uses claude-sonnet-4-6 with a 45s per-attempt timeout and one
  retry — comfortably inside the proxy budget
- generateSpec maps an exhausted timeout to SpecTimeoutError; the route
  returns a clean 504 (with CORS headers) instead of a stalled connection
- analyze step: live elapsed-seconds counter as freeze-proof, plus a
  reduced-motion exception so the loading spinner keeps spinning (a status
  indicator, which WCAG exempts from reduced-motion)
- textarea resize grip restyled to dark theme (light hatch on dark square)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:52:48 +02:00

925 lines
33 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 { CodeBlock } from '@/components/code-block';
import { Input, Label, Textarea } from '@/components/input';
import { InstallSnippets } from '@/components/install-snippets';
import { StreamingLogs } from '@/components/streaming-logs';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2, RotateCcw, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from '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],
};
}
function NewServerPageInner() {
const router = useRouter();
const [step, setStep] = useState<Step>('prompt');
const [elapsedSec, setElapsedSec] = useState(0);
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;
// Auto-unique the slug against the user's existing servers so the
// default fork doesn't 409 (e.g. forking the same template twice).
let uniqueSlug = trySlug(res.template.title);
try {
const own = await apiFetch<{ servers: { slug: string }[] }>('/v1/servers');
const taken = new Set(own.servers.map((s) => s.slug));
if (taken.has(uniqueSlug)) {
const base = uniqueSlug;
let n = 2;
while (taken.has(`${base}-${n}`)) n++;
uniqueSlug = `${base}-${n}`;
}
} catch {
// If the lookup fails we still proceed; the slug field is editable
// and the build surfaces a clear slug_taken error.
}
if (cancelled) return;
setName(res.template.title);
setSlug(uniqueSlug);
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]);
// Live elapsed counter for the analyze step — a value that ticks every
// second is unambiguous proof the page is alive, even when CSS animation is
// suppressed (e.g. the OS "reduce motion" setting).
useEffect(() => {
if (step !== 'analyzing') return;
setElapsedSec(0);
const startedAt = Date.now();
const id = setInterval(() => {
setElapsedSec(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => clearInterval(id);
}, [step]);
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;
const code = detail?.error;
setError(
code === 'slug_taken'
? `The slug "${slug}" is already used by one of your servers — change the Slug field above.`
: (code ?? (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-accent]" size={22} />
<p className="mt-4 text-[13px]">Analyzing your prompt</p>
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
Claude Sonnet 4.6 is drafting the tool spec. Usually 2040 seconds.
</p>
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
{elapsedSec}s elapsed
</p>
</div>
)}
{step === 'confirm' && preview && editable && (
<div className="mt-7 space-y-6">
{forkedTemplateTitle && (
<div className="panel-subtle p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="text-[12.5px]">
Forking <span className="mono font-semibold">{forkedTemplateTitle}</span> name
your copy and fill in your own credentials. The template author never sees them.
</div>
<a
href={`/templates/${templateSlug}`}
target="_blank"
rel="noreferrer"
className="shrink-0 text-[11.5px] text-[--color-fg-muted] underline hover:text-[--color-fg]"
>
Template
</a>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="fork-name">Name</Label>
<Input
id="fork-name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (!slug || slug === trySlug(name)) setSlug(trySlug(e.target.value));
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fork-slug" hint="must be unique in your workspace">
Slug
</Label>
<Input
id="fork-slug"
value={slug}
onChange={(e) => setSlug(trySlug(e.target.value))}
/>
</div>
</div>
</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>
)}
{!forkedTemplateId && (
<SharePanel
serverId={result.serverId}
defaultTitle={name}
defaultShortDescription={preview?.spec.description ?? ''}
secretKeys={editable?.requiredSecrets ?? []}
/>
)}
<div className="flex justify-end gap-2">
<Button
variant="secondary"
size="md"
onClick={() => router.push(`/servers/${result.serverId}`)}
>
Open server
</Button>
</div>
</div>
)}
</div>
);
}
// useSearchParams() forces client-side rendering — Next requires a Suspense
// boundary around it, or `next build` bails out of static generation.
export default function NewServerPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-[22px] font-semibold tracking-tight">New MCP server</h1>
<div className="panel mt-10 p-8 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
<p className="mt-4 text-[13px] text-[--color-fg-muted]">Loading</p>
</div>
</div>
}
>
<NewServerPageInner />
</Suspense>
);
}
const SHARE_CATEGORIES = [
'productivity',
'developer-tools',
'data',
'communication',
'finance',
'crm',
'analytics',
'devops',
'demo',
'other',
];
function SharePanel({
serverId,
defaultTitle,
defaultShortDescription,
secretKeys,
}: {
serverId: string;
defaultTitle: string;
defaultShortDescription: string;
secretKeys: string[];
}) {
const [share, setShare] = useState(true);
const [category, setCategory] = useState('other');
const [shortDescription, setShortDescription] = useState(defaultShortDescription.slice(0, 280));
const [hints, setHints] = useState<Record<string, string>>(() =>
Object.fromEntries(secretKeys.map((k) => [k, ''])),
);
const [state, setState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [publishedSlug, setPublishedSlug] = useState<string | null>(null);
async function publish() {
setError(null);
if (shortDescription.trim().length < 10) {
setError('Add a short description (at least 10 characters).');
return;
}
setState('submitting');
try {
const res = await apiFetch<{ template: { slug: string } }>('/v1/templates', {
method: 'POST',
body: JSON.stringify({
serverId,
title: defaultTitle,
category,
shortDescription: shortDescription.trim(),
secretHints: secretKeys.map((k) => ({
key: k,
description: hints[k]?.trim() || `Credential required for this server (${k}).`,
})),
}),
});
setPublishedSlug(res.template.slug);
setState('done');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
setState('error');
}
}
if (state === 'done' && publishedSlug) {
return (
<div className="panel p-4">
<div className="flex items-baseline justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">Shared to marketplace</h2>
<span className="mono text-[11px] text-emerald-300">public</span>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Others can now fork it. You can unshare anytime from the server&apos;s Publish tab.
</p>
<a
href={`/templates/${publishedSlug}`}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex h-8 items-center rounded-md bg-[--color-accent] px-3 text-[12.5px] font-medium text-white transition-colors hover:bg-[#5557e8]"
>
View in marketplace
</a>
</div>
);
}
return (
<div className="panel p-4">
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={share}
onChange={(e) => setShare(e.target.checked)}
className="mt-0.5 size-3.5 accent-[--color-accent]"
/>
<div>
<div className="text-[13px] font-medium">
Share as template in the marketplace{' '}
<span className="text-[--color-fg-subtle]">(recommended)</span>
</div>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
Your secrets stay private they are never copied into a template. But your{' '}
<span className="text-[--color-fg]">generated code becomes publicly viewable</span> so
others can audit it before forking. Unshare anytime.
</p>
</div>
</label>
{share && (
<div className="mt-4 space-y-3 border-t border-[--color-border] pt-4">
<div className="grid gap-3 md:grid-cols-[1fr_200px]">
<div className="space-y-1.5">
<Label hint={`${shortDescription.length}/280`}>Short description</Label>
<Input
value={shortDescription}
onChange={(e) => setShortDescription(e.target.value)}
placeholder="What does this server do, in one line?"
maxLength={280}
/>
</div>
<div className="space-y-1.5">
<Label>Category</Label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="h-8 w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[13px] focus:border-[--color-accent] focus:outline-none"
>
{SHARE_CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
{secretKeys.length > 0 && (
<div className="space-y-1.5">
<Label hint="optional — helps forkers know what to paste">Credential hints</Label>
{secretKeys.map((k) => (
<div key={k} className="grid grid-cols-[180px_1fr] gap-2">
<div className="mono flex h-8 items-center rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[12px] text-[--color-fg-muted]">
{k}
</div>
<Input
value={hints[k] ?? ''}
onChange={(e) => setHints((h) => ({ ...h, [k]: e.target.value }))}
placeholder={`What is ${k}? Where does a forker get one?`}
/>
</div>
))}
</div>
)}
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex items-center justify-between">
<p className="text-[11px] text-[--color-fg-subtle]">
Published code is re-scanned for banned patterns and hardcoded secrets.
</p>
<Button variant="primary" size="md" onClick={publish} disabled={state === 'submitting'}>
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
</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 {};
}