From f2238f2e6b894dd5e769fe671a2ecbda5f2cc270 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Tue, 19 May 2026 00:30:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Next.js=2015=20shell=20=E2=80=94?= =?UTF-8?q?=20design=20tokens,=20landing,=20auth=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/(marketing)/layout.tsx | 68 +++++++ apps/web/app/(marketing)/page.tsx | 262 +++++++++++++++++++++++++++ apps/web/app/globals.css | 123 +++++++++++++ apps/web/app/layout.tsx | 19 ++ apps/web/app/login/callback/page.tsx | 55 ++++++ apps/web/app/login/page.tsx | 84 +++++++++ apps/web/components/code-block.tsx | 65 +++++++ apps/web/components/input.tsx | 53 ++++++ apps/web/components/logo.tsx | 14 ++ apps/web/components/status-pill.tsx | 38 ++++ apps/web/components/ui/button.tsx | 45 +++++ apps/web/lib/api.ts | 33 ++++ apps/web/lib/cn.ts | 6 + apps/web/next-env.d.ts | 2 + apps/web/next.config.mjs | 17 ++ apps/web/package.json | 32 ++++ apps/web/postcss.config.mjs | 5 + apps/web/tsconfig.json | 17 ++ 18 files changed, 938 insertions(+) create mode 100644 apps/web/app/(marketing)/layout.tsx create mode 100644 apps/web/app/(marketing)/page.tsx create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/login/callback/page.tsx create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/components/code-block.tsx create mode 100644 apps/web/components/input.tsx create mode 100644 apps/web/components/logo.tsx create mode 100644 apps/web/components/status-pill.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/lib/api.ts create mode 100644 apps/web/lib/cn.ts create mode 100644 apps/web/next-env.d.ts create mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/tsconfig.json diff --git a/apps/web/app/(marketing)/layout.tsx b/apps/web/app/(marketing)/layout.tsx new file mode 100644 index 0000000..fc6c145 --- /dev/null +++ b/apps/web/app/(marketing)/layout.tsx @@ -0,0 +1,68 @@ +import Link from 'next/link'; +import { Logo } from '@/components/logo'; + +export default function MarketingLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+ + +
+
+ + Sign in + + + Start building + +
+
+
+
{children}
+
+
+
+ + All systems operational +
+
+ + Docs + + + Security + + + Privacy + + + Terms + +
+
© {new Date().getFullYear()} BuildMyMCPServer
+
+
+
+ ); +} diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx new file mode 100644 index 0000000..1500f25 --- /dev/null +++ b/apps/web/app/(marketing)/page.tsx @@ -0,0 +1,262 @@ +import Link from 'next/link'; +import { CodeBlock } from '@/components/code-block'; + +const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace. +Tools: search_pages, get_page_content. +Auth: NOTION_API_KEY.`; + +const OUTPUT_EXAMPLE = `> Generating spec... OK (2 tools) +> Static checks OK +> Building image bmm-mcp-notion OK 17.2s +> Deploying container OK +> Live at https://notion-x9.mcp.buildmymcpserver.com +> First request: 401 → token → 200 OK`; + +const INSTALL_SNIPPET = `{ + "mcpServers": { + "notion": { + "url": "https://notion-x9.mcp.buildmymcpserver.com/mcp", + "auth": "oauth2" + } + } +}`; + +const EXAMPLES: { title: string; desc: string }[] = [ + { title: 'Postgres reader', desc: 'Read-only access to your tables with schema introspection.' }, + { title: 'Salesforce', desc: 'Query opportunities, accounts and leads from Claude.' }, + { title: 'Notion', desc: 'Search pages, read content, append blocks.' }, + { title: 'GitHub', desc: 'List issues, search code, post comments — scoped to one repo.' }, + { title: 'Stripe', desc: 'Look up charges, customers, refunds (read-only by default).' }, + { title: 'Custom REST', desc: 'Wrap any HTTP API behind one prompt-defined tool surface.' }, +]; + +const FAQ: { q: string; a: string }[] = [ + { + q: 'What is MCP?', + a: 'Model Context Protocol — an open standard from Anthropic for connecting AI assistants to external tools, data and APIs over a transport like Streamable HTTP.', + }, + { + q: 'Do I need to write code?', + a: 'No. You describe the tool in natural language. We generate the TypeScript server, run static checks, build a Docker image and deploy it to a public OAuth-protected URL.', + }, + { + q: 'Which clients work?', + a: 'Claude Desktop, Cursor, ChatGPT Custom Connectors, VS Code Copilot, Continue.dev — anything that speaks the MCP spec.', + }, + { + q: 'How is auth handled?', + a: 'Every generated server is an OAuth 2.1 Resource Server. Our control plane is the Authorization Server (PKCE + Dynamic Client Registration + Resource Indicators per RFC 8707).', + }, + { + q: 'Can I self-host?', + a: 'Yes. The runner is a plain Docker container; the control plane is open to BYO Postgres + Redis. See the self-hosting guide in docs.', + }, + { + q: 'What about secrets?', + a: 'AES-256-GCM at rest in Postgres, injected as environment variables into the runtime container. Never logged, never echoed back.', + }, + { + q: 'Cold starts?', + a: 'No cold starts. Containers stay warm. Sub-50ms tool-call overhead on average for in-region requests.', + }, + { + q: 'Rate limits?', + a: 'Default 100 requests/min/IP per tool. Configurable per server. Quota enforced at the Traefik layer before hitting your container.', + }, + { + q: 'How fast is generation?', + a: 'Spec → image → live URL typically completes in 45-90 seconds.', + }, + { + q: 'Logs and metrics?', + a: 'Live log streaming to the dashboard, structured tool-call metrics (P50/P95/P99 latency, error rate, per-tool throughput) — all retained for 30 days.', + }, + { + q: 'What if I cancel?', + a: 'You can export the full TypeScript source of every server you built. No vendor lock-in.', + }, + { + q: 'Custom domain?', + a: 'Pro plan and above. Add a CNAME, we provision Let’s Encrypt automatically.', + }, +]; + +const TIERS = [ + { name: 'Hobby', price: '€0', tag: 'Forever free', features: ['1 server', '100k calls/mo', 'BMM subdomain', 'Community support'] }, + { name: 'Pro', price: '€49', tag: '/ month', features: ['5 servers', '1M calls/mo', 'Custom domain', 'Priority build queue', 'Email support'] }, + { name: 'Team', price: '€149', tag: '/ month', features: ['25 servers', '10M calls/mo', 'RBAC + audit log', 'SLA 99.9%', 'Slack support'] }, + { name: 'Enterprise', price: '€499+', tag: '/ month', features: ['Unlimited', 'BYOC', 'SSO / SAML', 'Dedicated cluster', 'Customer success'] }, +]; + +export default function Landing() { + return ( + <> + {/* Hero */} +
+
+
+ + v0.1 — MCP spec 2025-11-25 + +

