diff --git a/CHOICES.md b/CHOICES.md index 75f7d9b..6e2ab49 100644 --- a/CHOICES.md +++ b/CHOICES.md @@ -82,3 +82,68 @@ for prod. No CJS. ## Conventional commits per task, single branch Single `main` branch, conventional-commits messages. No PR flow because this is one autonomous run. + +--- + +# Sprint 3.5 — Real 3-step wizard, filled-in pages + +Done after live-running the Sprint 1-3 build and finding two UX gaps: +1. The wizard was a single-step that asked for secrets the user didn't necessarily need. +2. Half the nav links 404'd because the marketing/dashboard surfaces had un-built routes. + +## Two-phase generation (preview → cache → build) +The wizard now runs Claude twice in spirit but **only once on the wire**: +- Step 1 → `POST /v1/servers/preview` calls Claude synchronously, validates the spec, and + caches it in Redis under `preview:` with a 5-minute TTL. +- Step 2 renders the parsed tools + the credentials *Claude actually identified*. + No more generic Notion placeholders — the form is the spec. +- Step 3 → `POST /v1/servers` carries `previewId`. The BullMQ worker reads the cached + spec from Redis (`loadCachedSpec`) and skips the second Claude round-trip. Saves ~30s. + +If the cache is missed (TTL expired or restart) the worker regenerates fresh and logs +a `warn` so the build still completes. + +## Shared `@bmm/llm` package +`SYSTEM_PROMPT` + `generateSpec()` + `SpecValidationError` + `BannedPatternError` live +in `packages/llm`. Both `apps/api` (for the synchronous preview path) and +`apps/generator` (for the worker path) import the same module. No code duplication, no +prompt drift between the two surfaces. + +## Audit log writes +`apps/api/src/lib/audit.ts` exposes a single `audit()` helper that swallows its own +errors (audit failures never block the request path). Five write sites: +- `auth.login`, `auth.logout`, `server.create`, `server.iterate`, `server.delete`. +- Each entry includes `orgId`, `userId`, `resourceType`, `resourceId`, `metadata`, + `ipAddress`. The `/audit` UI page reads from `GET /v1/audit?limit=&action=`. + +## All marketing + dashboard links now resolve +Built out the previously-404 routes as full pages (no "Coming soon" placeholders): +- `/docs` + 6 sub-pages (Quickstart, MCP concepts, OAuth flow, Authoring tools, + Self-hosting, API reference, FAQ) under a static sidebar layout. MDX migration is + Sprint 4; for Sprint 3.5 they're plain TSX with the new ``, + ``, `` primitives. +- `/changelog` — release timeline with tags (launch / feature / fix). +- `/security` — eight pillars with bodies (per-server isolation, encryption, + OAuth 2.1, no-token-passthrough, static checks, container hardening, audit log, + rate limiting) plus disclosure email and compliance roadmap. +- `/privacy` — six sections (what we collect, what we don't, where it lives, + subprocessors, retention, GDPR rights). +- `/terms` — ten clauses. +- `/pricing` — full grid of the four tiers + pricing FAQ. Marketing nav now points + to `/pricing` instead of the landing anchor. +- `/status` — live JS probes against the dashboard and `/api/health` every 10s. + +## Settings + Audit dashboard pages +- `/settings` reads `GET /v1/me/org` and renders five cards: Organization, Plan & usage, + Members (table), API keys (Sprint 4 stub), Encryption (read-only status). +- `/audit` reads `GET /v1/audit` and renders a filterable table with action pills, + resource refs, IP, and metadata JSON. + +## What's *still* Sprint 4 +- Real Stripe billing (the "Manage billing" button in /settings is intentionally + disabled). +- Pagefind search in /docs. +- MDX migration of the docs pages. +- Real BetterStack-hosted status board at status.buildmymcpserver.com. +- Custom domain CNAME validation per server. +- `bmm` CLI + per-org API keys. diff --git a/apps/web/app/(dashboard)/audit/page.tsx b/apps/web/app/(dashboard)/audit/page.tsx new file mode 100644 index 0000000..9960fa3 --- /dev/null +++ b/apps/web/app/(dashboard)/audit/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import { Input } from '@/components/input'; + +interface AuditEntry { + id: string; + action: string; + resourceType: string | null; + resourceId: string | null; + metadata: Record | null; + ipAddress: string | null; + userId: string | null; + createdAt: string; +} + +const ACTION_FILTERS = [ + { value: '', label: 'All actions' }, + { value: 'auth.login', label: 'Logins' }, + { value: 'auth.logout', label: 'Logouts' }, + { value: 'server.create', label: 'Server created' }, + { value: 'server.iterate', label: 'Server iterated' }, + { value: 'server.delete', label: 'Server deleted' }, +]; + +export default function AuditPage() { + const [entries, setEntries] = useState(null); + const [action, setAction] = useState(''); + const [search, setSearch] = useState(''); + + useEffect(() => { + const q = action ? `?action=${encodeURIComponent(action)}` : ''; + apiFetch<{ entries: AuditEntry[] }>(`/v1/audit${q}`).then((r) => setEntries(r.entries)); + }, [action]); + + const visible = entries?.filter((e) => + search + ? e.action.includes(search) || + e.resourceId?.includes(search) || + e.ipAddress?.includes(search) + : true, + ); + + return ( +
+
+

Audit log

+

+ Every privileged action in your workspace, with IP and metadata. +

