From dda8f94de4163b90fc731a7543bc6a0ffe5243da Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Tue, 19 May 2026 22:10:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(wizard):=20editable=20spec=20in=20step=202?= =?UTF-8?q?=20=E2=80=94=20name,=20description,=20JSON=20schema,=20secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/api/src/lib/preview-cache.ts | 4 + apps/api/src/routes/servers.ts | 87 ++++- apps/web/app/(dashboard)/servers/new/page.tsx | 333 ++++++++++++++---- packages/types/src/index.ts | 18 + 4 files changed, 367 insertions(+), 75 deletions(-) diff --git a/apps/api/src/lib/preview-cache.ts b/apps/api/src/lib/preview-cache.ts index 7479512..f63afa4 100644 --- a/apps/api/src/lib/preview-cache.ts +++ b/apps/api/src/lib/preview-cache.ts @@ -23,3 +23,7 @@ export async function loadSpec(previewId: string): Promise return null; } } + +export async function overwriteSpec(previewId: string, spec: GeneratorSpec): Promise { + await getRedis().set(key(previewId), JSON.stringify(spec), 'EX', TTL_SECONDS); +} diff --git a/apps/api/src/routes/servers.ts b/apps/api/src/routes/servers.ts index d0c4432..bcd876c 100644 --- a/apps/api/src/routes/servers.ts +++ b/apps/api/src/routes/servers.ts @@ -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 { 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 { 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, + }; +} diff --git a/apps/web/app/(dashboard)/servers/new/page.tsx b/apps/web/app/(dashboard)/servers/new/page.tsx index dfc87d0..47d529b 100644 --- a/apps/web/app/(dashboard)/servers/new/page.tsx +++ b/apps/web/app/(dashboard)/servers/new/page.tsx @@ -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('prompt'); @@ -67,6 +91,7 @@ export default function NewServerPage() { const [slug, setSlug] = useState(''); const [preview, setPreview] = useState(null); + const [editable, setEditable] = useState(null); const [secretValues, setSecretValues] = useState>({}); const [error, setError] = useState(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 = {}; + 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 = {}; - 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) { + 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 = {}; - 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 = {}; + 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 (
@@ -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." />

- Next step we'll show you exactly which tools we'll expose and which credentials we need from you. + Next step we'll show you exactly which tools we'll expose and let you tweak + the spec before we build.

{EXAMPLE_PROMPTS.map((p) => ( @@ -228,81 +361,122 @@ export default function NewServerPage() {
)} - {step === 'confirm' && preview && ( + {step === 'confirm' && preview && editable && (

Confirm what we'll build

- - spec via {preview.source} - +
+ {editsDirty && ( + + )} + + spec via {preview.source} + +

{preview.spec.description}

+

+ Edit tool names, descriptions or input schemas inline. Renaming parameters may + require an Iterate after build to update the + implementation — the existing impl references the original names. +

- Tools ({preview.spec.tools.length}) + Tools ({editable.tools.length})

-
- {preview.spec.tools.map((tool) => ( -
-
- {tool.name} - - {Object.keys(tool.inputSchema).length} param - {Object.keys(tool.inputSchema).length === 1 ? '' : 's'} +
+ {editable.tools.map((tool, i) => ( +
+
+ updateTool(i, { name: e.target.value })} + placeholder="snake_case_tool_name" + className="mono" + /> + + {Object.keys(safeJsonObject(tool.inputSchemaJson)).length} param + {Object.keys(safeJsonObject(tool.inputSchemaJson)).length === 1 ? '' : 's'}
-

{tool.description}

- {Object.keys(tool.inputSchema).length > 0 && ( -
- -
- )} +