+ Describe your tool. +
+ We host the server. +
+ AI uses it. +

+

+ From prompt to production MCP server in 60 seconds. OAuth 2.1, Streamable HTTP, ready + for Claude, Cursor and ChatGPT. +

+
+ + Start building free + + + Read the docs + +
+
+ + OAuth 2.1 + PKCE + + + Streamable HTTP + + + AES-256 secrets + + + Per-server isolation + +
+
+ +
+
+
+ + + +
+
+
+
+ + {/* How it works */} +
+
+
+

How it works

+

+ Three steps. No JSON to write, no Docker to manage. +

+
+
+ {[ + { n: '01', t: 'Describe your tool', d: 'A sentence is enough. List your secrets and which APIs to call.' }, + { n: '02', t: 'We generate, check, deploy', d: 'Claude writes the spec. We render TypeScript, run static checks, build a container, deploy to your subdomain.' }, + { n: '03', t: 'Install in your client', d: 'Copy the snippet into Claude Desktop, Cursor or ChatGPT. OAuth flow on first use.' }, + ].map((s) => ( +
+
{s.n}
+

{s.t}

+

{s.d}

+
+ ))} +
+
+
+ + {/* Works with */} +
+
+

+ Works with the clients you already use +

+
+ {['Claude Desktop', 'Cursor', 'ChatGPT', 'VS Code Copilot', 'Continue.dev'].map((t) => ( + + + {t} + + ))} +
+
+
+ + {/* Examples */} +
+
+
+

Built for the work you actually have

+

+ Anything with an HTTP API or a database, in minutes. +

+
+
+ {EXAMPLES.map((e) => ( +
+
{e.title}
+

{e.desc}

+
+ ))} +
+
+
+ + {/* Pricing */} +
+
+
+

Pricing

+

+ Pay for tool calls, not for boilerplate. +

+
+
+ {TIERS.map((t, i) => ( +
+
{t.name}
+
+ {t.price} + {t.tag} +
+
    + {t.features.map((f) => ( +
  • — {f}
  • + ))} +
+
+ ))} +
+
+
+ + {/* FAQ */} +
+
+

FAQ