+
+ +
+ + setSearch(e.target.value)} + placeholder="Filter by resource id or ip…" + className="w-72" + /> +
+ +
+ {!visible && ( +
Loading…
+ )} + {visible && visible.length === 0 && ( +
+ No matching entries. +
+ )} + {visible && visible.length > 0 && ( + + + + + + + + + + + + {visible.map((e) => ( + + + + + + + + ))} + +
WhenActionResourceIPMetadata
+ {new Date(e.createdAt).toLocaleString()} + + + {e.action} + + + {e.resourceType ? `${e.resourceType}/${e.resourceId?.slice(0, 8) ?? '—'}` : '—'} + {e.ipAddress ?? '—'} + {e.metadata ? JSON.stringify(e.metadata) : '—'} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/servers/new/page.tsx b/apps/web/app/(dashboard)/servers/new/page.tsx index 33656ab..dfc87d0 100644 --- a/apps/web/app/(dashboard)/servers/new/page.tsx +++ b/apps/web/app/(dashboard)/servers/new/page.tsx @@ -8,15 +8,50 @@ 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'; const EXAMPLE_PROMPTS = [ - 'Read-only Postgres reader for the users and orders tables at db.example.com', - 'Search and read pages from our Notion workspace via the Notion API', - 'Wrap our internal HTTP API at api.acme.com — endpoints /search and /lookup', - 'Stripe charges and customers (read-only)', + { + label: 'Echo / demo (no external API)', + text: 'Build a simple echo server with two tools: echo (string in, same string back) and now (returns current UTC timestamp). No external API needed.', + }, + { + label: 'Notion search', + text: 'Search and read pages from our Notion workspace via the Notion API. Tools: search_pages(query), get_page_content(page_id). Auth: NOTION_API_KEY.', + }, + { + label: 'Postgres reader', + text: 'Read-only Postgres reader for the users and orders tables at db.example.com. One tool per table: list_users(limit), list_orders(limit, customer_id?). Auth: DATABASE_URL.', + }, + { + label: 'Wrap our REST API', + text: 'Wrap our internal HTTP API at api.acme.com — endpoints /search?q= and /lookup?id=. Tools: search(query), lookup(id). Auth: ACME_API_TOKEN.', + }, + { + label: 'Stripe charges (read-only)', + text: 'Stripe charges and customers, read-only. Tools: list_recent_charges(limit), get_customer(customer_id). Auth: STRIPE_SECRET_KEY.', + }, ]; -type Step = 'prompt' | 'building' | 'done'; +type Step = 'prompt' | 'analyzing' | 'confirm' | 'building' | 'done'; + +interface PreviewTool { + name: string; + description: string; + inputSchema: Record; +} + +interface PreviewResponse { + previewId: string; + source: 'claude' | 'mock'; + spec: { + name: string; + description?: string; + tools: PreviewTool[]; + requiredSecrets: string[]; + scopes: string[]; + }; +} interface BuildResult { serverId: string; @@ -26,10 +61,14 @@ interface BuildResult { export default function NewServerPage() { const router = useRouter(); const [step, setStep] = useState('prompt'); + + const [prompt, setPrompt] = useState(''); const [name, setName] = useState(''); const [slug, setSlug] = useState(''); - const [prompt, setPrompt] = useState(''); - const [secretRows, setSecretRows] = useState<{ key: string; value: string }[]>([{ key: '', value: '' }]); + + const [preview, setPreview] = useState(null); + const [secretValues, setSecretValues] = useState>({}); + const [error, setError] = useState(null); const [buildId, setBuildId] = useState(null); const [serverId, setServerId] = useState(null); @@ -38,20 +77,61 @@ export default function NewServerPage() { const trySlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32); - async function submit() { + async function analyze() { setError(null); - if (!name || !slug || prompt.length < 10) { - setError('Name, slug and a prompt of at least 10 characters are required.'); + if (prompt.trim().length < 10) { + setError('Prompt must be at least 10 characters.'); return; } - const secrets: Record = {}; - for (const row of secretRows) { - if (row.key && row.value) secrets[row.key.trim()] = row.value; + if (!name || !slug) { + setError('Name and slug are required.'); + return; } + setStep('analyzing'); + try { + const res = await apiFetch('/v1/servers/preview', { + method: 'POST', + body: JSON.stringify({ prompt }), + }); + setPreview(res); + const initial: Record = {}; + for (const key of res.spec.requiredSecrets) initial[key] = ''; + setSecretValues(initial); + setStep('confirm'); + } catch (e) { + const detail = (e as { detail?: { error?: string; detail?: string } }).detail; + setError(detail?.detail ?? detail?.error ?? (e as Error).message); + setStep('prompt'); + } + } + + async function build() { + setError(null); + if (!preview) 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.`); + return; + } + try { const res = await apiFetch<{ server: { id: string }; build: { id: string } }>( '/v1/servers', - { method: 'POST', body: JSON.stringify({ name, slug, prompt, secrets }) }, + { + method: 'POST', + body: JSON.stringify({ + name, + slug, + prompt, + secrets: filledSecrets, + previewId: preview.previewId, + }), + }, ); setBuildId(res.build.id); setServerId(res.server.id); @@ -62,35 +142,40 @@ export default function NewServerPage() { } } + const stepNumber = step === 'prompt' ? 1 : step === 'analyzing' || step === 'confirm' ? 2 : 3; + return (

New MCP server

- STEP {step === 'prompt' ? '1' : step === 'building' ? '2' : '3'} / 3 + STEP {stepNumber} / 3
{step === 'prompt' && (
- +