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;
|
||||
}
|
||||
}
|
||||
|
||||
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 { 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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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'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>
|
||||
<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'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's container at runtime.
|
||||
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
|
||||
implementation doesn'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 {};
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user