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:
parent
09688c1114
commit
dda8f94de4
@ -23,3 +23,7 @@ export async function loadSpec(previewId: string): Promise<GeneratorSpec | null>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function overwriteSpec(previewId: string, spec: GeneratorSpec): Promise<void> {
|
||||||
|
await getRedis().set(key(previewId), JSON.stringify(spec), 'EX', TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,14 +1,21 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets } from '@bmm/db';
|
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 { generateSpec, SpecValidationError, BannedPatternError } from '@bmm/llm';
|
||||||
|
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
|
||||||
import { requireAuth } from '../plugins/session.js';
|
import { requireAuth } from '../plugins/session.js';
|
||||||
import { getBuildQueue } from '../lib/queue.js';
|
import { getBuildQueue } from '../lib/queue.js';
|
||||||
import { buildChannel, getSubscriber } from '../lib/redis.js';
|
import { buildChannel, getSubscriber } from '../lib/redis.js';
|
||||||
import { encryptSecret } from '../lib/crypto.js';
|
import { encryptSecret } from '../lib/crypto.js';
|
||||||
import { audit } from '../lib/audit.js';
|
import { audit } from '../lib/audit.js';
|
||||||
import { cacheSpec } from '../lib/preview-cache.js';
|
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@ -68,7 +75,32 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
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
|
const existing = await db
|
||||||
.select({ id: mcpServers.id })
|
.select({ id: mcpServers.id })
|
||||||
@ -313,3 +345,52 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.send({ ok: true });
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -8,7 +8,7 @@ import { Input, Label, Textarea } from '@/components/input';
|
|||||||
import { StreamingLogs } from '@/components/streaming-logs';
|
import { StreamingLogs } from '@/components/streaming-logs';
|
||||||
import { InstallSnippets } from '@/components/install-snippets';
|
import { InstallSnippets } from '@/components/install-snippets';
|
||||||
import { CodeBlock } from '@/components/code-block';
|
import { CodeBlock } from '@/components/code-block';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, RotateCcw, X } from 'lucide-react';
|
||||||
|
|
||||||
const EXAMPLE_PROMPTS = [
|
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 {
|
interface BuildResult {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
publicUrl: string | null;
|
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() {
|
export default function NewServerPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [step, setStep] = useState<Step>('prompt');
|
const [step, setStep] = useState<Step>('prompt');
|
||||||
@ -67,6 +91,7 @@ export default function NewServerPage() {
|
|||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
|
|
||||||
const [preview, setPreview] = useState<PreviewResponse | null>(null);
|
const [preview, setPreview] = useState<PreviewResponse | null>(null);
|
||||||
|
const [editable, setEditable] = useState<EditableSpec | null>(null);
|
||||||
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -77,6 +102,16 @@ export default function NewServerPage() {
|
|||||||
const trySlug = (n: string) =>
|
const trySlug = (n: string) =>
|
||||||
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
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() {
|
async function analyze() {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (prompt.trim().length < 10) {
|
if (prompt.trim().length < 10) {
|
||||||
@ -94,9 +129,7 @@ export default function NewServerPage() {
|
|||||||
body: JSON.stringify({ prompt }),
|
body: JSON.stringify({ prompt }),
|
||||||
});
|
});
|
||||||
setPreview(res);
|
setPreview(res);
|
||||||
const initial: Record<string, string> = {};
|
setEditable(null); // will re-init via useEffect
|
||||||
for (const key of res.spec.requiredSecrets) initial[key] = '';
|
|
||||||
setSecretValues(initial);
|
|
||||||
setStep('confirm');
|
setStep('confirm');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
|
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() {
|
async function build() {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (!preview) return;
|
if (!preview || !editable) return;
|
||||||
|
|
||||||
const filledSecrets: Record<string, string> = {};
|
// Validate every schema parses
|
||||||
for (const [k, v] of Object.entries(secretValues)) {
|
const badTool = editable.tools.find((t) => t.schemaError);
|
||||||
if (v.trim()) filledSecrets[k] = v;
|
if (badTool) {
|
||||||
}
|
setError(`Fix schema for "${badTool.name}": ${badTool.schemaError}`);
|
||||||
const missing = preview.spec.requiredSecrets.filter((k) => !filledSecrets[k]);
|
|
||||||
if (missing.length > 0) {
|
|
||||||
setError(`Fill in: ${missing.join(', ')} — or remove if not needed.`);
|
|
||||||
return;
|
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 {
|
try {
|
||||||
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
|
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
|
||||||
'/v1/servers',
|
'/v1/servers',
|
||||||
@ -130,6 +256,7 @@ export default function NewServerPage() {
|
|||||||
prompt,
|
prompt,
|
||||||
secrets: filledSecrets,
|
secrets: filledSecrets,
|
||||||
previewId: preview.previewId,
|
previewId: preview.previewId,
|
||||||
|
specEdit,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -137,12 +264,17 @@ export default function NewServerPage() {
|
|||||||
setServerId(res.server.id);
|
setServerId(res.server.id);
|
||||||
setStep('building');
|
setStep('building');
|
||||||
} catch (e) {
|
} 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);
|
setError(detail?.error ?? (e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stepNumber = step === 'prompt' ? 1 : step === 'analyzing' || step === 'confirm' ? 2 : 3;
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-6 py-8">
|
<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."
|
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]">
|
<p className="text-[12px] leading-relaxed text-[--color-fg-subtle]">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
{EXAMPLE_PROMPTS.map((p) => (
|
{EXAMPLE_PROMPTS.map((p) => (
|
||||||
@ -228,81 +361,122 @@ export default function NewServerPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'confirm' && preview && (
|
{step === 'confirm' && preview && editable && (
|
||||||
<div className="mt-7 space-y-6">
|
<div className="mt-7 space-y-6">
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we'll build</h2>
|
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we'll build</h2>
|
||||||
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
|
<div className="flex items-center gap-3">
|
||||||
spec via {preview.source}
|
{editsDirty && (
|
||||||
</span>
|
<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>
|
</div>
|
||||||
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[13px] font-semibold tracking-tight">
|
<h3 className="text-[13px] font-semibold tracking-tight">
|
||||||
Tools ({preview.spec.tools.length})
|
Tools ({editable.tools.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-3">
|
||||||
{preview.spec.tools.map((tool) => (
|
{editable.tools.map((tool, i) => (
|
||||||
<div key={tool.name} className="panel p-3">
|
<div key={i} className="panel p-3 space-y-2.5">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="grid gap-2 md:grid-cols-[1fr_auto]">
|
||||||
<span className="mono text-[13px] font-semibold">{tool.name}</span>
|
<Input
|
||||||
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
|
value={tool.name}
|
||||||
{Object.keys(tool.inputSchema).length} param
|
onChange={(e) => updateTool(i, { name: e.target.value })}
|
||||||
{Object.keys(tool.inputSchema).length === 1 ? '' : 's'}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p>
|
<Textarea
|
||||||
{Object.keys(tool.inputSchema).length > 0 && (
|
rows={2}
|
||||||
<div className="mt-2">
|
value={tool.description}
|
||||||
<CodeBlock
|
onChange={(e) => updateTool(i, { description: e.target.value })}
|
||||||
label="input schema"
|
placeholder="What the AI client sees — one clear sentence."
|
||||||
code={JSON.stringify(tool.inputSchema, null, 2)}
|
/>
|
||||||
/>
|
<div className="space-y-1">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{preview.spec.requiredSecrets.length > 0 ? (
|
<div>
|
||||||
<div>
|
<h3 className="text-[13px] font-semibold tracking-tight">
|
||||||
<h3 className="text-[13px] font-semibold tracking-tight">
|
Credentials we need
|
||||||
Credentials we need
|
</h3>
|
||||||
</h3>
|
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
||||||
<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
|
||||||
These will be AES-256-GCM encrypted at rest and injected as environment variables
|
implementation doesn't actually use one.
|
||||||
into your server's container at runtime.
|
</p>
|
||||||
</p>
|
<div className="mt-3 space-y-2">
|
||||||
<div className="mt-3 space-y-2">
|
{editable.requiredSecrets.length === 0 && (
|
||||||
{preview.spec.requiredSecrets.map((key) => (
|
<p className="text-[12.5px] text-[--color-fg-muted]">
|
||||||
<div key={key} className="space-y-1">
|
No credentials. This server runs self-contained.
|
||||||
<Label htmlFor={`secret-${key}`}>
|
</p>
|
||||||
<span className="mono">{key}</span>
|
)}
|
||||||
</Label>
|
{editable.requiredSecrets.map((key, idx) => (
|
||||||
<Input
|
<div key={idx} className="grid grid-cols-[180px_1fr_auto] items-start gap-2">
|
||||||
id={`secret-${key}`}
|
<Input
|
||||||
type="password"
|
value={key}
|
||||||
value={secretValues[key] ?? ''}
|
onChange={(e) => updateSecretKey(idx, e.target.value)}
|
||||||
onChange={(e) =>
|
placeholder="MY_API_KEY"
|
||||||
setSecretValues((s) => ({ ...s, [key]: e.target.value }))
|
className="mono"
|
||||||
}
|
/>
|
||||||
placeholder="paste credential value"
|
<Input
|
||||||
/>
|
type="password"
|
||||||
</div>
|
value={secretValues[key] ?? ''}
|
||||||
))}
|
onChange={(e) => setSecretValues((s) => ({ ...s, [key]: e.target.value }))}
|
||||||
</div>
|
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>
|
||||||
<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 && (
|
{preview.spec.scopes.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
@ -326,7 +500,12 @@ export default function NewServerPage() {
|
|||||||
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
|
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
|
||||||
← Back
|
← Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="md" onClick={build}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={build}
|
||||||
|
disabled={Boolean(hasSchemaErrors)}
|
||||||
|
>
|
||||||
Build server →
|
Build server →
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -394,3 +573,13 @@ export default function NewServerPage() {
|
|||||||
</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 {};
|
||||||
|
}
|
||||||
|
|||||||
@ -111,6 +111,23 @@ export type BuildEvent = z.infer<typeof BuildEvent>;
|
|||||||
|
|
||||||
// ---- API request payloads ----
|
// ---- 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({
|
export const CreateServerInput = z.object({
|
||||||
name: z.string().min(1).max(128),
|
name: z.string().min(1).max(128),
|
||||||
slug: z
|
slug: z
|
||||||
@ -121,6 +138,7 @@ export const CreateServerInput = z.object({
|
|||||||
prompt: z.string().min(10).max(8000),
|
prompt: z.string().min(10).max(8000),
|
||||||
secrets: z.record(z.string(), z.string()).default({}),
|
secrets: z.record(z.string(), z.string()).default({}),
|
||||||
previewId: z.string().min(1).max(64).optional(),
|
previewId: z.string().min(1).max(64).optional(),
|
||||||
|
specEdit: SpecEdit.optional(),
|
||||||
});
|
});
|
||||||
export type CreateServerInput = z.infer<typeof CreateServerInput>;
|
export type CreateServerInput = z.infer<typeof CreateServerInput>;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user