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,
};
}