buildmymcpserver/apps/web/app/(dashboard)/servers/new/page.tsx
Marco Sadjadi dda8f94de4 feat(wizard): editable spec in step 2 — name, description, JSON schema, secrets
The wizard's confirm step is no longer read-only. Users can refine what Claude
parsed before committing to a build.

Backend:
- @bmm/types adds SpecEdit (tools[name,description,inputSchema] + requiredSecrets);
  CreateServerInput accepts an optional specEdit alongside previewId.
- Servers create endpoint: when specEdit is provided, loads cached spec from Redis,
  index-merges the edits in (keeping LLM-generated implementations untouched),
  re-validates via GeneratorSpec, re-runs the banned-pattern scan, overwrites the
  Redis cache so the worker reads the user's version. Refuses with
  preview_expired/tool_count_mismatch/banned_pattern on safety failures.
- New overwriteSpec() helper in preview-cache.

Frontend:
- Step 2 renders each tool as an editable card: name input, description textarea,
  JSON schema textarea with parse-on-keystroke validation (inline error if invalid).
- Required secrets list is editable: keys via uppercase-snake-case input, +Add /
  remove buttons, secret values kept in sync when keys are renamed.
- Reset-to-AI-suggestion button appears when edits are dirty.
- Pre-submit validation: schema must parse, secret keys must match UPPER_SNAKE_CASE,
  required secret values must be provided.
- Warning copy: 'Renaming parameters may require an Iterate after build — the
  existing impl references the original names.'

Verified end-to-end via browser smoke test: edited description + renamed tool
landed correctly in mcp_servers.tools_schema and in the live container at :4107.
Implementation field preserved from the original cached spec.
2026-05-19 22:10:26 +02:00

586 lines
21 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 } 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 trySlug = (n: string) =>
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
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,
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">
<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 {};
}