+
+ {FAQ.map((f) => ( +
+

{f.q}

+

{f.a}

+
+ ))} +
+
+
+ + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..e5f3df6 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,123 @@ +@import 'tailwindcss'; + +@theme { + --color-bg: #0a0a0b; + --color-bg-elevated: #111114; + --color-bg-subtle: #16161a; + --color-fg: #fafafa; + --color-fg-muted: #a1a1aa; + --color-fg-subtle: #71717a; + --color-border: #1f1f22; + --color-border-strong: #2a2a2e; + --color-accent: #6366f1; + --color-accent-fg: #ffffff; + --color-success: #22c55e; + --color-warn: #f59e0b; + --color-danger: #ef4444; + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; +} + +@layer base { + * { + border-color: var(--color-border); + } + html { + color-scheme: dark; + background: var(--color-bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + body { + background: var(--color-bg); + color: var(--color-fg); + font-family: var(--font-sans); + font-feature-settings: 'cv11', 'ss01'; + } + ::selection { + background: rgba(99, 102, 241, 0.3); + color: var(--color-fg); + } + /* Focus rings */ + :focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + /* Scrollbars */ + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: var(--color-border-strong); + border-radius: 5px; + } + ::-webkit-scrollbar-thumb:hover { + background: var(--color-fg-subtle); + } +} + +@layer components { + .panel { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + } + .panel-subtle { + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + } + .mono { + font-family: var(--font-mono); + font-size: 0.8125rem; + letter-spacing: -0.01em; + } + /* Reduced motion */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } + } +} + +@keyframes pulse-dot { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.9); + } +} + +@keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..14e44b4 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import { GeistSans } from 'geist/font/sans'; +import { GeistMono } from 'geist/font/mono'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'BuildMyMCPServer — Describe your tool. We host the server.', + description: + 'From prompt to production MCP server in 60 seconds. OAuth 2.1, Streamable HTTP, ready for Claude, Cursor & ChatGPT.', + metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'), +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/login/callback/page.tsx b/apps/web/app/login/callback/page.tsx new file mode 100644 index 0000000..6c1e1ea --- /dev/null +++ b/apps/web/app/login/callback/page.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Logo } from '@/components/logo'; +import { apiFetch } from '@/lib/api'; + +export default function CallbackPage() { + const router = useRouter(); + const params = useSearchParams(); + const token = params.get('token'); + const [state, setState] = useState<'verifying' | 'ok' | 'error'>('verifying'); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token) { + setState('error'); + setError('Missing token'); + return; + } + (async () => { + try { + await apiFetch('/v1/auth/verify', { + method: 'POST', + body: JSON.stringify({ token }), + }); + setState('ok'); + setTimeout(() => router.replace('/dashboard'), 200); + } catch (err) { + setState('error'); + setError((err as Error).message); + } + })(); + }, [token, router]); + + return ( +
+
+ + {state === 'verifying' && ( +

Verifying your magic link…

+ )} + {state === 'ok' && ( +

Signed in. Redirecting…

+ )} + {state === 'error' && ( + <> +

Could not verify magic link.

+ {error &&

{error}

} + + )} +
+
+ ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..8883e8c --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,84 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { Logo } from '@/components/logo'; +import { Button } from '@/components/ui/button'; +import { Input, Label } from '@/components/input'; +import { apiFetch } from '@/lib/api'; + +export default function LoginPage() { + const [email, setEmail] = useState(''); + const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + const [error, setError] = useState(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setState('sending'); + setError(null); + try { + await apiFetch('/v1/auth/magic-link', { + method: 'POST', + body: JSON.stringify({ email }), + }); + setState('sent'); + } catch (err) { + setState('error'); + setError((err as Error).message); + } + } + + return ( +
+
+ +

Sign in to your workspace

+

+ We'll email you a magic link. No password. +

+ + {state !== 'sent' ? ( +
+
+ + setEmail(e.target.value)} + placeholder="you@company.com" + /> +
+ + {error &&

{error}

} +
+ ) : ( +
+

+ Magic link sent to {email}. +

+

+ In dev mode the link is printed to the API console output. Check the terminal. +

+
+ )} + +
+ + ← Back to home + +
+
+
+ ); +} diff --git a/apps/web/components/code-block.tsx b/apps/web/components/code-block.tsx new file mode 100644 index 0000000..5d984c3 --- /dev/null +++ b/apps/web/components/code-block.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState } from 'react'; +import { Check, Copy } from 'lucide-react'; +import { cn } from '@/lib/cn'; + +export interface CodeBlockProps { + code: string; + language?: string; + label?: string; + className?: string; +} + +export function CodeBlock({ code, language, label, className }: CodeBlockProps) { + const [copied, setCopied] = useState(false); + + async function onCopy() { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch {} + } + + return ( +
+ {(label || language) && ( +
+ + {label ?? language} + + +
+ )} + {!label && !language && ( + + )} +
+        {code}
+      
+
+ ); +} diff --git a/apps/web/components/input.tsx b/apps/web/components/input.tsx new file mode 100644 index 0000000..9799aad --- /dev/null +++ b/apps/web/components/input.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { cn } from '@/lib/cn'; + +export const Input = React.forwardRef>( + function Input({ className, ...props }, ref) { + return ( + + ); + }, +); + +export const Textarea = React.forwardRef>( + function Textarea({ className, ...props }, ref) { + return ( +