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.
This commit is contained in:
Marco Sadjadi 2026-05-19 22:10:26 +02:00
parent 09688c1114
commit dda8f94de4
4 changed files with 367 additions and 75 deletions

View File

@ -23,3 +23,7 @@ export async function loadSpec(previewId: string): Promise<GeneratorSpec | null>
return null;
}
}
export async function overwriteSpec(previewId: string, spec: GeneratorSpec): Promise<void> {
await getRedis().set(key(previewId), JSON.stringify(spec), 'EX', TTL_SECONDS);
}

View File

@ -1,14 +1,21 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets } from '@bmm/db';
import { CreateServerInput, IterateServerInput, BuildEvent, PreviewInput } from '@bmm/types';
import {
CreateServerInput,
IterateServerInput,
BuildEvent,
PreviewInput,
GeneratorSpec,
type SpecEdit,
} from '@bmm/types';
import { generateSpec, SpecValidationError, BannedPatternError } from '@bmm/llm';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { requireAuth } from '../plugins/session.js';
import { getBuildQueue } from '../lib/queue.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { encryptSecret } from '../lib/crypto.js';
import { audit } from '../lib/audit.js';
import { cacheSpec } from '../lib/preview-cache.js';
import { config } from '../config.js';
const db = createDb();
@ -68,7 +75,32 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
const { name, slug, prompt, secrets: secretValues, previewId } = parsed.data;
const { name, slug, prompt, secrets: secretValues, previewId, specEdit } = parsed.data;
// If the user edited the spec in step 2 of the wizard, merge their edits into
// the cached spec (keeping the original tool implementations untouched).
if (specEdit) {
if (!previewId) {
return reply.code(400).send({ error: 'preview_id_required_with_edit' });
}
const cached = await loadSpec(previewId);
if (!cached) {
return reply.code(410).send({ error: 'preview_expired' });
}
const merged = mergeSpecEdit(cached, specEdit);
const validation = GeneratorSpec.safeParse(merged);
if (!validation.success) {
return reply
.code(422)
.send({ error: 'spec_invalid_after_edit', detail: validation.error.flatten() });
}
try {
rescanInjection(validation.data);
} catch (err) {
return reply.code(422).send({ error: 'banned_pattern', detail: (err as Error).message });
}
await overwriteSpec(previewId, validation.data);
}
const existing = await db
.select({ id: mcpServers.id })
@ -313,3 +345,52 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.send({ ok: true });
});
}
// ---- Spec-edit merge helpers ----
const BANNED_PATTERNS = [
/\beval\s*\(/,
/\bnew\s+Function\s*\(/,
/\brequire\s*\(\s*['"]child_process['"]/,
/\bchild_process\b/,
/ignore\s+previous\s+instructions/i,
/disregard\s+(the\s+)?(above|previous)/i,
];
function rescanInjection(spec: GeneratorSpec): void {
for (const tool of spec.tools) {
for (const pattern of BANNED_PATTERNS) {
if (pattern.test(tool.implementation) || pattern.test(tool.description)) {
throw new Error(`banned_pattern_detected: ${pattern.source}`);
}
}
}
}
function mergeSpecEdit(cached: GeneratorSpec, edit: SpecEdit): GeneratorSpec {
// Index-based merge: user can edit tool name/description/inputSchema but cannot add or
// remove tools — that requires fresh generation. Implementation stays from cache so the
// LLM-generated code is preserved as-is.
if (edit.tools.length !== cached.tools.length) {
throw new Error(
`tool_count_mismatch: cached ${cached.tools.length}, edit ${edit.tools.length}`,
);
}
const mergedTools = edit.tools.map((editTool, i) => {
const original = cached.tools[i];
if (!original) {
throw new Error(`tool_index_missing: ${i}`);
}
return {
name: editTool.name,
description: editTool.description,
inputSchema: editTool.inputSchema,
implementation: original.implementation,
};
});
return {
...cached,
tools: mergedTools,
requiredSecrets: edit.requiredSecrets,
};
}

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { Button } from '@/components/ui/button';
@ -8,7 +8,7 @@ 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';
import { Loader2, RotateCcw, X } from 'lucide-react';
const EXAMPLE_PROMPTS = [
{
@ -53,11 +53,35 @@ interface PreviewResponse {
};
}
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');
@ -67,6 +91,7 @@ export default function NewServerPage() {
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);
@ -77,6 +102,16 @@ export default function NewServerPage() {
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) {
@ -94,9 +129,7 @@ export default function NewServerPage() {
body: JSON.stringify({ prompt }),
});
setPreview(res);
const initial: Record<string, string> = {};
for (const key of res.spec.requiredSecrets) initial[key] = '';
setSecretValues(initial);
setEditable(null); // will re-init via useEffect
setStep('confirm');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
@ -105,20 +138,113 @@ export default function NewServerPage() {
}
}
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) return;
if (!preview || !editable) 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.`);
// 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',
@ -130,6 +256,7 @@ export default function NewServerPage() {
prompt,
secrets: filledSecrets,
previewId: preview.previewId,
specEdit,
}),
},
);
@ -137,12 +264,17 @@ export default function NewServerPage() {
setServerId(res.server.id);
setStep('building');
} catch (e) {
const detail = (e as { detail?: { error?: string } }).detail;
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">
@ -165,7 +297,8 @@ export default function NewServerPage() {
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.
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) => (
@ -228,81 +361,122 @@ export default function NewServerPage() {
</div>
)}
{step === 'confirm' && preview && (
{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 ({preview.spec.tools.length})
Tools ({editable.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'}
<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>
<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)}
<Textarea
rows={2}
value={tool.description}
onChange={(e) => updateTool(i, { description: e.target.value })}
placeholder="What the AI client sees — one clear sentence."
/>
</div>
<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>
{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.
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">
{preview.spec.requiredSecrets.map((key) => (
<div key={key} className="space-y-1">
<Label htmlFor={`secret-${key}`}>
<span className="mono">{key}</span>
</Label>
{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
id={`secret-${key}`}
type="password"
value={secretValues[key] ?? ''}
onChange={(e) =>
setSecretValues((s) => ({ ...s, [key]: e.target.value }))
}
placeholder="paste credential value"
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>
) : (
<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>
@ -326,7 +500,12 @@ export default function NewServerPage() {
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
Back
</Button>
<Button variant="primary" size="md" onClick={build}>
<Button
variant="primary"
size="md"
onClick={build}
disabled={Boolean(hasSchemaErrors)}
>
Build server
</Button>
</div>
@ -394,3 +573,13 @@ export default function NewServerPage() {
</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 {};
}

View File

@ -111,6 +111,23 @@ export type BuildEvent = z.infer<typeof BuildEvent>;
// ---- API request payloads ----
export const SpecEditTool = z.object({
name: z
.string()
.min(1)
.max(64)
.regex(/^[a-z][a-z0-9_]*$/, 'snake_case identifier required'),
description: z.string().min(1).max(2000),
inputSchema: z.record(z.string(), ToolParam),
});
export type SpecEditTool = z.infer<typeof SpecEditTool>;
export const SpecEdit = z.object({
tools: z.array(SpecEditTool).min(1).max(50),
requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]),
});
export type SpecEdit = z.infer<typeof SpecEdit>;
export const CreateServerInput = z.object({
name: z.string().min(1).max(128),
slug: z
@ -121,6 +138,7 @@ export const CreateServerInput = z.object({
prompt: z.string().min(10).max(8000),
secrets: z.record(z.string(), z.string()).default({}),
previewId: z.string().min(1).max(64).optional(),
specEdit: SpecEdit.optional(),
});
export type CreateServerInput = z.infer<typeof CreateServerInput>;