buildmymcpserver/apps/web/app/(dashboard)/servers/new/page.tsx
Marco Sadjadi 09688c1114 feat(web): real 3-step wizard, settings, audit, docs, marketing pages
Sprint 3.5: close every dead link and replace the single-step wizard with the
spec-mandated 3-step flow.

Wizard:
- Step 1 collects prompt + name + slug, calls /v1/servers/preview.
- Step 2 renders parsed tools (name, description, input schema as copyable JSON)
  + a credential field per requiredSecret Claude actually identified. Self-contained
  servers see 'No credentials needed' instead of generic Notion placeholders.
- Step 3 streams the live build over WebSocket and shows install snippets.

New dashboard pages:
- /settings — org, plan/usage, members table, API keys + billing stubs (Sprint 4),
  encryption status. Reads /v1/me/org.
- /audit — filterable table over /v1/audit with action pills, resource refs, IP,
  metadata JSON.

Docs site (/docs + 6 sub-pages):
- Sticky 240px sidebar, max-w-prose article column, shared DocsTitle/H2/Code primitives.
- Quickstart, MCP concepts, OAuth 2.1 flow (full walkthrough with curl), Authoring
  tools, Self-hosting, API reference, FAQ.

Marketing pages:
- /changelog with tagged release timeline.
- /security with 8 pillars + disclosure.
- /privacy with GDPR-aware sections.
- /terms (10 clauses).
- /pricing full page (nav now points here instead of /#pricing anchor).
- /status with live 10s probes against /api/health and /login.

Footer 'system status' badge now links to /status.

All 20 routes 200 OK in smoke crawl. Typecheck clean across packages.
2026-05-19 18:20:31 +02:00

397 lines
14 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 { useState } from 'react';
import { useRouter } 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 } 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 BuildResult {
serverId: string;
publicUrl: string | null;
}
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 [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 trySlug = (n: string) =>
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
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);
const initial: Record<string, string> = {};
for (const key of res.spec.requiredSecrets) initial[key] = '';
setSecretValues(initial);
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');
}
}
async function build() {
setError(null);
if (!preview) return;
const filledSecrets: Record<string, string> = {};
for (const [k, v] of Object.entries(secretValues)) {
if (v.trim()) filledSecrets[k] = v;
}
const missing = preview.spec.requiredSecrets.filter((k) => !filledSecrets[k]);
if (missing.length > 0) {
setError(`Fill in: ${missing.join(', ')} — or remove if not needed.`);
return;
}
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,
}),
},
);
setBuildId(res.build.id);
setServerId(res.server.id);
setStep('building');
} catch (e) {
const detail = (e as { detail?: { error?: string } }).detail;
setError(detail?.error ?? (e as Error).message);
}
}
const stepNumber = step === 'prompt' ? 1 : step === 'analyzing' || step === 'confirm' ? 2 : 3;
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 which credentials we need from you.
</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 && (
<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">Confirm what we&apos;ll build</h2>
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
spec via {preview.source}
</span>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
</div>
<div>
<h3 className="text-[13px] font-semibold tracking-tight">
Tools ({preview.spec.tools.length})
</h3>
<div className="mt-2 space-y-2">
{preview.spec.tools.map((tool) => (
<div key={tool.name} className="panel p-3">
<div className="flex items-baseline gap-2">
<span className="mono text-[13px] font-semibold">{tool.name}</span>
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
{Object.keys(tool.inputSchema).length} param
{Object.keys(tool.inputSchema).length === 1 ? '' : 's'}
</span>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p>
{Object.keys(tool.inputSchema).length > 0 && (
<div className="mt-2">
<CodeBlock
label="input schema"
code={JSON.stringify(tool.inputSchema, null, 2)}
/>
</div>
)}
</div>
))}
</div>
</div>
{preview.spec.requiredSecrets.length > 0 ? (
<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]">
These will be AES-256-GCM encrypted at rest and injected as environment variables
into your server&apos;s container at runtime.
</p>
<div className="mt-3 space-y-2">
{preview.spec.requiredSecrets.map((key) => (
<div key={key} className="space-y-1">
<Label htmlFor={`secret-${key}`}>
<span className="mono">{key}</span>
</Label>
<Input
id={`secret-${key}`}
type="password"
value={secretValues[key] ?? ''}
onChange={(e) =>
setSecretValues((s) => ({ ...s, [key]: e.target.value }))
}
placeholder="paste credential value"
/>
</div>
))}
</div>
</div>
) : (
<div className="panel p-3">
<p className="text-[12.5px] text-[--color-fg-muted]">
No credentials needed. This server runs self-contained.
</p>
</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}>
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>
);
}