buildmymcpserver/apps/web/app/(dashboard)/servers/new/page.tsx
Marco Sadjadi be02600759
All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s
feat(security): block credentials from reaching the LLM via prompt secret scan
Prompts were sent to the model with no secret scan, so a pasted API key would leak to the LLM. Added findSecretInPrompt in @bmm/types (tight provider-key patterns: Anthropic/OpenAI/GitHub/AWS/Google/Slack/Stripe/JWT/private-key) shared by both sides. The web wizard blocks before sending with a clear message; the API preview and preview-stream endpoints reject with secret_in_prompt as the hard guarantee. Credential VALUES already never touched the model - they are entered in the separate encrypted step 2; this closes the remaining leak path where a user pastes a key into the prompt itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:52:16 +02:00

1036 lines
38 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, apiSseStream } from '@/lib/api';
import { findSecretInPrompt } from '@bmm/types';
import { Loader2, RotateCcw, X } from 'lucide-react';
import Link from 'next/link';
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>;
}
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
interface PreviewResponse {
previewId: string;
source: 'claude' | 'glm' | 'mock';
plan?: Plan;
modelDisplayName?: string;
modelBadge?: 'open-tier' | 'claude-haiku' | 'claude-sonnet' | 'claude-opus';
upgradeHint?: boolean;
spec: {
name: string;
description?: string;
tools: PreviewTool[];
requiredSecrets: string[];
scopes: string[];
};
}
const PREVIEW_MODEL_BY_PLAN: Record<Plan, { name: string; estimate: string }> = {
hobby: { name: 'Open-tier AI', estimate: '3060 seconds' },
pro: { name: 'Claude Haiku 4.5', estimate: '1020 seconds' },
team: { name: 'Claude Sonnet 4.6', estimate: '1540 seconds' },
enterprise: { name: 'Claude Sonnet 4.6', estimate: '1540 seconds' },
};
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 [userPlan, setUserPlan] = useState<Plan | null>(null);
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]);
// Plan determines which model the preview will use — we display its name
// *before* the request so the user knows what they're waiting for.
useEffect(() => {
apiFetch<{ user: { plan?: Plan } }>('/v1/auth/me')
.then((r) => setUserPlan(r.user.plan ?? 'hobby'))
.catch(() => setUserPlan('hobby'));
}, []);
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;
}
// Keep credentials out of the model: block a prompt that contains a real
// key/token before it is ever sent. Credentials go in the encrypted fields
// in the next step, never in the prompt.
const leaked = findSecretInPrompt(prompt);
if (leaked) {
setError(
`Looks like your prompt contains ${leaked}. Remove it — API keys must never go in the prompt (it is sent to the AI). You'll add credentials in their own encrypted fields in the next step.`,
);
return;
}
setStep('analyzing');
// Streaming preview: pipes Anthropic's token deltas back as SSE. Cloudflare's
// ~100s edge cap doesn't bite because every chunk we receive resets the
// idle timer; the only practical limit is the model's own runtime. If the
// backend returns 409 streaming_unavailable (e.g. hobby/GLM tier), we fall
// back to the sync endpoint so the wizard still works there.
let finalResolved = false;
let sseError: { error?: string; detail?: string } | null = null;
let sseSpec: PreviewResponse | null = null;
await apiSseStream(
'/v1/servers/preview/stream',
{ prompt },
{
onEvent: (event, data) => {
if (event === 'spec') {
finalResolved = true;
sseSpec = data as PreviewResponse;
} else if (event === 'error') {
finalResolved = true;
sseError = data as { error?: string; detail?: string };
}
// 'text' deltas are ignored for now — the wizard already shows a
// spinner. We could surface partial JSON later if useful.
},
onError: (err) => {
sseError = { detail: err.message };
},
},
);
if (finalResolved && sseSpec) {
setPreview(sseSpec);
setEditable(null);
setStep('confirm');
return;
}
if (sseError && (sseError as { error?: string }).error === 'streaming_unavailable') {
// GLM / mock tier — fall back to sync.
try {
const res = await apiFetch<PreviewResponse>('/v1/servers/preview', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
setPreview(res);
setEditable(null);
setStep('confirm');
return;
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
setStep('prompt');
return;
}
}
setError(
(sseError as { detail?: string } | null)?.detail ??
(sseError as { error?: string } | null)?.error ??
'Spec generation failed.',
);
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?: string } }).detail;
const code = detail?.error;
if (code === 'slug_taken') {
setError(
`The slug "${slug}" is already used by one of your servers — change the Slug field above.`,
);
return;
}
if (code === 'plan_limit_reached') {
setError(`${detail?.detail ?? 'Plan limit reached.'} See /pricing to upgrade.`);
return;
}
if (code === 'rate_limited') {
setError(detail?.detail ?? 'Daily build limit reached — try again tomorrow or upgrade.');
return;
}
setError(detail?.detail ?? 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.{' '}
<span className="text-[--color-fg-muted]">
Don&apos;t paste API keys or access tokens here you&apos;ll add each one in its own
encrypted field in the next step.
</span>
</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]">
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).name} is
drafting the tool spec. Usually{' '}
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}.
</p>
{userPlan === 'hobby' && (
<p className="mt-2 text-[11px] text-[--color-fg-muted]">
<Link href="/pricing" className="text-[--color-accent] hover:underline">
Upgrade to Pro
</Link>{' '}
for ~3× faster analysis with Claude Haiku.
</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]">
drafted with {preview.modelDisplayName ?? 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">API keys &amp; credentials</h3>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
One field per key or access token entered here, separately from your prompt.
AES-256-GCM encrypted at rest, injected as env vars at runtime only. Remove any your
implementation doesn&apos;t use; add any we missed.
</p>
<div className="mt-3 space-y-2">
{editable.requiredSecrets.length === 0 && (
<p className="text-[12.5px] text-[--color-fg-muted]">
None detected. If your tool calls an API that needs a key or access token, add it
below with <span className="mono">+ Add credential</span> never put secrets in
the prompt.
</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 {};
}