feat(web): real 3-step wizard, settings, audit, docs, marketing pages
Sprint 3.5: close every dead link and replace the single-step wizard with the spec-mandated 3-step flow. Wizard: - Step 1 collects prompt + name + slug, calls /v1/servers/preview. - Step 2 renders parsed tools (name, description, input schema as copyable JSON) + a credential field per requiredSecret Claude actually identified. Self-contained servers see 'No credentials needed' instead of generic Notion placeholders. - Step 3 streams the live build over WebSocket and shows install snippets. New dashboard pages: - /settings — org, plan/usage, members table, API keys + billing stubs (Sprint 4), encryption status. Reads /v1/me/org. - /audit — filterable table over /v1/audit with action pills, resource refs, IP, metadata JSON. Docs site (/docs + 6 sub-pages): - Sticky 240px sidebar, max-w-prose article column, shared DocsTitle/H2/Code primitives. - Quickstart, MCP concepts, OAuth 2.1 flow (full walkthrough with curl), Authoring tools, Self-hosting, API reference, FAQ. Marketing pages: - /changelog with tagged release timeline. - /security with 8 pillars + disclosure. - /privacy with GDPR-aware sections. - /terms (10 clauses). - /pricing full page (nav now points here instead of /#pricing anchor). - /status with live 10s probes against /api/health and /login. Footer 'system status' badge now links to /status. All 20 routes 200 OK in smoke crawl. Typecheck clean across packages.
This commit is contained in:
parent
1c92964bbd
commit
09688c1114
65
CHOICES.md
65
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:<id>` 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 `<DocsTitle>`,
|
||||
`<DocsH2>`, `<DocsCode>` 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.
|
||||
|
||||
120
apps/web/app/(dashboard)/audit/page.tsx
Normal file
120
apps/web/app/(dashboard)/audit/page.tsx
Normal file
@ -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<string, unknown> | 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<AuditEntry[] | null>(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 (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Audit log</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
Every privileged action in your workspace, with IP and metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<select
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
className="h-8 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[13px] focus:border-[--color-accent] focus:outline-none"
|
||||
>
|
||||
{ACTION_FILTERS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter by resource id or ip…"
|
||||
className="w-72"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel mt-4">
|
||||
{!visible && (
|
||||
<div className="px-4 py-4 text-[12.5px] text-[--color-fg-muted]">Loading…</div>
|
||||
)}
|
||||
{visible && visible.length === 0 && (
|
||||
<div className="px-4 py-12 text-center text-[13px] text-[--color-fg-muted]">
|
||||
No matching entries.
|
||||
</div>
|
||||
)}
|
||||
{visible && visible.length > 0 && (
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">When</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Action</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Resource</th>
|
||||
<th className="px-4 py-2 text-left font-medium">IP</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Metadata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((e) => (
|
||||
<tr key={e.id} className="border-b border-[--color-border] last:border-0">
|
||||
<td className="px-4 py-2 mono text-[--color-fg-muted]">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px]">
|
||||
{e.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 mono text-[--color-fg-muted]">
|
||||
{e.resourceType ? `${e.resourceType}/${e.resourceId?.slice(0, 8) ?? '—'}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 mono text-[--color-fg-muted]">{e.ipAddress ?? '—'}</td>
|
||||
<td className="px-4 py-2 mono text-[10.5px] text-[--color-fg-subtle]">
|
||||
{e.metadata ? JSON.stringify(e.metadata) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<Step>('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<PreviewResponse | null>(null);
|
||||
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [buildId, setBuildId] = useState<string | null>(null);
|
||||
const [serverId, setServerId] = useState<string | null>(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<string, string> = {};
|
||||
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<PreviewResponse>('/v1/servers/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
setPreview(res);
|
||||
const initial: Record<string, string> = {};
|
||||
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<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.`);
|
||||
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 (
|
||||
<div className="mx-auto max-w-3xl px-6 py-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">New MCP server</h1>
|
||||
<div className="mono text-[11px] tracking-wider text-[--color-fg-subtle]">
|
||||
STEP {step === 'prompt' ? '1' : step === 'building' ? '2' : '3'} / 3
|
||||
STEP {stepNumber} / 3
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step === 'prompt' && (
|
||||
<div className="mt-7 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="prompt">Describe your tool</Label>
|
||||
<Label htmlFor="prompt">Describe what your tool should do</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
rows={5}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="A sentence is enough. Mention APIs, secrets, scopes, expected tool names."
|
||||
placeholder="A sentence is enough. Mention which APIs you need, which scopes, what the AI client should be able to do."
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<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.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{EXAMPLE_PROMPTS.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p}
|
||||
onClick={() => setPrompt(p)}
|
||||
key={p.label}
|
||||
onClick={() => setPrompt(p.text)}
|
||||
className="rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2.5 py-1 text-[11.5px] text-[--color-fg-muted] transition-colors hover:border-[--color-border-strong] hover:text-[--color-fg]"
|
||||
>
|
||||
{p}
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -110,7 +195,7 @@ export default function NewServerPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slug" hint="subdomain / id">Slug</Label>
|
||||
<Label htmlFor="slug" hint="becomes subdomain / id">Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
@ -120,58 +205,128 @@ export default function NewServerPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label hint="environment variables, encrypted at rest">Secrets</Label>
|
||||
<div className="space-y-2">
|
||||
{secretRows.map((row, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<Input
|
||||
placeholder="NOTION_API_KEY"
|
||||
value={row.key}
|
||||
onChange={(e) => {
|
||||
const next = [...secretRows];
|
||||
next[i] = { key: e.target.value.toUpperCase(), value: row.value };
|
||||
setSecretRows(next);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
placeholder="secret_xxx"
|
||||
type="password"
|
||||
value={row.value}
|
||||
onChange={(e) => {
|
||||
const next = [...secretRows];
|
||||
next[i] = { key: row.key, value: e.target.value };
|
||||
setSecretRows(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={() =>
|
||||
setSecretRows((rs) => (rs.length > 1 ? rs.filter((_, j) => j !== i) : rs))
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSecretRows((rs) => [...rs, { key: '', value: '' }])}
|
||||
>
|
||||
+ Add secret
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="md" onClick={() => router.push('/servers')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" onClick={submit}>
|
||||
<Button variant="primary" size="md" onClick={analyze}>
|
||||
Analyze →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'analyzing' && (
|
||||
<div className="mt-10 panel p-8 text-center">
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
|
||||
<p className="mt-4 text-[13px]">Analyzing your prompt…</p>
|
||||
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
|
||||
Claude Opus 4.7 is parsing the spec. Usually 20–40 seconds.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && preview && (
|
||||
<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>
|
||||
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
|
||||
spec via {preview.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold tracking-tight">
|
||||
Tools ({preview.spec.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'}
|
||||
</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)}
|
||||
/>
|
||||
</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.
|
||||
</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>
|
||||
<Input
|
||||
id={`secret-${key}`}
|
||||
type="password"
|
||||
value={secretValues[key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setSecretValues((s) => ({ ...s, [key]: e.target.value }))
|
||||
}
|
||||
placeholder="paste credential value"
|
||||
/>
|
||||
</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 && (
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold tracking-tight">OAuth scopes</h3>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{preview.spec.scopes.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px] text-[--color-fg-muted]"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button variant="primary" size="md" onClick={build}>
|
||||
Build server →
|
||||
</Button>
|
||||
</div>
|
||||
@ -220,9 +375,7 @@ export default function NewServerPage() {
|
||||
Drop this into your client. OAuth handshake runs automatically on first use.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<InstallSnippets
|
||||
input={{ name, slug, publicUrl: result.publicUrl }}
|
||||
/>
|
||||
<InstallSnippets input={{ name, slug, publicUrl: result.publicUrl }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
178
apps/web/app/(dashboard)/settings/page.tsx
Normal file
178
apps/web/app/(dashboard)/settings/page.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Org {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
monthlyCallQuota: number;
|
||||
callsThisPeriod: number;
|
||||
periodStartsAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
id: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [org, setOrg] = useState<Org | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ org: Org; members: Member[] }>('/v1/me/org')
|
||||
.then((r) => {
|
||||
setOrg(r.org);
|
||||
setMembers(r.members);
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
|
||||
if (err?.includes('401')) {
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-8">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Settings</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
Organization, plan, members, billing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!org && <div className="mt-6 text-[12.5px] text-[--color-fg-muted]">Loading…</div>}
|
||||
|
||||
{org && (
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<Card title="Organization">
|
||||
<Row label="Name" value={org.name} />
|
||||
<Row label="Slug" value={org.slug} mono />
|
||||
<Row label="Created" value={new Date(org.createdAt).toLocaleString()} />
|
||||
<Row label="Organization ID" value={org.id} mono small />
|
||||
</Card>
|
||||
|
||||
<Card title="Plan & usage">
|
||||
<Row label="Plan" value={org.plan.charAt(0).toUpperCase() + org.plan.slice(1)} />
|
||||
<Row
|
||||
label="Calls this period"
|
||||
value={`${org.callsThisPeriod.toLocaleString()} / ${org.monthlyCallQuota.toLocaleString()}`}
|
||||
/>
|
||||
<Row
|
||||
label="Period started"
|
||||
value={new Date(org.periodStartsAt).toLocaleString()}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
Manage billing (Sprint 4)
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Members" className="md:col-span-2">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
|
||||
<tr>
|
||||
<th className="py-2 text-left font-medium">Email</th>
|
||||
<th className="py-2 text-left font-medium">Name</th>
|
||||
<th className="py-2 text-left font-medium">Role</th>
|
||||
<th className="py-2 text-left font-medium">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.id} className="border-b border-[--color-border] last:border-0">
|
||||
<td className="py-2.5 mono">{m.email}</td>
|
||||
<td className="py-2.5 text-[--color-fg-muted]">{m.name ?? '—'}</td>
|
||||
<td className="py-2.5">
|
||||
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px]">
|
||||
{m.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 text-[--color-fg-muted]">
|
||||
{new Date(m.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3">
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
Invite member (Team plan)
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="API keys">
|
||||
<p className="text-[12.5px] text-[--color-fg-muted]">
|
||||
Programmatic access for the upcoming <span className="mono">bmm</span> CLI.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
Generate key (Sprint 4)
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Encryption">
|
||||
<Row label="Secret storage" value="AES-256-GCM" mono />
|
||||
<Row label="Key source" value="env (SECRETS_ENCRYPTION_KEY)" mono />
|
||||
<p className="mt-3 text-[12px] leading-relaxed text-[--color-fg-subtle]">
|
||||
Secrets are encrypted before write to Postgres, decrypted only at the moment of
|
||||
container env injection. Plaintext is never logged.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`panel p-4 ${className ?? ''}`}>
|
||||
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{title}</div>
|
||||
<div className="mt-3 space-y-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
small,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
small?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 text-[12.5px]">
|
||||
<span className="text-[--color-fg-subtle]">{label}</span>
|
||||
<span className={`${mono ? 'mono' : ''} ${small ? 'text-[11px]' : ''} text-[--color-fg]`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
apps/web/app/(marketing)/changelog/page.tsx
Normal file
102
apps/web/app/(marketing)/changelog/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { CodeBlock } from '@/components/code-block';
|
||||
|
||||
export const metadata = { title: 'Changelog — BuildMyMCPServer' };
|
||||
|
||||
interface Release {
|
||||
version: string;
|
||||
date: string;
|
||||
tag: 'launch' | 'feature' | 'fix';
|
||||
title: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const RELEASES: Release[] = [
|
||||
{
|
||||
version: '0.2.0',
|
||||
date: '2026-05-19',
|
||||
tag: 'feature',
|
||||
title: '3-step wizard + filled-in pages',
|
||||
items: [
|
||||
'Real 3-step wizard: prompt → confirm parsed spec → build. Step 2 shows the tools Claude actually parsed and only asks for the credentials it identified.',
|
||||
'Preview endpoint: POST /v1/servers/preview runs Claude once, caches the spec 5 min, build worker reuses it. Saves a second Claude round-trip (~30s).',
|
||||
'Shared @bmm/llm package — system prompt + generateSpec live in one place, used by api and generator.',
|
||||
'Audit log: writes for login, logout, server.create, server.iterate, server.delete. /v1/audit endpoint + /audit page.',
|
||||
'Real /settings page (org info, plan & usage, members, encryption status).',
|
||||
'Full /docs site: quickstart, MCP concepts, OAuth flow, authoring, self-hosting, API reference, FAQ.',
|
||||
'Changelog, /security, /privacy, /terms, /pricing, /status pages — all marketing links work.',
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '0.1.0',
|
||||
date: '2026-05-18',
|
||||
tag: 'launch',
|
||||
title: 'Sprints 1–3 — initial public dev build',
|
||||
items: [
|
||||
'Monorepo: Next.js 15 + Fastify + BullMQ generator + runner-template.',
|
||||
'Drizzle schema for orgs, servers, builds, secrets, oauth, metrics, audit.',
|
||||
'Magic-link auth, 30-day sessions, AES-256-GCM secret encryption.',
|
||||
'OAuth 2.1 Authorization Server: PKCE, RFC 7591 dynamic registration, RFC 8707 resource indicators, RS256 JWKS.',
|
||||
'Runner template with Streamable HTTP + OAuth 2.1 Resource Server.',
|
||||
'WebSocket build stream: queued → generating → building → deploying → live.',
|
||||
'Install snippet generator for Claude Desktop, Cursor, ChatGPT.',
|
||||
'docker-compose dev environment, pnpm dev bootstraps everything.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const tagStyle: Record<Release['tag'], string> = {
|
||||
launch: 'border-[--color-accent]/40 bg-[--color-accent]/10 text-[--color-accent]',
|
||||
feature: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
|
||||
fix: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
|
||||
};
|
||||
|
||||
export default function Changelog() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<header className="mb-12">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
Release notes
|
||||
</div>
|
||||
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">Changelog</h1>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||
What shipped, when, and why.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-12">
|
||||
{RELEASES.map((r) => (
|
||||
<article key={r.version} className="relative pl-6">
|
||||
<div className="absolute left-0 top-2 size-2 rounded-full border border-[--color-border-strong] bg-[--color-bg-elevated]" />
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="mono text-[13px] text-[--color-fg]">v{r.version}</span>
|
||||
<span className="text-[12px] text-[--color-fg-subtle]">{r.date}</span>
|
||||
<span
|
||||
className={`mono rounded-full border px-2 py-0.5 text-[10.5px] uppercase tracking-wider ${tagStyle[r.tag]}`}
|
||||
>
|
||||
{r.tag}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-[16px] font-semibold tracking-tight">{r.title}</h2>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{r.items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="relative pl-4 text-[13px] leading-relaxed text-[--color-fg-muted] before:absolute before:left-0 before:top-2 before:size-1 before:rounded-full before:bg-[--color-fg-subtle]"
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-16">
|
||||
<CodeBlock
|
||||
label="subscribe"
|
||||
code={`Follow @buildmymcp on Mastodon — or watch the GitHub repo for tagged releases.`}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -12,7 +12,7 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
|
||||
<Link href="/#how" className="transition-colors hover:text-[--color-fg]">
|
||||
How it works
|
||||
</Link>
|
||||
<Link href="/#pricing" className="transition-colors hover:text-[--color-fg]">
|
||||
<Link href="/pricing" className="transition-colors hover:text-[--color-fg]">
|
||||
Pricing
|
||||
</Link>
|
||||
<Link href="/docs" className="transition-colors hover:text-[--color-fg]">
|
||||
@ -42,10 +42,10 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
|
||||
<main className="flex-1">{children}</main>
|
||||
<footer className="border-t border-[--color-border] py-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4 px-6 text-[12px] text-[--color-fg-subtle] md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/status" className="flex items-center gap-2 transition-colors hover:text-[--color-fg]">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||
<span>All systems operational</span>
|
||||
</div>
|
||||
<span>System status</span>
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1">
|
||||
<Link href="/docs" className="transition-colors hover:text-[--color-fg]">
|
||||
Docs
|
||||
|
||||
149
apps/web/app/(marketing)/pricing/page.tsx
Normal file
149
apps/web/app/(marketing)/pricing/page.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata = { title: 'Pricing — BuildMyMCPServer' };
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
name: 'Hobby',
|
||||
price: '€0',
|
||||
tag: 'Forever free',
|
||||
description: 'For trying things out and shipping single-user tools.',
|
||||
features: [
|
||||
'1 MCP server',
|
||||
'100,000 tool calls / month',
|
||||
'BuildMyMCP subdomain',
|
||||
'Community support',
|
||||
],
|
||||
cta: 'Start free',
|
||||
href: '/login',
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '€49',
|
||||
tag: '/ month',
|
||||
description: 'For solo founders and small teams shipping production tools.',
|
||||
features: [
|
||||
'5 MCP servers',
|
||||
'1M tool calls / month',
|
||||
'Custom domain',
|
||||
'Priority build queue',
|
||||
'Email support, 1 business-day SLA',
|
||||
],
|
||||
cta: 'Start Pro',
|
||||
href: '/login',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
price: '€149',
|
||||
tag: '/ month',
|
||||
description: 'For teams with RBAC, audit, and 99.9% SLA needs.',
|
||||
features: [
|
||||
'25 MCP servers',
|
||||
'10M tool calls / month',
|
||||
'RBAC + extended audit log',
|
||||
'99.9% uptime SLA',
|
||||
'Shared Slack channel support',
|
||||
],
|
||||
cta: 'Start Team',
|
||||
href: '/login',
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '€499+',
|
||||
tag: '/ month',
|
||||
description: 'For organizations bringing their own cloud, SSO and dedicated infra.',
|
||||
features: [
|
||||
'Unlimited servers',
|
||||
'BYOC (AWS, GCP, Azure, Hetzner)',
|
||||
'SSO / SAML',
|
||||
'Dedicated cluster',
|
||||
'Customer success manager',
|
||||
],
|
||||
cta: 'Contact sales',
|
||||
href: 'mailto:sales@buildmymcpserver.com',
|
||||
},
|
||||
];
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: 'What counts as a tool call?',
|
||||
a: 'Every successful invocation of a tool exposed by your MCP server. Failed calls do not count.',
|
||||
},
|
||||
{
|
||||
q: 'What happens if I exceed my quota?',
|
||||
a: 'Hobby: 429 with a hint to upgrade. Pro/Team: overage at €0.02 per 1000 calls, billed the following month. Soft caps configurable.',
|
||||
},
|
||||
{
|
||||
q: 'Annual billing?',
|
||||
a: 'Yes — save 20% on Pro and Team paying annually. Enterprise is annual by default.',
|
||||
},
|
||||
{
|
||||
q: 'Plan changes?',
|
||||
a: 'Upgrade any time, pro-rated. Downgrade at end of billing period.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-16">
|
||||
<header className="mb-12 max-w-2xl">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
Pricing
|
||||
</div>
|
||||
<h1 className="mt-2 text-[36px] font-semibold leading-tight tracking-tight">
|
||||
Pay for tool calls, not boilerplate.
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] leading-relaxed text-[--color-fg-muted]">
|
||||
Build infinite servers, pay for the traffic they actually serve. Cancel any time, export
|
||||
everything, no lock-in.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{TIERS.map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
className={`panel flex h-full flex-col p-5 ${t.highlight ? 'border-[--color-accent]/40' : ''}`}
|
||||
>
|
||||
<div className="text-[12px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
{t.name}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className="text-[28px] font-semibold tracking-tight">{t.price}</span>
|
||||
<span className="text-[12px] text-[--color-fg-subtle]">{t.tag}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[12px] leading-relaxed text-[--color-fg-muted]">{t.description}</p>
|
||||
<ul className="mt-4 space-y-1.5 text-[12.5px] text-[--color-fg-muted]">
|
||||
{t.features.map((f) => (
|
||||
<li key={f}>— {f}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={t.href}
|
||||
className={`mt-6 inline-flex h-8 items-center justify-center rounded-md px-3 text-[12.5px] font-medium transition-colors duration-200 ${
|
||||
t.highlight
|
||||
? 'bg-[--color-accent] text-white hover:bg-[#5557e8]'
|
||||
: 'border border-[--color-border] bg-[--color-bg-elevated] text-[--color-fg] hover:bg-[--color-bg-subtle]'
|
||||
}`}
|
||||
>
|
||||
{t.cta}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-20">
|
||||
<h2 className="text-[22px] font-semibold tracking-tight">Pricing FAQ</h2>
|
||||
<div className="mt-6 grid gap-x-12 gap-y-6 md:grid-cols-2">
|
||||
{FAQ.map((f) => (
|
||||
<div key={f.q}>
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">{f.q}</h3>
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
apps/web/app/(marketing)/privacy/page.tsx
Normal file
103
apps/web/app/(marketing)/privacy/page.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
export const metadata = { title: 'Privacy — BuildMyMCPServer' };
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
h: 'What we collect',
|
||||
p: [
|
||||
'Account: email, organization name, the IP address of your sign-in.',
|
||||
'Workspace: the prompts you send to the generator, the generated server specifications, build logs, server names and slugs you choose.',
|
||||
'Tool-call metrics: counts, latencies, status codes — never the request or response payloads. We only know that a tool was called, how long it took, and whether it succeeded.',
|
||||
'Billing: Stripe customer id + subscription metadata for paid plans. Card details never touch our servers.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: 'What we do NOT collect',
|
||||
p: [
|
||||
'Plaintext credentials. Every secret you save is AES-256-GCM encrypted before it hits storage. We have no ability to read them; only your container at runtime can.',
|
||||
'Tool-call payloads. The arguments your AI client sends and the responses our server returns are not stored or logged.',
|
||||
'Browsing data, cross-site tracking, advertising identifiers.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: 'Where it lives',
|
||||
p: [
|
||||
'EU region by default (Hetzner Falkenstein, Germany).',
|
||||
'Postgres + Redis + container hosts are all in the same region.',
|
||||
'Backups encrypted at rest in Backblaze B2 EU.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: 'Subprocessors',
|
||||
p: [
|
||||
'Anthropic (generation) — only the prompt text you send. Anthropic\'s data-retention policy applies.',
|
||||
'Hetzner (compute).',
|
||||
'Backblaze (encrypted backups).',
|
||||
'Stripe (billing).',
|
||||
'Cloudflare (DNS + DDoS).',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: 'Retention',
|
||||
p: [
|
||||
'Active account: all workspace data kept while subscribed.',
|
||||
'Cancelled account: 30-day grace period, then full deletion (servers, builds, logs, audit).',
|
||||
'Audit log: 1 year on Team+, 30 days on Pro/Hobby.',
|
||||
'Tool-call metrics: 30 days, then aggregated to daily counts.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: 'Your rights (GDPR)',
|
||||
p: [
|
||||
'Access — export everything in JSON via the settings page (Sprint 4) or by email.',
|
||||
'Deletion — delete your organization in settings; everything goes within 30 days.',
|
||||
'Rectification — change name and email yourself; everything else is editable on request.',
|
||||
'Portability — the generated TypeScript source of every server is yours, downloadable.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Privacy() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<header className="mb-12">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
Privacy policy
|
||||
</div>
|
||||
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">Privacy</h1>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||
Plain language. What we collect, where it lives, how to get it deleted. Last updated
|
||||
2026-05-19.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-9">
|
||||
{SECTIONS.map((s) => (
|
||||
<section key={s.h}>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">{s.h}</h2>
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{s.p.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="relative pl-4 text-[13.5px] leading-relaxed text-[--color-fg-muted] before:absolute before:left-0 before:top-2 before:size-1 before:rounded-full before:bg-[--color-fg-subtle]"
|
||||
>
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">Contact</h2>
|
||||
<p className="mt-3 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
Data controller: BuildMyMCPServer. Email{' '}
|
||||
<a className="text-[--color-accent] underline" href="mailto:privacy@buildmymcpserver.com">
|
||||
privacy@buildmymcpserver.com
|
||||
</a>{' '}
|
||||
for any of the above.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
apps/web/app/(marketing)/security/page.tsx
Normal file
97
apps/web/app/(marketing)/security/page.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import Link from 'next/link';
|
||||
import { CodeBlock } from '@/components/code-block';
|
||||
|
||||
export const metadata = { title: 'Security — BuildMyMCPServer' };
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
title: 'Per-server isolation',
|
||||
body: 'Every customer MCP server runs in its own Docker container. No shared process, no shared filesystem, no shared memory. One server compromised does not affect any other.',
|
||||
},
|
||||
{
|
||||
title: 'Encrypted secrets',
|
||||
body: 'API keys and credentials are AES-256-GCM encrypted at rest in Postgres with a 32-byte key sourced from env. Decryption happens only at the moment of container ENV injection. Plaintext is never logged.',
|
||||
},
|
||||
{
|
||||
title: 'OAuth 2.1, no API keys for end users',
|
||||
body: 'Every generated server is an OAuth 2.1 Resource Server. PKCE, Dynamic Client Registration (RFC 7591), Resource Indicators (RFC 8707), RS256-signed JWTs. Short-lived, audience-bound, replay-resistant.',
|
||||
},
|
||||
{
|
||||
title: 'No token passthrough',
|
||||
body: 'When a tool calls a downstream API, it uses its own server-side credentials — not the user\'s OAuth token. Tokens never leak across trust boundaries. This is mandated by the MCP authorization spec.',
|
||||
},
|
||||
{
|
||||
title: 'Static security checks',
|
||||
body: 'Every LLM-generated tool body is scanned for banned patterns (eval, new Function, child_process) before Docker build. Prompt-injection markers like "ignore previous instructions" also trip the check. Build fails fast, no risky code ships.',
|
||||
},
|
||||
{
|
||||
title: 'Container hardening',
|
||||
body: 'Production containers run with --read-only, --cap-drop=ALL, --security-opt=no-new-privileges, CPU and memory limits. Network egress can be restricted to whitelisted domains per server.',
|
||||
},
|
||||
{
|
||||
title: 'Audit log',
|
||||
body: 'Every privileged action — login, logout, server create/iterate/delete — is recorded with IP, timestamp, user, and metadata. Available via /audit for Team-and-above orgs.',
|
||||
},
|
||||
{
|
||||
title: 'Rate limiting',
|
||||
body: 'Default 100 requests/min/IP per tool, enforced at the Traefik layer before traffic ever reaches your container.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Security() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<header className="mb-12">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
Security posture
|
||||
</div>
|
||||
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">
|
||||
Built like infrastructure.
|
||||
</h1>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||
We host code generated by an LLM, on behalf of customers, that exposes their internal
|
||||
APIs to AI clients. The threat model is real. Here is what we do about it.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
{PILLARS.map((p) => (
|
||||
<section key={p.title} className="panel p-5">
|
||||
<h2 className="text-[14px] font-semibold tracking-tight">{p.title}</h2>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted]">{p.body}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-[18px] font-semibold tracking-tight">Disclosure</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
Found a vulnerability? Email{' '}
|
||||
<a className="text-[--color-accent] underline" href="mailto:security@buildmymcpserver.com">
|
||||
security@buildmymcpserver.com
|
||||
</a>{' '}
|
||||
with a clear reproduction. We respond within 48h. We do not run a paid bounty yet, but we
|
||||
will credit you publicly in the changelog (or anonymously if you prefer).
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<CodeBlock
|
||||
label="pgp"
|
||||
code={`Key fingerprint published at buildmymcpserver.com/.well-known/security.txt`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-[18px] font-semibold tracking-tight">Compliance roadmap</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
SOC 2 Type I is targeted for Q4 2026. The GDPR posture is described in our{' '}
|
||||
<Link href="/privacy" className="text-[--color-accent] underline">
|
||||
privacy policy
|
||||
</Link>
|
||||
. If you need a DPA or other contracts ahead of SOC 2, reach out — Team and Enterprise
|
||||
customers get one on request.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/web/app/(marketing)/status/page.tsx
Normal file
139
apps/web/app/(marketing)/status/page.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Probe {
|
||||
name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PROBES: Probe[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
url: '/login',
|
||||
description: 'Web frontend (Next.js)',
|
||||
},
|
||||
{
|
||||
name: 'Control plane API',
|
||||
url: '/api/health',
|
||||
description: 'Fastify control plane via /api proxy',
|
||||
},
|
||||
];
|
||||
|
||||
type State = 'pending' | 'ok' | 'down';
|
||||
|
||||
interface Result {
|
||||
state: State;
|
||||
latencyMs: number | null;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export default function Status() {
|
||||
const [results, setResults] = useState<Record<string, Result>>(() =>
|
||||
Object.fromEntries(PROBES.map((p) => [p.name, { state: 'pending', latencyMs: null, detail: null }])),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function probe() {
|
||||
const next: Record<string, Result> = {};
|
||||
for (const p of PROBES) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const res = await fetch(p.url, { cache: 'no-store' });
|
||||
const latency = Math.round(performance.now() - start);
|
||||
next[p.name] = {
|
||||
state: res.ok ? 'ok' : 'down',
|
||||
latencyMs: latency,
|
||||
detail: res.ok ? `HTTP ${res.status}` : `HTTP ${res.status}`,
|
||||
};
|
||||
} catch (e) {
|
||||
next[p.name] = {
|
||||
state: 'down',
|
||||
latencyMs: null,
|
||||
detail: (e as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!cancelled) setResults(next);
|
||||
}
|
||||
probe();
|
||||
const interval = setInterval(probe, 10_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const anyDown = Object.values(results).some((r) => r.state === 'down');
|
||||
const allPending = Object.values(results).every((r) => r.state === 'pending');
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<header className="mb-10">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
System status
|
||||
</div>
|
||||
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">
|
||||
{allPending ? 'Checking…' : anyDown ? 'Partial outage' : 'All systems operational'}
|
||||
</h1>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||
Live health probes against each service. Refreshes every 10 seconds.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="panel divide-y divide-[--color-border]">
|
||||
{PROBES.map((p) => {
|
||||
const r = results[p.name]!;
|
||||
return (
|
||||
<div key={p.name} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="text-[13.5px] font-medium">{p.name}</div>
|
||||
<div className="text-[12px] text-[--color-fg-subtle]">{p.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[12px]">
|
||||
{r.latencyMs !== null && (
|
||||
<span className="mono text-[--color-fg-subtle]">{r.latencyMs}ms</span>
|
||||
)}
|
||||
<Dot state={r.state} />
|
||||
<span className={cn(stateClass(r.state), 'font-medium tabular-nums')}>
|
||||
{stateLabel(r.state)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
||||
Production status board: BetterStack-hosted at status.buildmymcpserver.com (set up
|
||||
post-launch).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ state }: { state: State }) {
|
||||
const color =
|
||||
state === 'ok' ? 'bg-emerald-400' : state === 'down' ? 'bg-red-400' : 'bg-zinc-400';
|
||||
return (
|
||||
<span
|
||||
className={cn('size-2 rounded-full', color)}
|
||||
style={state === 'ok' ? { animation: 'pulse-dot 1.6s ease-in-out infinite' } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function stateLabel(state: State): string {
|
||||
if (state === 'ok') return 'Operational';
|
||||
if (state === 'down') return 'Down';
|
||||
return 'Checking';
|
||||
}
|
||||
|
||||
function stateClass(state: State): string {
|
||||
if (state === 'ok') return 'text-emerald-300';
|
||||
if (state === 'down') return 'text-red-300';
|
||||
return 'text-[--color-fg-subtle]';
|
||||
}
|
||||
77
apps/web/app/(marketing)/terms/page.tsx
Normal file
77
apps/web/app/(marketing)/terms/page.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
export const metadata = { title: 'Terms — BuildMyMCPServer' };
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
h: '1. The service',
|
||||
p: 'BuildMyMCPServer ("we", "us") lets you turn natural-language prompts into hosted Model Context Protocol (MCP) servers. You ("you") are the customer.',
|
||||
},
|
||||
{
|
||||
h: '2. Acceptable use',
|
||||
p: 'You will not use the service to build servers that violate applicable law, infringe third-party rights, or facilitate abuse. You retain responsibility for what your servers do with the credentials you provide to them.',
|
||||
},
|
||||
{
|
||||
h: '3. Generated code',
|
||||
p: 'Code we generate on your behalf is yours. You may export, modify, or self-host it. You also accept that LLM-generated code may contain bugs, and you are responsible for reviewing it before relying on it for critical workloads.',
|
||||
},
|
||||
{
|
||||
h: '4. Your credentials',
|
||||
p: 'Any API keys or secrets you upload are encrypted at rest and used only to run your servers. You warrant you have the right to use the credentials you upload.',
|
||||
},
|
||||
{
|
||||
h: '5. Service availability',
|
||||
p: 'Free and Pro plans are best-effort. Team plan carries a 99.9% monthly uptime SLA with service credits as the sole remedy. Enterprise SLAs are negotiated separately.',
|
||||
},
|
||||
{
|
||||
h: '6. Billing',
|
||||
p: 'Paid plans bill monthly via Stripe in advance. Metered overage bills the month following accrual. You can cancel any time; refunds are pro-rated only for service outages we acknowledge.',
|
||||
},
|
||||
{
|
||||
h: '7. Termination',
|
||||
p: 'Either party may terminate for material breach with 30 days written notice. We may suspend without notice for security incidents, payment failures over 14 days, or violations of section 2.',
|
||||
},
|
||||
{
|
||||
h: '8. Liability',
|
||||
p: 'To the maximum extent permitted by law, our aggregate liability is capped at the fees you paid us in the 12 months preceding the event. Neither party is liable for indirect or consequential damages.',
|
||||
},
|
||||
{
|
||||
h: '9. Changes',
|
||||
p: 'We may update these terms; we will notify you 30 days before a material change takes effect. Your continued use after that date constitutes acceptance.',
|
||||
},
|
||||
{
|
||||
h: '10. Governing law',
|
||||
p: 'These terms are governed by the laws of Germany. Exclusive jurisdiction is Berlin.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Terms() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<header className="mb-12">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
Terms of service
|
||||
</div>
|
||||
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">Terms</h1>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||
Boring on purpose. Last updated 2026-05-19.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-7">
|
||||
{SECTIONS.map((s) => (
|
||||
<section key={s.h}>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">{s.h}</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">{s.p}</p>
|
||||
</section>
|
||||
))}
|
||||
<section>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Questions</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
<a className="text-[--color-accent] underline" href="mailto:legal@buildmymcpserver.com">
|
||||
legal@buildmymcpserver.com
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/web/app/docs/api-reference/page.tsx
Normal file
90
apps/web/app/docs/api-reference/page.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import {
|
||||
DocsTitle,
|
||||
DocsLead,
|
||||
DocsH2,
|
||||
DocsP,
|
||||
DocsCode,
|
||||
Mono,
|
||||
} from '@/components/docs-page';
|
||||
|
||||
export const metadata = { title: 'API reference — BuildMyMCPServer docs' };
|
||||
|
||||
export default function ApiReference() {
|
||||
return (
|
||||
<>
|
||||
<DocsTitle kicker="Reference">API reference</DocsTitle>
|
||||
<DocsLead>
|
||||
Every endpoint on the control plane. Authenticated routes use the session cookie set by
|
||||
the magic-link verify call.
|
||||
</DocsLead>
|
||||
|
||||
<DocsH2 id="auth">Auth</DocsH2>
|
||||
<DocsP>
|
||||
<Mono>POST /v1/auth/magic-link</Mono> — body <Mono>{`{"email":"…"}`}</Mono> — emails (or
|
||||
prints in dev) a one-time link.
|
||||
</DocsP>
|
||||
<DocsP>
|
||||
<Mono>POST /v1/auth/verify</Mono> — body <Mono>{`{"token":"…"}`}</Mono> — exchanges the
|
||||
token for a session cookie.
|
||||
</DocsP>
|
||||
<DocsP><Mono>GET /v1/auth/me</Mono> — returns the current session user + org.</DocsP>
|
||||
<DocsP><Mono>POST /v1/auth/logout</Mono> — destroys the session.</DocsP>
|
||||
|
||||
<DocsH2 id="servers">Servers</DocsH2>
|
||||
<DocsP><Mono>GET /v1/servers</Mono> — list servers in the current org.</DocsP>
|
||||
<DocsP>
|
||||
<Mono>POST /v1/servers/preview</Mono> — body <Mono>{`{"prompt":"…"}`}</Mono> — runs Claude
|
||||
synchronously, validates the spec, caches it, returns <Mono>{`{ previewId, source, spec }`}</Mono>.
|
||||
</DocsP>
|
||||
<DocsP>
|
||||
<Mono>POST /v1/servers</Mono> — body <Mono>{`{name, slug, prompt, secrets, previewId?}`}</Mono>
|
||||
— creates the server, queues the build, returns the server + build records.
|
||||
</DocsP>
|
||||
<DocsP>
|
||||
<Mono>GET /v1/servers/:id</Mono> — server detail with the latest 10 build records.
|
||||
</DocsP>
|
||||
<DocsP>
|
||||
<Mono>POST /v1/servers/:id/iterate</Mono> — body <Mono>{`{prompt, secrets}`}</Mono> —
|
||||
queues a new version build.
|
||||
</DocsP>
|
||||
<DocsP><Mono>DELETE /v1/servers/:id</Mono> — removes the server and tears down the container.</DocsP>
|
||||
|
||||
<DocsH2 id="builds">Builds</DocsH2>
|
||||
<DocsP>
|
||||
<Mono>GET /v1/builds/:id</Mono> — build record + persisted logs.
|
||||
</DocsP>
|
||||
<DocsP>
|
||||
<Mono>WS /v1/builds/:id/stream</Mono> — live event stream of build events:
|
||||
<Mono>status</Mono>, <Mono>log</Mono>, <Mono>done</Mono>, <Mono>error</Mono>.
|
||||
</DocsP>
|
||||
|
||||
<DocsH2 id="oauth">OAuth (clients of generated servers, not dashboard)</DocsH2>
|
||||
<DocsP>
|
||||
<Mono>GET /oauth/.well-known/oauth-authorization-server</Mono> — RFC 8414 metadata.
|
||||
</DocsP>
|
||||
<DocsP><Mono>GET /oauth/jwks</Mono> — RS256 public key for verifying access tokens.</DocsP>
|
||||
<DocsP><Mono>POST /oauth/register</Mono> — RFC 7591 dynamic client registration.</DocsP>
|
||||
<DocsP><Mono>GET /oauth/authorize</Mono> — authorization code endpoint, requires session.</DocsP>
|
||||
<DocsP><Mono>POST /oauth/token</Mono> — code exchange + refresh.</DocsP>
|
||||
|
||||
<DocsH2 id="examples">Curl example</DocsH2>
|
||||
<DocsCode
|
||||
label="full lifecycle"
|
||||
code={`# 1. magic link
|
||||
curl -X POST http://localhost:4000/v1/auth/magic-link -d '{"email":"me@x.dev"}'
|
||||
# (grab token from API console)
|
||||
|
||||
# 2. verify -> session
|
||||
curl -c cookies.txt -X POST http://localhost:4000/v1/auth/verify -d '{"token":"…"}'
|
||||
|
||||
# 3. preview
|
||||
curl -b cookies.txt -X POST http://localhost:4000/v1/servers/preview \\
|
||||
-d '{"prompt":"echo server with one tool: echo(message)"}'
|
||||
|
||||
# 4. build
|
||||
curl -b cookies.txt -X POST http://localhost:4000/v1/servers \\
|
||||
-d '{"name":"Echo","slug":"echo","prompt":"…","secrets":{},"previewId":"…"}'`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
apps/web/app/docs/authoring/page.tsx
Normal file
94
apps/web/app/docs/authoring/page.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
DocsTitle,
|
||||
DocsLead,
|
||||
DocsH2,
|
||||
DocsP,
|
||||
DocsList,
|
||||
DocsLi,
|
||||
DocsCode,
|
||||
Mono,
|
||||
} from '@/components/docs-page';
|
||||
|
||||
export const metadata = { title: 'Authoring tools — BuildMyMCPServer docs' };
|
||||
|
||||
export default function Authoring() {
|
||||
return (
|
||||
<>
|
||||
<DocsTitle kicker="Build">Authoring tools</DocsTitle>
|
||||
<DocsLead>
|
||||
What you write in the prompt is what Claude turns into TypeScript. Better prompts mean
|
||||
better tools. These patterns cover 80% of the common asks.
|
||||
</DocsLead>
|
||||
|
||||
<DocsH2 id="anatomy">Anatomy of a tool</DocsH2>
|
||||
<DocsP>Each generated tool ends up looking like this:</DocsP>
|
||||
<DocsCode
|
||||
label="generated TypeScript"
|
||||
code={`server.registerTool(
|
||||
'search_pages',
|
||||
{
|
||||
title: 'search_pages',
|
||||
description: 'Search Notion pages matching a query.',
|
||||
inputSchema: {
|
||||
query: z.string().describe('search terms'),
|
||||
},
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const res = await fetch('https://api.notion.com/v1/search', {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
headers: {
|
||||
'Authorization': \`Bearer \${process.env.NOTION_API_KEY}\`,
|
||||
'Notion-Version': '2022-06-28',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query: args.query }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { content: [{ type: 'text', text: JSON.stringify(data.results) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: 'text', text: 'Error: ' + msg }], isError: true };
|
||||
}
|
||||
},
|
||||
);`}
|
||||
/>
|
||||
|
||||
<DocsH2 id="rules">Rules the generator enforces</DocsH2>
|
||||
<DocsList>
|
||||
<DocsLi>No <Mono>eval</Mono>, no <Mono>new Function</Mono>, no <Mono>child_process</Mono>. The static check rejects the build.</DocsLi>
|
||||
<DocsLi>No <Mono>import</Mono> statements in tool bodies — the runtime injects <Mono>fetch</Mono>, <Mono>pg</Mono>, <Mono>z</Mono>.</DocsLi>
|
||||
<DocsLi>Secrets live in <Mono>process.env</Mono>. Never embedded literally.</DocsLi>
|
||||
<DocsLi>External HTTP calls must use <Mono>AbortSignal.timeout</Mono>. Default 10s.</DocsLi>
|
||||
<DocsLi>Database access via <Mono>pg</Mono> with parameterized queries only.</DocsLi>
|
||||
<DocsLi>Errors return as MCP error-content, not thrown exceptions.</DocsLi>
|
||||
</DocsList>
|
||||
|
||||
<DocsH2 id="patterns">Prompt patterns that work</DocsH2>
|
||||
<DocsP>
|
||||
<strong>Be explicit about tool names.</strong> "Tool: <Mono>search_pages(query)</Mono>"
|
||||
beats "give me a search tool".
|
||||
</DocsP>
|
||||
<DocsP>
|
||||
<strong>Name the credentials.</strong> "Auth: <Mono>NOTION_API_KEY</Mono>" tells the
|
||||
generator what to put in <Mono>requiredSecrets</Mono>. Saves an iteration.
|
||||
</DocsP>
|
||||
<DocsP>
|
||||
<strong>Say if a tool is destructive.</strong> "Tool: <Mono>delete_page(page_id)</Mono> —
|
||||
destructive, permanently removes the page" surfaces the warning to the AI client.
|
||||
</DocsP>
|
||||
<DocsP>
|
||||
<strong>One server per integration, not per tool.</strong> A Notion server with five
|
||||
tools is cleaner than five Notion servers each with one tool.
|
||||
</DocsP>
|
||||
|
||||
<DocsH2 id="iteration">Iterate on a live server</DocsH2>
|
||||
<DocsP>
|
||||
Open the server detail page, click the <Mono>Iterate</Mono> tab, describe what you want
|
||||
to add. A new build version is queued, rolling-deployed, the old version stays live until
|
||||
the new one is healthy.
|
||||
</DocsP>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
apps/web/app/docs/concepts/page.tsx
Normal file
86
apps/web/app/docs/concepts/page.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
DocsTitle,
|
||||
DocsLead,
|
||||
DocsH2,
|
||||
DocsP,
|
||||
DocsList,
|
||||
DocsLi,
|
||||
DocsCode,
|
||||
Mono,
|
||||
} from '@/components/docs-page';
|
||||
|
||||
export const metadata = { title: 'MCP concepts — BuildMyMCPServer docs' };
|
||||
|
||||
export default function Concepts() {
|
||||
return (
|
||||
<>
|
||||
<DocsTitle kicker="Get started">MCP concepts</DocsTitle>
|
||||
<DocsLead>
|
||||
Model Context Protocol is an open standard from Anthropic for connecting AI assistants to
|
||||
external tools, data, and APIs. Three primitives, one transport.
|
||||
</DocsLead>
|
||||
|
||||
<DocsH2 id="primitives">The three primitives</DocsH2>
|
||||
<DocsList>
|
||||
<DocsLi>
|
||||
<strong className="text-[--color-fg]">Tools</strong> — functions the AI can invoke.
|
||||
Each has a name, a description, an input schema (JSON Schema or Zod), and a server-side
|
||||
implementation. The AI decides when to call them based on the description.
|
||||
</DocsLi>
|
||||
<DocsLi>
|
||||
<strong className="text-[--color-fg]">Resources</strong> — read-only data the AI can
|
||||
fetch. URI-addressed. Think files, documents, database records.
|
||||
</DocsLi>
|
||||
<DocsLi>
|
||||
<strong className="text-[--color-fg]">Prompts</strong> — parameterized prompt templates
|
||||
the server exposes to the client. Used for orchestration patterns the server author
|
||||
wants to encourage.
|
||||
</DocsLi>
|
||||
</DocsList>
|
||||
|
||||
<DocsH2 id="transport">Transport: Streamable HTTP</DocsH2>
|
||||
<DocsP>
|
||||
Every generated server speaks <Mono>Streamable HTTP</Mono> (MCP spec 2025-11-25). The
|
||||
previous SSE transport was deprecated in June 2025 and is not supported here. One HTTP
|
||||
endpoint at <Mono>/mcp</Mono>, optionally negotiating a long-lived stream via
|
||||
<Mono>text/event-stream</Mono> when the server wants to push updates.
|
||||
</DocsP>
|
||||
<DocsCode
|
||||
label="single request"
|
||||
code={`POST /mcp HTTP/1.1
|
||||
Authorization: Bearer <jwt>
|
||||
Content-Type: application/json
|
||||
Accept: application/json, text/event-stream
|
||||
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<DocsH2 id="session">Session lifecycle</DocsH2>
|
||||
<DocsList>
|
||||
<DocsLi>
|
||||
<Mono>initialize</Mono> — client sends protocol version + capabilities, server returns
|
||||
its info and assigns a session id via the <Mono>mcp-session-id</Mono> header.
|
||||
</DocsLi>
|
||||
<DocsLi>
|
||||
<Mono>notifications/initialized</Mono> — client confirms readiness. Server is now free
|
||||
to push notifications.
|
||||
</DocsLi>
|
||||
<DocsLi>
|
||||
<Mono>tools/list</Mono>, <Mono>tools/call</Mono>, <Mono>resources/list</Mono>,
|
||||
<Mono>prompts/list</Mono> — the work.
|
||||
</DocsLi>
|
||||
</DocsList>
|
||||
|
||||
<DocsH2 id="why-mcp">Why MCP and not just REST</DocsH2>
|
||||
<DocsP>
|
||||
REST APIs need bespoke OpenAPI integration per client. MCP standardizes the discovery,
|
||||
invocation, auth and streaming so any spec-compliant client picks up any spec-compliant
|
||||
server with zero glue code. That's the entire point.
|
||||
</DocsP>
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
apps/web/app/docs/faq/page.tsx
Normal file
71
apps/web/app/docs/faq/page.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { DocsTitle, DocsLead, DocsH2, DocsP, Mono } from '@/components/docs-page';
|
||||
|
||||
export const metadata = { title: 'FAQ — BuildMyMCPServer docs' };
|
||||
|
||||
const ITEMS: { q: string; a: React.ReactNode }[] = [
|
||||
{
|
||||
q: 'How does the LLM-generated code stay safe?',
|
||||
a: 'Three layers: strict Zod validation of the JSON spec, regex scan for banned tokens (eval, child_process, prompt-injection markers), and a static check on the rendered TypeScript before Docker build. If any layer trips, the build fails with a clear error and nothing is deployed.',
|
||||
},
|
||||
{
|
||||
q: 'What happens if Claude hallucinates a broken tool?',
|
||||
a: 'The build fails at the static-check or Docker-build stage. The user sees the exact error in the live log and can refine the prompt and rebuild. No invalid server ever serves traffic.',
|
||||
},
|
||||
{
|
||||
q: 'Do my secrets ever leave my environment?',
|
||||
a: 'No. Secrets are AES-256-GCM encrypted at rest in your Postgres, decrypted only when injecting into your container at boot. They never appear in audit logs, build logs, or the prompt sent to Claude.',
|
||||
},
|
||||
{
|
||||
q: 'Why MCP and not OpenAPI?',
|
||||
a: 'MCP standardizes the discovery, invocation, auth, and streaming surface in a way OpenAPI never did. The point is that any spec-compliant client picks up any spec-compliant server with zero per-API integration work. OpenAPI requires custom glue for every client.',
|
||||
},
|
||||
{
|
||||
q: 'Can I use my own Claude API key?',
|
||||
a: 'Yes — set ANTHROPIC_API_KEY in .env. On self-hosted control planes you can also wire a separate per-org key (Sprint 4).',
|
||||
},
|
||||
{
|
||||
q: 'What if I don\'t set ANTHROPIC_API_KEY?',
|
||||
a: <>The generator falls back to a deterministic mock spec (two tools: <Mono>echo</Mono>, <Mono>now</Mono>) so you can verify the full pipeline without burning credits.</>,
|
||||
},
|
||||
{
|
||||
q: 'Cold-start latency?',
|
||||
a: 'Generated containers stay warm. After first boot, /mcp responds in sub-50ms in-region.',
|
||||
},
|
||||
{
|
||||
q: 'Rate limits?',
|
||||
a: 'Default 100 requests/min/IP per tool. Configurable per server. Quota enforced before hitting your container.',
|
||||
},
|
||||
{
|
||||
q: 'How is OAuth different from API keys?',
|
||||
a: 'OAuth 2.1 with PKCE + Dynamic Client Registration + Resource Indicators means the AI client gets a short-lived, audience-bound token. Compromised tokens expire and can\'t be replayed against other servers. API keys are static and replayable forever.',
|
||||
},
|
||||
{
|
||||
q: 'Can the AI client itself get phished into using a malicious server?',
|
||||
a: 'The MCP spec mandates user consent on initial server addition. Beyond that, each server\'s scope is opaque to other servers — there\'s no cross-server token leakage because of audience binding.',
|
||||
},
|
||||
{
|
||||
q: 'How do I export my server\'s code?',
|
||||
a: 'Every build record stores the rendered TypeScript in Postgres. The /servers/:id detail page exposes it for download (Sprint 4 UI; available now via API).',
|
||||
},
|
||||
{
|
||||
q: 'What about ChatGPT specifically?',
|
||||
a: 'ChatGPT supports MCP via Custom Connectors. The wizard\'s install tab gives you the URL + OAuth setting; the handshake runs automatically on first call.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Faq() {
|
||||
return (
|
||||
<>
|
||||
<DocsTitle kicker="Reference">FAQ</DocsTitle>
|
||||
<DocsLead>Common questions, direct answers.</DocsLead>
|
||||
<div className="space-y-7">
|
||||
{ITEMS.map((item) => (
|
||||
<div key={item.q}>
|
||||
<DocsH2>{item.q}</DocsH2>
|
||||
<DocsP>{item.a}</DocsP>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
apps/web/app/docs/layout.tsx
Normal file
85
apps/web/app/docs/layout.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import Link from 'next/link';
|
||||
import { Logo } from '@/components/logo';
|
||||
|
||||
const SECTIONS: { heading: string; items: { href: string; label: string }[] }[] = [
|
||||
{
|
||||
heading: 'Get started',
|
||||
items: [
|
||||
{ href: '/docs', label: 'Quickstart' },
|
||||
{ href: '/docs/concepts', label: 'MCP concepts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Auth',
|
||||
items: [{ href: '/docs/oauth', label: 'OAuth 2.1 flow' }],
|
||||
},
|
||||
{
|
||||
heading: 'Build',
|
||||
items: [
|
||||
{ href: '/docs/authoring', label: 'Authoring tools' },
|
||||
{ href: '/docs/self-hosting', label: 'Self-hosting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Reference',
|
||||
items: [
|
||||
{ href: '/docs/api-reference', label: 'API reference' },
|
||||
{ href: '/docs/faq', label: 'FAQ' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/85 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Logo />
|
||||
<span className="text-[12.5px] text-[--color-fg-subtle]">/ docs</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
||||
>
|
||||
Start building
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-1 gap-12 px-6 py-10">
|
||||
<aside className="w-[240px] shrink-0">
|
||||
<nav className="sticky top-20 space-y-5">
|
||||
{SECTIONS.map((section) => (
|
||||
<div key={section.heading}>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.14em] text-[--color-fg-subtle]">
|
||||
{section.heading}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="block rounded-sm px-1 py-1 text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<article className="prose prose-invert max-w-2xl flex-1">{children}</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
apps/web/app/docs/oauth/page.tsx
Normal file
125
apps/web/app/docs/oauth/page.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import {
|
||||
DocsTitle,
|
||||
DocsLead,
|
||||
DocsH2,
|
||||
DocsP,
|
||||
DocsList,
|
||||
DocsLi,
|
||||
DocsCode,
|
||||
Mono,
|
||||
} from '@/components/docs-page';
|
||||
|
||||
export const metadata = { title: 'OAuth 2.1 flow — BuildMyMCPServer docs' };
|
||||
|
||||
export default function OAuthDocs() {
|
||||
return (
|
||||
<>
|
||||
<DocsTitle kicker="Auth">OAuth 2.1 flow</DocsTitle>
|
||||
<DocsLead>
|
||||
Every generated server is an OAuth 2.1 Resource Server. The control plane is the
|
||||
Authorization Server. Dynamic Client Registration, PKCE, and Resource Indicators per the
|
||||
2025 MCP authorization spec.
|
||||
</DocsLead>
|
||||
|
||||
<DocsH2 id="rfcs">Standards we follow</DocsH2>
|
||||
<DocsList>
|
||||
<DocsLi>OAuth 2.1 draft (<Mono>draft-ietf-oauth-v2-1</Mono>) — no implicit, mandatory PKCE</DocsLi>
|
||||
<DocsLi>RFC 8414 — Authorization Server Metadata at <Mono>/.well-known/oauth-authorization-server</Mono></DocsLi>
|
||||
<DocsLi>RFC 9728 — Protected Resource Metadata at <Mono>/.well-known/oauth-protected-resource</Mono></DocsLi>
|
||||
<DocsLi>RFC 8707 — Resource Indicators (audience binding)</DocsLi>
|
||||
<DocsLi>RFC 7591 — Dynamic Client Registration</DocsLi>
|
||||
</DocsList>
|
||||
|
||||
<DocsH2 id="walkthrough">End-to-end walkthrough</DocsH2>
|
||||
<DocsP>
|
||||
First request from a fresh client to a fresh server is unauthenticated. The server
|
||||
replies with a <Mono>401</Mono> plus a <Mono>WWW-Authenticate</Mono> header pointing to
|
||||
its resource metadata.
|
||||
</DocsP>
|
||||
<DocsCode
|
||||
label="step 1 — 401 challenge"
|
||||
code={`$ curl -i http://localhost:4103/mcp -d '{}' -H 'content-type: application/json'
|
||||
|
||||
HTTP/1.1 401 Unauthorized
|
||||
www-authenticate: Bearer resource_metadata="http://localhost:4103/.well-known/oauth-protected-resource"
|
||||
content-type: application/json
|
||||
|
||||
{"error":"unauthorized"}`}
|
||||
/>
|
||||
|
||||
<DocsP>
|
||||
The client fetches that resource metadata, sees the authorization server, then fetches the
|
||||
AS metadata to discover registration, authorize, token and JWKS endpoints.
|
||||
</DocsP>
|
||||
<DocsCode
|
||||
label="step 2 — resource metadata"
|
||||
code={`$ curl http://localhost:4103/.well-known/oauth-protected-resource
|
||||
|
||||
{
|
||||
"resource": "http://localhost:4103",
|
||||
"authorization_servers": ["http://localhost:4000/oauth"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"scopes_supported": ["mcp:read"]
|
||||
}`}
|
||||
/>
|
||||
|
||||
<DocsP>
|
||||
The client registers itself dynamically. No human in the loop, no preconfigured client
|
||||
IDs. Each AI surface gets its own ephemeral identity.
|
||||
</DocsP>
|
||||
<DocsCode
|
||||
label="step 3 — dynamic registration"
|
||||
code={`POST /oauth/register HTTP/1.1
|
||||
{
|
||||
"client_name": "Claude Desktop",
|
||||
"redirect_uris": ["claude://oauth/callback"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"resource": "http://localhost:4103"
|
||||
}
|
||||
|
||||
201 Created
|
||||
{ "client_id": "bmm_8aee2fe0…", "redirect_uris": […] }`}
|
||||
/>
|
||||
|
||||
<DocsP>
|
||||
Authorization Code with PKCE. The user gives consent, the AS returns a one-time code,
|
||||
the client exchanges it for an RS256-signed JWT bound to the resource (audience).
|
||||
</DocsP>
|
||||
<DocsCode
|
||||
label="step 4 — token exchange"
|
||||
code={`POST /oauth/token HTTP/1.1
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "4uNk_SCU8…",
|
||||
"code_verifier": "riSU-w1DT…",
|
||||
"client_id": "bmm_8aee2fe0…",
|
||||
"redirect_uri": "claude://oauth/callback",
|
||||
"resource": "http://localhost:4103"
|
||||
}
|
||||
|
||||
200 OK
|
||||
{
|
||||
"access_token": "eyJ…",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "pQR…"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<DocsP>
|
||||
Subsequent <Mono>/mcp</Mono> calls carry the JWT. The runner verifies the signature
|
||||
against the AS's JWKS, checks the <Mono>iss</Mono>, the <Mono>aud</Mono>
|
||||
(RFC 8707 — must match the runner's own public URL), and the expiry. No token
|
||||
passthrough; the runner never forwards the client's token to a downstream API.
|
||||
</DocsP>
|
||||
|
||||
<DocsH2 id="security">Why this matters</DocsH2>
|
||||
<DocsP>
|
||||
Without audience binding, a token issued for one customer's MCP server could be
|
||||
replayed against another customer's server. RFC 8707 closes that. Without PKCE, a
|
||||
public OAuth client on a desktop is exposed to interception of the authorization code.
|
||||
OAuth 2.1 closes that.
|
||||
</DocsP>
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
apps/web/app/docs/page.tsx
Normal file
89
apps/web/app/docs/page.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import {
|
||||
DocsTitle,
|
||||
DocsLead,
|
||||
DocsH2,
|
||||
DocsH3,
|
||||
DocsP,
|
||||
DocsList,
|
||||
DocsLi,
|
||||
DocsCode,
|
||||
Mono,
|
||||
} from '@/components/docs-page';
|
||||
|
||||
export const metadata = { title: 'Quickstart — BuildMyMCPServer docs' };
|
||||
|
||||
export default function Quickstart() {
|
||||
return (
|
||||
<>
|
||||
<DocsTitle kicker="Get started">Quickstart</DocsTitle>
|
||||
<DocsLead>
|
||||
Describe the tool you want, paste in any credentials, watch the build stream, copy a snippet
|
||||
into your AI client. Five minutes from first prompt to a live OAuth-protected MCP server.
|
||||
</DocsLead>
|
||||
|
||||
<DocsH2 id="prereqs">Prerequisites</DocsH2>
|
||||
<DocsList>
|
||||
<DocsLi>An AI client that speaks MCP — Claude Desktop, Cursor, ChatGPT Custom Connectors, VS Code Copilot, or Continue.dev.</DocsLi>
|
||||
<DocsLi>API credentials for whatever you want your server to access (Notion, your DB, etc.). Or pick the echo example to skip this.</DocsLi>
|
||||
</DocsList>
|
||||
|
||||
<DocsH2 id="step-1">1. Sign in</DocsH2>
|
||||
<DocsP>Hit the dashboard and enter your email. We send a magic link — no password.</DocsP>
|
||||
<DocsCode label="dev mode" code={`The link is printed to the API console output.\nCheck the terminal where you ran \`pnpm dev\`.`} />
|
||||
|
||||
<DocsH2 id="step-2">2. Describe your tool</DocsH2>
|
||||
<DocsP>
|
||||
Click <Mono>+ New server</Mono> and write what you want in plain language. The clearer
|
||||
you are about which APIs and which tool names, the better the spec.
|
||||
</DocsP>
|
||||
<DocsCode
|
||||
label="prompt.txt"
|
||||
code={`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.`}
|
||||
/>
|
||||
|
||||
<DocsH2 id="step-3">3. Confirm the plan</DocsH2>
|
||||
<DocsP>
|
||||
Step 2 of the wizard shows you exactly which tools Claude parsed from your prompt, the
|
||||
input schemas, and which credentials we need from you. Fill them in. Skip the step
|
||||
entirely for self-contained demo servers like the <Mono>echo</Mono> template.
|
||||
</DocsP>
|
||||
|
||||
<DocsH2 id="step-4">4. Watch the build stream</DocsH2>
|
||||
<DocsP>The build goes through five states live over WebSocket:</DocsP>
|
||||
<DocsList>
|
||||
<DocsLi><Mono>queued</Mono> → spec validated, job in BullMQ</DocsLi>
|
||||
<DocsLi><Mono>generating</Mono> → Claude returns spec (or cached preview is reused)</DocsLi>
|
||||
<DocsLi><Mono>building</Mono> → TypeScript rendered, static checks, Docker image built</DocsLi>
|
||||
<DocsLi><Mono>deploying</Mono> → container booted on an allocated host port</DocsLi>
|
||||
<DocsLi><Mono>live</Mono> → endpoint responds, OAuth gate is active</DocsLi>
|
||||
</DocsList>
|
||||
|
||||
<DocsH2 id="step-5">5. Install in your client</DocsH2>
|
||||
<DocsP>
|
||||
The Done screen shows three tabs — Claude Desktop, Cursor, ChatGPT — each with a copy-ready
|
||||
snippet. Paste the JSON into your client's MCP config and restart. The OAuth handshake
|
||||
runs automatically on first tool call.
|
||||
</DocsP>
|
||||
<DocsCode
|
||||
label="claude_desktop_config.json"
|
||||
code={`{
|
||||
"mcpServers": {
|
||||
"notion-reader": {
|
||||
"url": "http://localhost:4103/mcp",
|
||||
"auth": "oauth2"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
||||
<DocsH3>What's next</DocsH3>
|
||||
<DocsP>
|
||||
Read about the <a href="/docs/concepts" className="text-[--color-accent] underline">underlying MCP concepts</a>,
|
||||
learn how the <a href="/docs/oauth" className="text-[--color-accent] underline">OAuth 2.1 flow</a> protects each server,
|
||||
or jump to <a href="/docs/authoring" className="text-[--color-accent] underline">authoring custom tools</a>.
|
||||
</DocsP>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
apps/web/app/docs/self-hosting/page.tsx
Normal file
82
apps/web/app/docs/self-hosting/page.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
DocsTitle,
|
||||
DocsLead,
|
||||
DocsH2,
|
||||
DocsP,
|
||||
DocsList,
|
||||
DocsLi,
|
||||
DocsCode,
|
||||
Mono,
|
||||
} from '@/components/docs-page';
|
||||
|
||||
export const metadata = { title: 'Self-hosting — BuildMyMCPServer docs' };
|
||||
|
||||
export default function SelfHosting() {
|
||||
return (
|
||||
<>
|
||||
<DocsTitle kicker="Build">Self-hosting</DocsTitle>
|
||||
<DocsLead>
|
||||
The control plane and generator are open. Bring your own Postgres, Redis, Docker host and
|
||||
Anthropic API key. Production uses Hetzner + Coolify + Traefik; the seams are the same.
|
||||
</DocsLead>
|
||||
|
||||
<DocsH2 id="requirements">Requirements</DocsH2>
|
||||
<DocsList>
|
||||
<DocsLi>Node.js 20+</DocsLi>
|
||||
<DocsLi>pnpm 9+</DocsLi>
|
||||
<DocsLi>Docker engine reachable from the generator process</DocsLi>
|
||||
<DocsLi>Postgres 16+ and Redis 7+ (docker-compose for dev)</DocsLi>
|
||||
<DocsLi>Anthropic API key (optional — mock fallback for offline dev)</DocsLi>
|
||||
</DocsList>
|
||||
|
||||
<DocsH2 id="dev">Local dev</DocsH2>
|
||||
<DocsCode
|
||||
label="bash"
|
||||
code={`git clone <repo>
|
||||
cd buildmymcpserver
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
pnpm dev`}
|
||||
/>
|
||||
<DocsP>
|
||||
<Mono>pnpm dev</Mono> loads <Mono>.env</Mono>, brings up Postgres and Redis via
|
||||
docker-compose, pushes the Drizzle schema, and starts web (<Mono>:3001</Mono>), api
|
||||
(<Mono>:4000</Mono>) and generator concurrently.
|
||||
</DocsP>
|
||||
|
||||
<DocsH2 id="env">Environment variables</DocsH2>
|
||||
<DocsList>
|
||||
<DocsLi><Mono>DATABASE_URL</Mono> — Postgres connection string</DocsLi>
|
||||
<DocsLi><Mono>REDIS_URL</Mono> — Redis (BullMQ + pubsub + preview cache)</DocsLi>
|
||||
<DocsLi><Mono>ANTHROPIC_API_KEY</Mono> — unset = mock generator</DocsLi>
|
||||
<DocsLi><Mono>SECRETS_ENCRYPTION_KEY</Mono> — 32-byte hex, AES-256-GCM key</DocsLi>
|
||||
<DocsLi><Mono>CONTROL_PLANE_PUBLIC_URL</Mono> — issuer for OAuth tokens</DocsLi>
|
||||
<DocsLi><Mono>OAUTH_KEY_DIR</Mono> — where RS256 keypair lives (auto-generated on boot)</DocsLi>
|
||||
<DocsLi><Mono>RUNNER_PORT_RANGE_START/END</Mono> — host port window for generated containers</DocsLi>
|
||||
</DocsList>
|
||||
|
||||
<DocsH2 id="prod">Production deployment</DocsH2>
|
||||
<DocsP>
|
||||
The intended production setup is a Hetzner AX52 running Coolify, Traefik for wildcard SSL
|
||||
on <Mono>*.mcp.yourdomain.com</Mono>, and Cloudflare for DNS+DDoS. The runner-deploy
|
||||
adapter is the only environment-specific seam — swap the Docker-CLI implementation in
|
||||
<Mono>apps/generator/src/lib/deploy.ts</Mono> for the Coolify HTTP API.
|
||||
</DocsP>
|
||||
|
||||
<DocsH2 id="sandboxing">Container sandboxing</DocsH2>
|
||||
<DocsP>
|
||||
Production flags (commented in deploy.ts):
|
||||
</DocsP>
|
||||
<DocsList>
|
||||
<DocsLi><Mono>--read-only</Mono></DocsLi>
|
||||
<DocsLi><Mono>--cap-drop=ALL</Mono></DocsLi>
|
||||
<DocsLi><Mono>--security-opt=no-new-privileges</Mono></DocsLi>
|
||||
<DocsLi><Mono>--cpus=0.5 --memory=512m</Mono></DocsLi>
|
||||
</DocsList>
|
||||
<DocsP>
|
||||
Dev relaxes these for Docker Desktop on Windows compat. Don't ship dev defaults to
|
||||
prod.
|
||||
</DocsP>
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
apps/web/components/docs-page.tsx
Normal file
75
apps/web/components/docs-page.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { CodeBlock } from './code-block';
|
||||
|
||||
export function DocsTitle({ children, kicker }: { children: ReactNode; kicker?: string }) {
|
||||
return (
|
||||
<header className="mb-6">
|
||||
{kicker && (
|
||||
<div className="mb-2 text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
{kicker}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-[28px] font-semibold leading-tight tracking-tight">{children}</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsLead({ children }: { children: ReactNode }) {
|
||||
return <p className="mb-8 text-[15px] leading-relaxed text-[--color-fg-muted]">{children}</p>;
|
||||
}
|
||||
|
||||
export function DocsH2({ children, id }: { children: ReactNode; id?: string }) {
|
||||
return (
|
||||
<h2 id={id} className="mt-10 mb-3 text-[18px] font-semibold tracking-tight">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsH3({ children }: { children: ReactNode }) {
|
||||
return <h3 className="mt-6 mb-2 text-[14px] font-semibold tracking-tight">{children}</h3>;
|
||||
}
|
||||
|
||||
export function DocsP({ children }: { children: ReactNode }) {
|
||||
return <p className="mb-4 text-[13.5px] leading-relaxed text-[--color-fg]">{children}</p>;
|
||||
}
|
||||
|
||||
export function DocsList({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ul className="mb-4 space-y-1.5 pl-4 text-[13.5px] leading-relaxed text-[--color-fg]">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsLi({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<li className="relative pl-2 before:absolute before:left-[-12px] before:top-2 before:size-1 before:rounded-full before:bg-[--color-fg-subtle]">
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function Mono({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<code className="mono rounded-sm bg-[--color-bg-subtle] px-1.5 py-0.5 text-[12.5px] text-[--color-fg]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsCode({
|
||||
label,
|
||||
code,
|
||||
language,
|
||||
}: {
|
||||
label?: string;
|
||||
code: string;
|
||||
language?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<CodeBlock label={label} code={code} language={language} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user