Compare commits

..

94 Commits

Author SHA1 Message Date
Marco Sadjadi
c4f0db5719 fix(review): loop-2 findings — login fallback, proof-band confidence, contrast, content hedges
Code review: login no longer strands visitors with zero sign-in methods when
the providers fetch fails (falls back to SMS + error notice); skeleton while
providers load instead of a blank card.

Design re-audit: proof band flipped from apology to flex ('Don't take our
word for it / Every claim links to its proof') and promoted to the primary
heading tier; unverifiable '60 seconds'/'in minutes' speed claims dropped;
fg-subtle body text bumped to fg-muted (AA contrast); marketplace section
differentiated via border-y + elevated bg (adjacent hairlines removed);
preview frame traffic lights on-palette; header h-12 -> h-14 (hero svh calc
adjusted); emerald-400 -> --color-success token.

Content QA: rest-api article description drift between page and registry
resolved; ChatGPT plan-gating table and Atlassian Rovo SSE-cutoff claims now
carry dated hedges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
2026-07-08 23:13:53 +02:00
Marco Sadjadi
a08f5f05b1 feat(content): 11 new SEO/GEO guide articles with per-article OG images
How-to cluster: create-mcp-server-without-code, claude-desktop-mcp-setup,
chatgpt-mcp-connector (incl. honest Business/Enterprise write-connector plan
limits), rest-api-to-mcp-server.
Comparison cluster: mcp-server-hosting-pricing (5-platform matrix),
composio-alternative, smithery-alternative — house style: explicitly state
where competitors win.
Technical/GEO cluster: mcp-transports-explained (SSE deprecation 2025-03-26),
mcp-oauth-plain-english, mcp-server-security-checklist, and German DACH
article mcp-server-ohne-code-erstellen (articleJsonLd gained optional
inLanguage param).

Every article: pageMetadata canonical, Article JSON-LD with Person author +
image + wordCount, BreadcrumbList, FAQPage where applicable, 1200x630 OG
image via shared helper, internal linking. Registry entries in lib/articles.ts
feed guides index, sitemap and RSS automatically. Product claims sourced only
from lib/seo.ts truth — no invented customers, SLAs or certifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
2026-07-08 23:04:19 +02:00
Marco Sadjadi
089074d104 feat(conversion): Google-first login, truthful dashboard, SSR marketplace, template seeding
- login: OAuth/email on top, phone collapsed behind 'Sign in with phone
  instead' (prod providers: google on, email off, sms on — a developer
  should never see a phone field as the front door)
- dashboard: plan card wired to GET /v1/billing/status; calls card shows
  '—' + pointer to per-server metrics (no user-facing usage endpoint
  exists; previous card showed invented '0 of 100,000 / Hobby')
- templates: server-rendered grid (revalidate 300) via fetchPublicTemplates,
  client browser hydrates with initial data; inviting empty state with
  labeled starter ideas instead of 'No templates yet'
- servers/new: removed upgrade nag from first analyze wait
- scripts/seed-templates.mjs: idempotent dry-run-by-default seeder driving
  the real preview->create->live->publish flow for 6 first-party templates

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
2026-07-08 23:00:25 +02:00
Marco Sadjadi
17056d0b30 feat(landing): honest high-end redesign — kill fake social proof, brand gradient identity, final CTA, mobile fixes
- removed fabricated fork counts/'verified' badges, 'our customers ship
  today' copy, pseudo client logo marks and stale v0.1 badge (zero-user
  product must not fake traction)
- new proof-by-specificity band: verifiable links to /docs/oauth, /status,
  /security instead of testimonial cosplay
- identity: indigo-to-cyan brand gradient, indigo-tinted elevated surfaces,
  mono section kickers, terminal-chrome hero rotator, gradient h-11 CTA
- how-it-works as connected pipeline; new final CTA band (page no longer
  ends on FAQ); footer upgraded to 4-column product/resources/legal
- lib/pricing.ts single tier source for pricing page + landing teaser;
  annual-billing FAQ claim softened to truth (no annual checkout exists)
- hero video preload=metadata + IntersectionObserver play (2.6MB off the
  critical path); 44px touch targets; mobile menu: Guides link, CTA,
  scroll-lock, escape/outside-tap close

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
2026-07-08 23:00:25 +02:00
Marco Sadjadi
cba45402ce fix(seo): canonical self-deindexing on /docs/*, truthful llms.txt, real sitemap dates, RSS feed, breadcrumbs, per-article OG images
- /docs subpages canonicalized to /docs and had no descriptions; each now
  uses pageMetadata with its own path (was: Google could index at most one)
- llms.txt claimed Team EUR 149, RBAC/SLA and BYO-cloud that do not exist;
  regenerated from lib/seo.ts truth + new llms-full.txt with docs content
- sitemap stamped lastModified=now on every request; now real per-route dates
  from new lib/articles.ts registry (single source for guides index/sitemap/RSS)
- new /feed.xml RSS 2.0 route + alternates link in root layout
- articleJsonLd: image (per-guide opengraph-image via lib/og-article.tsx),
  Person author, wordCount; new breadcrumbJsonLd on guides + docs
- GSC verification via NEXT_PUBLIC_GSC_VERIFICATION (documented in .env.example)
- dropped fabricated Enterprise EUR 499 offer from SoftwareApplication JSON-LD
- article-shell: OL/Table/Note primitives for upcoming articles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXUwmPVRTD8AKQtio6gCN5
2026-07-08 23:00:02 +02:00
Marco Sadjadi
3dc65e4f4d fix(ux): human-readable API errors instead of raw api_error_NNN codes
All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s
apiFetch threw new Error(api_error_<status>), so any UI fallback to (e).message showed a cryptic code, and some flows surfaced bare codes like slug_taken. Added a central humanizeError() in lib/api.ts: prefers the backend detail sentence, then a mapped friendly message per known code, then a status-based fallback - never a raw code. apiFetch now sets the thrown error message via it, so every (e).message fallback across the app becomes a real sentence. Wizard analyze/build now use humanizeError directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:08:39 +02:00
Marco Sadjadi
2a12ea18cd fix(wizard): editable slug on confirm step so slug_taken (409) is fixable in place
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
POST /v1/servers returns 409 slug_taken when the org already has that slug. The error told users to change the slug field above, but the normal (non-fork) flow had no slug field on the confirm step - only Step 1 did - leaving them stuck. Now Name+Slug are editable on the confirm step for the normal flow too, mirroring the fork flow, so a slug conflict is resolved without going back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:56:45 +02:00
Marco Sadjadi
be02600759 feat(security): block credentials from reaching the LLM via prompt secret scan
All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s
Prompts were sent to the model with no secret scan, so a pasted API key would leak to the LLM. Added findSecretInPrompt in @bmm/types (tight provider-key patterns: Anthropic/OpenAI/GitHub/AWS/Google/Slack/Stripe/JWT/private-key) shared by both sides. The web wizard blocks before sending with a clear message; the API preview and preview-stream endpoints reject with secret_in_prompt as the hard guarantee. Credential VALUES already never touched the model - they are entered in the separate encrypted step 2; this closes the remaining leak path where a user pastes a key into the prompt itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:52:16 +02:00
Marco Sadjadi
ee4713f82c feat(account): self-service GDPR Art.17 erasure; Enterprise price -> Custom
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
Account deletion (DELETE /v1/account): re-type email/phone to confirm, stops live containers and hard-deletes every org where the caller is sole member (FK cascade clears servers, builds, logs, encrypted secrets), deletes the user (cascade drops sessions), audits the action, clears the session cookie. Frontend danger-zone replaces the old open-a-ticket placeholder. Closes audit ACC-001. Enterprise price unified to Custom on landing + pricing, removing the 499/999 inconsistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:23:41 +02:00
Marco Sadjadi
bd82a67fba fix(claims): purge false tier claims from landing, billing cards and legal docs
All checks were successful
Deploy to Production / deploy (push) Successful in 1m32s
The false RBAC / 99.9 SLA / BYOC / custom-domain claims were not only on /pricing but also on the landing-page tier cards, the in-app billing upgrade cards, and — most seriously — the AGB and Terms as a binding 99.9 monthly uptime SLA the single-host infra cannot meet. Aligned all of them: SLA removed from AGB/Terms (best-effort, no guaranteed SLA for self-serve; Enterprise by contract); landing+billing cards now show Audit log, RBAC coming-soon, custom-domain coming-soon, honest Enterprise infra; landing Team price corrected 149->199; billing cards model name Haiku/Sonnet -> Claude AI. Privacy page intentionally keeps exact model names for data-residency disclosure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:43:46 +02:00
Marco Sadjadi
7eb323e8f8 fix(pricing): every tier claim now true or honest; build real priority queue
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
Audited all tiers vs code. BUILT priority build queue (both enqueue sites set BullMQ priority by plan, enterprise>team>pro>hobby). Made honest what is not built and cannot be built remotely: Custom domain -> coming soon; Team RBAC -> Audit log + RBAC coming soon; dropped Team 99.9 SLA; reworded FAQ rate-limit, cold-start sub-50ms, 30-day-retention and auto-TLS claims to reality; quota FAQ no longer promises unbuilt overage billing; JSON-LD offers aligned, Team price 149->199. Verified-true kept: server limits 1/5/25/inf and daily caps 5/40/50 enforced, faster paid Claude analysis, source export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:33:41 +02:00
Marco Sadjadi
74ca59b8b7 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m19s
fix(pricing): honest Enterprise claims — drop unbuilt BYOC/SSO/dedicated-cluster

BYOC, dedicated cluster and SSO/SAML are advertised but not implemented (the
platform deploys local Docker containers on one shared host; no cloud-provider
abstraction exists). Reframe as "on request / scoped per contract" on the
pricing page and in the sitewide SoftwareApplication JSON-LD, since Enterprise
is contact-sales and scoped per deal anyway. Avoids advertising features that
do not exist (UWG / trust risk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 13:22:50 +02:00
Marco Sadjadi
4d717d877f @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
feat(pricing): generic "Claude AI" label on paid tiers instead of model names

Naming "Claude Haiku 4.5" on Pro read as a cheap tier. All paid tiers now show
"Claude AI" with the differentiation moved to the detail line (speed / flagship
quality / top-tier + EU residency); Hobby keeps "Open-tier AI".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 13:09:00 +02:00
Marco Sadjadi
4687c8be52 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m25s
fix(billing): correct Stripe API version + harden checkout; clarify wizard secrets

- Stripe apiVersion was pinned to 2025-10-29.acacia, but stripe@22 is built
  for 2026-04-22.dahlia — where ui_mode embedded_page exists. The mismatch
  made the embedded checkout create call fail/hang, surfacing in the browser
  as an opaque CORS error (CF returns a 5xx without our ACAO header). Pin to
  dahlia + add a 20s client timeout so any failure returns a readable 502.
- new-server wizard: step 1 now warns not to paste API keys into the prompt;
  the credentials section (which already collects each secret in its own
  encrypted field) is relabelled and its empty state invites adding one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 12:08:05 +02:00
Marco Sadjadi
1349dc1dc0 @
feat(web): SEO — server-rendered template pages + /guides articles

- templates/[slug] converted from client to server component: per-template
  generateMetadata (title/description/canonical/OG) + SoftwareApplication
  JSON-LD; code-audit toggle split into a client island; missing/non-public
  templates now return a real 404.
- sitemap.ts pulls public template slugs live from the API (best-effort) +
  the new /guides routes.
- new /guides section: 3 server-rendered SEO articles (host MCP with OAuth,
  hosted-platforms comparison, MintMCP alternative) with TechArticle JSON-LD;
  Guides link added to the marketing nav.
- lib/seo.ts: articleJsonLd + templateJsonLd builders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 12:08:05 +02:00
Marco Sadjadi
21a5cf5762 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m25s
feat(web): subtle hover/tap video controls (seek + play/pause)

Add a discreet bottom control bar to the hero video — play/pause, elapsed
time, a seek slider, and mute — that reveals on hover (desktop) or tap
(touch) and auto-hides ~2.8s after the last interaction while playing; it
stays visible while paused so the scrubber is reachable. The seek slider is
a real <input type=range> (keyboard/drag/touch, accessible) laid invisibly
over a custom rail+fill so the look matches the page. Autoplay/muted/loop,
the centre play overlay, the play-failed fallback link and poster are
unchanged; the always-on mute button is now folded into the bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-30 20:55:48 +02:00
Marco Sadjadi
cf423de3d5 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m22s
feat(billing): in-app embedded Stripe checkout + webhook hardening

Checkout previously used hosted ui_mode → window.location to checkout.stripe.com,
which pops out of the installed PWA into the system browser. Switch to embedded:

- API: ui_mode embedded_page (stripe-node v22 / API 2025-10 renamed the enum),
  return_url instead of success/cancel_url, returns client_secret.
- web: @stripe/react-stripe-js EmbeddedCheckout mounted in an in-app modal;
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY baked at build (Dockerfile arg + compose arg).
- .env.production.example: full Stripe section (was missing) + admin-email
  placeholder (INF-001).

Also bundled (same files): BILL-002 invoice.paid resets quota only on
subscription_cycle; BILL-003 webhook dedup rolled back on handler failure;
BILL-001 change-plan writes plan locally; BILL-004 webhook cross-checks
sub.customer before trusting metadata.orgId; INF-003 API routed off the raw
docker.sock through a locked-down tecnativa/docker-socket-proxy (CONTAINERS+POST).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-29 20:56:40 +02:00
Marco Sadjadi
9d5386ccba @
fix(security): sovereign-audit hardening pass — RCE, multi-tenant, reliability

Reasoning-based audit fixes (all verified by typecheck, attack paths re-traced):

- build-time RCE: validate spec.dependencies to npm-registry semver only
  (no git/url/file specifiers) + --ignore-scripts in runner Dockerfile.
- container hardening fail-CLOSED: harden unless RUNNER_DISABLE_HARDENING=1,
  no longer gated on a fragile NODE_ENV string compare.
- secret env keys validated (UPPER_SNAKE, reject NODE_*/PATH/LD_*).
- cross-org image-tag collision: qualify tag with serverId.
- /iterate now enforces suspension + daily-build limits like /servers.
- preview SSE: clear keepalive in finally + on client close (timer/FD leak).
- SMS OTP: atomic attempt counter (lt(attempts,MAX) in UPDATE) — brute-force race.
- getSession orders membership by createdAt (deterministic primary org).
- template scopes aggregated from real tool scopes (was hardcoded mcp:read).
- template category filter pushed into WHERE (was applied after LIMIT).
- support admin reply/status: 404 on unknown ticket; status change now audited.
- build worker: queue defaultJobOptions, docker build/run/stop timeouts,
  old-container teardown in finally (no orphan on post-deploy DB failure).
- nginx: HSTS, X-Frame-Options DENY, nosniff, Referrer-Policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-29 20:56:30 +02:00
Marco Sadjadi
092290bb38 fix(preview/stream): await onSpec/onError handlers
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
The llm package called the user-supplied onSpec/onError handlers
without awaiting them. In the /preview/stream route onSpec is async
(it does `await cacheSpec(...)` then writes the SSE `spec` event), so
the api handler's `await streamSpecFromAnthropic(...)` returned BEFORE
the terminal event had been written. The route's finally block then
ran `reply.raw.end()`, the queued `send('spec', ...)` hit a closed
stream and silently no-op'd, and the browser saw zero terminal
events — frontend ran into the "Spec generation failed." fallback
even though Anthropic had delivered a perfectly valid spec.

Verified against prod log: req-8 ran 66s with 200 and produced no
preview_spec_* log line, which is exactly the success-but-event-lost
signature.

Fix:
- StreamHandlers.onSpec / onError typed as Promise<void> | void
- Both call sites in streamSpecFromAnthropic now `await` them
- /preview/stream sets `resolved = true` at the END of each handler
  (after the SSE write completes) so the post-stream "unresolved"
  fallback only fires on a genuine programming bug
- Added preview_spec_ready info log on the happy path so future
  diagnosis doesn't have to infer success from the absence of error
  logs
2026-05-28 22:00:03 +02:00
Marco Sadjadi
29e699dc74 fix(preview/stream): emit CORS headers before flushHeaders()
All checks were successful
Deploy to Production / deploy (push) Successful in 1m22s
@fastify/cors injects Access-Control-Allow-* in the onSend hook, but
the SSE endpoint goes straight to reply.raw.flushHeaders() — onSend
never runs, so the browser saw "No 'Access-Control-Allow-Origin'
header" and blocked the fetch before any bytes flowed.

Set Allow-Origin (reflecting the configured app origin),
Allow-Credentials, and Vary: Origin manually right before the SSE
content-type headers. Matches what the cors plugin would have
emitted on a normal response.
2026-05-28 21:53:35 +02:00
Marco Sadjadi
31bfeed9dd feat(dashboard): delete button on server detail page
All checks were successful
Deploy to Production / deploy (push) Successful in 1m27s
The DELETE /v1/servers/:id endpoint existed (tears down the runner
container + removes the row) but nothing in the UI called it, so
servers could only be removed via SSH+psql. Adds a danger-variant
button in the top-right of the detail header with a native confirm,
spinner state, and inline error surfacing. Redirects to /servers
on success.
2026-05-28 21:44:52 +02:00
Marco Sadjadi
ec819082a6 fix(llm): escape backticks in SYSTEM_PROMPT (broke typecheck)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m9s
2026-05-28 21:39:34 +02:00
Marco Sadjadi
147ba69968 fix(runner): alias params/input to args so tool implementations don't ReferenceError
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
Auth chain finally landed but tool calls crashed in the wetter server
with "Error: params is not defined". The MCP SDK passes the validated
tool args as a single parameter; our template names that parameter
`args` but the model frequently writes `params.location` / `input.x`
because that's how OpenAPI and JSON-RPC reference docs read.

Two-sided fix:
- render.ts wraps every implementation with `const params = args; const
  input = args;` inside the try block. Whichever alias the model
  picked, the variable resolves to the same validated object.
- SYSTEM_PROMPT now states the variable name EXPLICITLY ("variable
  named EXACTLY `args`, e.g. args.location") so new generations stop
  drifting on that detail.

Existing wetter runner needs a rebuild to pick up the alias shim.
2026-05-28 21:39:11 +02:00
Marco Sadjadi
b421457010 fix(oauth): accept client_secret_basic on /oauth/token (RFC 6749 §2.3.1)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
Sovereign-audit Phase 3 caught the next layer of the same bug:
form-urlencoded parsing now works, but the AS metadata advertises
both `client_secret_basic` and `client_secret_post` while the handler
only read credentials from the body. Claude Desktop (and most OAuth
SDKs) prefer Basic auth, so every token exchange landed at
"401 invalid_client" — visible in prod logs as POST /oauth/token from
160.79.106.37 returning 401 in <4ms (failing the missing-secret check).

Parse Authorization: Basic header, decode base64, percent-decode each
side (RFC 6749 §2.3.1 mandates pct-encoding of user/pass before the
base64 step), and treat the resulting credentials as if they came from
the body. Header takes precedence when both are present.
2026-05-28 21:28:23 +02:00
Marco Sadjadi
44cebc9fd8 fix(oauth): accept application/x-www-form-urlencoded on /oauth/token
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
Sovereign-audit traced "Authorization with the MCP server failed" past
discovery, DCR, /authorize → redirect → code, and into POST /oauth/token,
which Fastify rejected with 415 before our handler ever ran.

RFC 6749 §3.2 makes form-urlencoded the mandatory wire format for the
token endpoint, and every DCR-emitting client (Claude Desktop, Cursor,
OpenAI Codex, …) posts it that way. Fastify ships no built-in parser
for that media type so the route 415'd from the framework's content-
type layer — invisible to a code review of the route handler.

Adds a small URLSearchParams-based parser next to the existing JSON
one, parses the form body into a plain object so the route's zod
schema picks it up unchanged. No new dependency.
2026-05-28 21:21:40 +02:00
Marco Sadjadi
0c6d738a6b feat(preview): SSE-streamed generation, no CF 100s edge cap
All checks were successful
Deploy to Production / deploy (push) Successful in 1m27s
Architectural fix for "spec_too_large" / preview_timeout — the sync
endpoint had to fit the whole model run into Cloudflare's ~100s edge
window, which made the system fragile against any prompt that produced
a verbose spec. The new streaming path pipes Anthropic's token deltas
as Server-Sent Events; every chunk resets CF's idle timer and a 15s
keepalive comment guarantees activity even during slow first-token
windows.

@bmm/llm: new streamSpecFromAnthropic() exposes the SDK's .stream()
flow with the same typed-error contract as generateSpec — same
SpecTruncatedError / SpecValidationError / SpecTimeoutError raised from
the relevant moment.

API: POST /v1/servers/preview/stream returns text/event-stream with
events 'text' (deltas), 'spec' (final success payload, same shape as
the sync endpoint), 'error' (typed). Anthropic-only — GLM/hobby falls
back to the sync route via 409 streaming_unavailable.

Frontend: apiSseStream() handles the POST + ReadableStream + SSE
parser. The wizard's analyze() prefers the stream and only uses the
sync endpoint on the explicit 409 fallback.

nginx (api.buildmymcpserver.com): the /v1/builds/ location block (which
already had proxy_buffering off + 600s read timeout for the WS build
stream) now also matches /v1/servers/preview/stream so the SSE
response isn't buffered.
2026-05-28 21:11:05 +02:00
Marco Sadjadi
b930a454e8 fix(llm): tighter system prompt + 12288 max_tokens for paid tiers
All checks were successful
Deploy to Production / deploy (push) Successful in 1m33s
Sonnet 4.6 was still hitting max_tokens on ambitious prompts like
"WorldWeather MCP for any location" because the implementation bodies
ballooned with defensive scaffolding. Two changes:

1. SYSTEM_PROMPT now imposes hard limits the model can self-enforce:
   - at most 6 tools (combine related capabilities with a mode param)
   - implementation body <= 40 lines, no comments, no overengineering
   - descriptions <= 100 chars
   These keep a typical preview under ~7k output tokens.

2. team/enterprise maxTokens 8192 -> 12288. At ~130 tok/s that fits in
   ~94s, still under Cloudflare's 100s edge cap. Hobby (GLM) and pro
   (Haiku) keep their existing limits — they were not hitting the
   ceiling.

SpecTruncatedError still fires + surfaces 422 spec_too_large when even
12288 isn't enough, so the user gets actionable feedback instead of an
opaque zod error.
2026-05-28 21:01:50 +02:00
Marco Sadjadi
4d136c4fb2 fix(mcp): RFC 9728 protected-resource metadata path + audience binding
All checks were successful
Deploy to Production / deploy (push) Successful in 1m31s
Codex/RFC review showed that Claude Desktop addresses the MCP resource
as <PUBLIC_URL>/mcp (the streamable-HTTP endpoint) rather than the
base URL. Per RFC 9728 the protected-resource metadata then lives at
.well-known/oauth-protected-resource inserted between host and path:

  https://mcp.buildmymcpserver.com/.well-known/oauth-protected-resource/<slug>/mcp

Runner template now:
  - publishes `resource: <PUBLIC_URL>/mcp`
  - sets WWW-Authenticate to the RFC 9728 well-known URL
  - serves /.well-known/oauth-protected-resource[/*] so the metadata
    answers at both the legacy and RFC paths during transition
  - accepts both audiences (<PUBLIC_URL>/mcp + <PUBLIC_URL>) during
    rollout so already-issued tokens keep working

API:
  - resolveServerByResource() tries port first, then path segment
    (production path-routing), with a guard against treating "mcp" as
    a tenant slug
  - AS metadata advertises resource_parameter_supported: true

nginx (scripts/setup-runner-tls.sh + scripts/bmm-mcp-runners.nginx):
  - new location matches /.well-known/oauth-protected-resource/<slug>/...
    and proxies to the slug's runner with the slug stripped, so the
    runner sees the local well-known path

Docs (oauth + api-reference) updated to the RFC paths.
2026-05-28 20:54:27 +02:00
Marco Sadjadi
1d845abf92 fix(oauth): resolve server by path segment, not subdomain
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
Claude Desktop got past discovery + DCR but /oauth/authorize rejected
the resource parameter with invalid_resource. Root cause:
resolveServerByResource() extracted the slug from the URL's first
hostname label (subdomain routing), but production runs path routing —
mcp.buildmymcpserver.com/<slug>. The function saw resource
"https://mcp.buildmymcpserver.com/text-generation", tried to look up
slug="mcp", missed, returned null → 400.

Path lookup is now tried first (matches the production topology and
the resource URL we publish via /.well-known/oauth-protected-resource),
port lookup second (local dev), subdomain lookup last with an explicit
"mcp" guard so the legacy path doesn't shadow the new one.
2026-05-28 19:58:31 +02:00
Marco Sadjadi
86cf89ef42 fix(oauth): serve AS metadata at the RFC 8414 strict path
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
Root cause of Claude Desktop's repeated "Registrierung beim
Anmeldedienst fehlgeschlagen" reference ofid_897eda676d452435:

RFC 8414 §3 constructs the well-known discovery URL by INSERTING
"/.well-known/oauth-authorization-server" between the host and the
issuer path. For issuer https://api.buildmymcpserver.com/oauth the
correct location is

  https://api.buildmymcpserver.com/.well-known/oauth-authorization-server/oauth

We previously served only the issuer-appended form
(/oauth/.well-known/...), which is the historically common but
RFC-incorrect placement. Claude Desktop's MCP SDK is strict per
RFC 8414, hit the 404, and bailed out of discovery before ever
reaching /oauth/register — so the DCR fix from earlier never had
a chance to run.

Now serves the same metadata at four paths via a single handler:
  - /.well-known/oauth-authorization-server/oauth (RFC 8414 strict)
  - /.well-known/oauth-authorization-server      (root fallback)
  - /oauth/.well-known/oauth-authorization-server (historical)
  - /.well-known/openid-configuration            (OIDC fallback)

A single buildAsMetadata() helper keeps them in sync.
2026-05-28 19:47:47 +02:00
Marco Sadjadi
d2b19a5439 fix(preview): max_tokens 4096→8192 + detect truncation explicitly
All checks were successful
Deploy to Production / deploy (push) Successful in 1m24s
Root cause of repeat 422s: 4096 was too tight for ambitious prompts
(Marco's research-assistant prompt produces ~12kB of JSON before the
model gets cut off mid-string). The error then surfaced as an opaque
"Unterminated string in JSON" zod failure instead of pointing the user
at the real problem.

Two fixes:
- maxTokens back to 8192 (the original) for all Claude tiers, 4096 for
  GLM. Timeouts bumped to 95s — Sonnet 4.6 at ~130 tok/s does 8192 in
  ~63s, ~30s headroom for cold starts, still under Cloudflare's 100s
  edge cap.
- Detect stop_reason === 'max_tokens' on the Anthropic response BEFORE
  parsing and throw the new SpecTruncatedError. /preview catches it
  and returns 422 spec_too_large with a clear "split the prompt"
  message instead of leaking the zod parse failure.
2026-05-28 19:34:40 +02:00
Marco Sadjadi
979d1abfca feat(preview): log spec validation failures with raw output
All checks were successful
Deploy to Production / deploy (push) Successful in 1m25s
422s from /preview hid the actual reason: zod_message tells which field
was wrong and a 400-char preview of the model output reveals refusals
or non-JSON returns. Both stay in the api log only — never surfaced
to the client unchanged.
2026-05-28 19:19:57 +02:00
Marco Sadjadi
5a8e736113 fix(llm): preview timeout 60s→90s + maxTokens 8192→4096
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
Enterprise plan was hitting SpecTimeoutError exactly at 60s because the
Sonnet 4.6 preview was budgeted for 8192 tokens at ~80 tok/s (≈102s
worst case) inside a 60s window. The frontend then rolled back to step
1 with no spec.

A real spec is small (<= ~10 tools, ~1.5–2.5k output tokens in practice)
so 4096 is plenty and lets even Sonnet finish in ~51s worst case. The
90s timeout buys headroom for cold starts while staying under
Cloudflare's 100s edge cap. Hobby/GLM bumped to 90s too — same
headroom argument.
2026-05-28 18:51:51 +02:00
Marco Sadjadi
1093dc40a7 fix(runner): correct PUBLIC_URL + mount runner-map volume
All checks were successful
Deploy to Production / deploy (push) Successful in 1m38s
Two overlapping bugs were killing OAuth discovery for every external
MCP client (Claude Desktop, Cursor, etc.):

1. worker.ts injected PUBLIC_URL=http://<RUNNER_HOST>:<port> into the
   runner container even when MCP_DOMAIN was set. Result: the runner's
   /.well-known/oauth-protected-resource advertised an unreachable URL
   and the WWW-Authenticate header pointed at a non-HTTPS loopback
   address. Claude Desktop refused to follow the discovery chain.
   Now derives PUBLIC_URL from the same computePublicUrl() helper that
   builds the user-visible URL stored in mcp_servers.public_url, so the
   container's self-reported resource matches its actual route.

2. docker-compose.prod.yml never mounted /opt/buildmymcpserver/runner-map
   into the api / generator containers. The .conf snippet written by
   the generator landed in an ephemeral container path; the host
   inotify watcher saw an empty directory and produced an empty
   runner-map.combined. Result: nginx 404'd every /<slug>/* request,
   the runner was unreachable from the public domain, and OAuth
   discovery couldn't even begin. Mount added to both services.

Existing weather server has the wrong PUBLIC_URL baked in and must be
recreated after deploy. No customers yet.

export computePublicUrl from deploy.ts so worker.ts can call it.
2026-05-28 17:54:56 +02:00
Marco Sadjadi
3a05766f88 fix(oauth): allow generic RFC 7591 DCR + expand install snippets
All checks were successful
Deploy to Production / deploy (push) Successful in 1m28s
- /oauth/register: drop resource_required check, accept generic
  registrations (Claude Desktop omits resource in DCR body per spec).
  serverId stored as NULL; /authorize still enforces org-ownership
  + access-token aud claim still pinned to resource. Fixes Claude
  Desktop DCR failure (ofid_d7e39530c109fa7f).
- /oauth/authorize: skip strict server.id check when client.serverId
  is NULL (generic client); org check remains the security boundary.
- schema: oauth_clients.server_id no longer NOT NULL.
- migration 0002: ALTER COLUMN server_id DROP NOT NULL (already
  applied on prod).
- install-snippets: add Claude Code (CLI), VS Code, Codex, raw URL
  tabs. Claude Desktop now shows form-field values (Name / Remote MCP
  Server URL / OAuth Client ID / Secret) matching the new Custom
  Connector UI instead of the obsolete JSON config.
- types: InstallTarget enum extended.
- hero-video: clicking the audio toggle restarts the video from
  frame 0 so unmute aligns with the spoken opening.
- marketing: drop em-dashes from rendered copy.
2026-05-28 17:20:01 +02:00
Marco Sadjadi
e75f9ad4fe feat(marketing): real brand logos for the integrations grid
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Owner: "die logos müssen stimmen echte sein fetche sie." Replaced the
ASCII single-character marks (P / S / N / G / S / {}) with the actual
brand SVGs.

Sources:
- PostgreSQL, Notion, GitHub, Stripe paths from Simple Icons (CC0,
  https://simpleicons.org). Inlined as React components with
  fill="currentColor" so the icon colour is CSS-driven and matches
  whatever foreground the brand chip uses.
- Salesforce was deindexed from Simple Icons in 2022 at the brand's
  request, so I drew a clean generic cloud in the same silhouette
  family — close enough to read as Salesforce-cloud-shape without
  copying their trademarked mark.
- Custom REST gets a stylised pair of curly braces rendered as
  stroked paths, signalling "any HTTP API" without pretending to be
  a specific brand.

Brand colours used as chip backgrounds, all official values:
- PostgreSQL #336791  · Salesforce #00a1e0 · Notion #ffffff
- GitHub     #181717  · Stripe     #635bff · REST   #6366f1

Notion is the one inversion — its mark is rendered in #0a0a0b on a
white chip because that's how Notion's actual brand mark reads. The
others all render the icon in white on a brand-colour chip.

Use of the marks is nominative fair use — they show compatibility
with each platform, not endorsement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:54:04 +02:00
Marco Sadjadi
7a32385e2b feat(marketing): give each below-the-fold section its own visual archetype
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Owner: "die sektionen unter dem video sehen viel zu ähnlich aus — das
kannst du besser." Correct — every section was the same `panel + 3-col
grid` pattern, no page rhythm. Each section now reads as its own type
of moment:

- **Clients** ("Connects everywhere your AI lives"): typographic logo
  row, no panels. Each client carries a small mono mark in a 7×7 box
  (C, ⌘, ✦, <>, →) plus a 17px tracking-tight wordmark. Group hover
  flips the mark and label to the accent colour so the row reads as
  interactive trust signal, not a wall of text. Generous py-20/24
  spacing — this is a beat between sections, not a feature card.

- **Examples** ("Wrap any HTTP API. In minutes."): asymmetric 2-col
  header (h2 left, supporting copy right) over a 3-col card grid
  where each integration carries a coloured 48×48 brand mark —
  Postgres `#336791`, Salesforce `#00a1e0`, Notion black-on-white,
  GitHub `#181717`, Stripe `#635bff`, Custom REST `#6366f1`. The marks
  give each card its own visual identity, breaking the uniform-card
  pattern. h2 sized 32/40 px (was a flat 28 px).

- **Marketplace** ("Skip the prompt. Fork what works."): split layout.
  Left column: eyebrow + headline + supporting paragraph + bullet
  list of the three selling points (no longer equal-weight cards) +
  PulseLink CTA. Right column: new `MarketplaceMock` — a faux-browser
  frame containing four realistic template cards (notion-search /
  github-issues / stripe-readonly / linear-tasks) with author chips,
  ✓ verified badges, tool counts, and a fork glyph. Visitor SEES the
  marketplace instead of reading copy about it.

- **Pricing** ("Pay for tool calls. Not for boilerplate."): 4-card
  row but Pro is featured — indigo border, indigo glow shadow
  `0 0 0 4px rgba(99,102,241,0.12)`, "RECOMMENDED" pill floating at
  -top-3, and accent-coloured feature bullets. Other tiers stay
  calm so the eye lands on Pro first. Price typography enlarged from
  26 px to 40 px so prices read as the headline of each card.

Spacing rhythm: every section is now py-20/28 sm:py-24/28 (was
py-12-14 sm:py-16-20) — gives the below-the-fold the breathing room
it needed; the page no longer feels like a stack of crammed cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:36:06 +02:00
Marco Sadjadi
05746e13e6 fix(video): drop WebM source + load()-before-play() + open-in-tab fallback
All checks were successful
Deploy to Production / deploy (push) Successful in 59s
Owner: "wird nicht richtig gestream hab browser daten gelöscht aber kann
[nicht]" — clearing the cache didn't help. Three things changed:

1. **Single MP4 source.** Chrome listed the WebM source first because
   we offered it first; on the owner's setup the VP9 decode appears to
   stall silently and Chrome does NOT fall back to MP4 — it parks the
   element at networkState=2/readyState=0 forever. Removing the WebM
   source forces Chrome onto the MP4 (Main profile / yuv420p / TV-range
   / faststart, 2.6 MB) which we've already verified plays correctly.

2. **.load() before .play() in togglePlay.** When the original autoplay
   was blocked before the source ever fetched, some Chrome builds leave
   the element in a "stuck unloaded" state where subsequent .play()
   calls inside a user gesture also no-op. Calling .load() first resets
   the resource-selection algorithm, then .play() fetches and plays.

3. **playFailed escape hatch.** If .play() still rejects even after
   .load() + user gesture (extension sandbox, hardware decoder
   failure), surface a small "your browser blocked playback — open
   the video directly" link to the raw MP4. The visitor isn't trapped
   staring at a poster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:26:56 +02:00
Marco Sadjadi
b464b5640f feat(video): play-overlay for blocked autoplay + click-to-play
All checks were successful
Deploy to Production / deploy (push) Successful in 1m0s
Owner reported "video läuft nicht, sehe nur foto" — classic blocked-
autoplay on browsers with prefers-reduced-motion / data-saver / strict
autoplay policies. The poster sat there forever and the visitor
thought the page was broken because the only control was a tiny
mute pill they didn't realise would also start playback.

Fixes:
- Tracks `playing` state via the video element's own play/pause events
  so React knows whether the browser actually granted autoplay.
- Renders a large centre PLAY button overlay whenever the video is
  paused. The button covers the full frame (universal YouTube / Vimeo
  pattern: click anywhere on the video to play); the inner indigo
  circle with the triangle is the visual affordance, with hover scale
  for tactile feedback.
- Wires onClick directly on the <video> element too so the click-
  anywhere-to-play works whether or not the overlay happens to be up.
- Mute toggle now calls e.stopPropagation so tapping it doesn't
  accidentally trigger play/pause via the video's onClick handler.
- Best-effort .play() call in the mount effect, with the rejection
  silently swallowed — failure just means the user has to click play
  themselves, which the overlay already affords.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:21:04 +02:00
Marco Sadjadi
438ce3cfbc feat(video): v10 hero video with mute toggle — voice + bg music
All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s
Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.

## Video composition (remotion/)

Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:

- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
  excerpt, an oauth_callback.ts snippet, an .env file with a yellow
  squiggle warning ("in git history since v0.3.1"), and a live-ticking
  502 retry toast. Tangle now reads as a developer's desktop right
  before they give up, not as four icons drifting.

- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
  the parse beat: three sequential highlights on the prompt text
  (MCP server / searches / Notion workspace), three chips below the
  input (intent / tool / secret → vault), three-stat summary panel
  (tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
  global, on the voice line "the prompt path and the secret path
  never cross") a mini two-rail diagram with an explicit X-marker
  ring lands, visualising the architectural promise the moment it's
  spoken.

- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
  env-var injection beats; added the lock-snap flash at frame 66,
  pinned the vault at full opacity throughout, and added a dashed
  vault → container connector so the secret's provenance is visible.
  The "what the AI sees" panel is now 680 px wide with an eye icon,
  four corner viewfinder brackets around the prompt text, and three
  explicit denied lines (no secrets / no environment variables / no
  tokens).

- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
  card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
  container caption, <60s countdown.

- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
  chips that collided with the card and with the tokens-only badge
  are replaced by a clean vertical chip column at x=760: read-only
  filesystem · dropped capabilities · no new privileges · 512 MB
  memory cap · 0.5 CPU limit · ✓ your token only (last in green).
  A vault graphic now sits below the server card with a dashed arrow
  up into its env slot so the architecture story is complete in one
  frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
  gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
  the curious. Handshake stages simplified to your client → verified
  → scoped token. Final settlement arrow in success-green curves
  from the scoped-token pill back into the card.

- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
  with 36 px gaps. The "templates carry code, not credentials"
  sub-caption was pulled (felt on-the-nose; the detached lock and
  empty NOTION_API_KEY=? slot carry the story visually).

- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
  versions had a fake "1,200+ developers building" fork counter
  (pulled — solo-founder, hadn't earned). Replaced with a two-lane
  architecture diagram that visualises "no paths cross" literally:
  top lane prompt → AI → code, bottom lane vault → encrypted →
  env, both converging at the server box on the right. v10
  refinements: all seven boxes visible from frame 0 (no late
  server arrival), a parallel glow tour walks across both lanes
  simultaneously, a dashed vertical divider with a "no shared
  node" chip pinned in the middle, and the closing line "One
  sentence in. Live server out." slides down from above and lands
  centred while the diagram fades to 0.12 opacity behind it —
  no overlap.

- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
  loop seam.

The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.

Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.

## Web integration (apps/web/components/hero-video.tsx)

New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:31:10 +02:00
Marco Sadjadi
6197ee7f5e feat: particle cloud (no discrete dots) + geo-IP country preselect on login
All checks were successful
Deploy to Production / deploy (push) Successful in 1m1s
Two coordinated polish moves the owner asked for.

## 1. Hero particle field — "no white dots, just a glow that follows the mouse and is always in motion"

Previous tuning (uPointSize 2.8, uBaseAlpha 0.6) gave discrete indigo
dots that additively saturated to near-white in dense clusters. The
owner wanted no granular dots visible at all — a continuous indigo
cloud that the cursor pulls toward itself.

Changes:

- **Render fragment**: replaced the anti-aliased disc SDF
  (`smoothstep(0.5, 0.42, d)` — hard edge) with a Gaussian falloff
  (`exp(-d * d * 6.0)` — smooth blob, no edge). Each particle is now
  a soft volume that blends seamlessly with neighbours.

- **Sim fragment**: replaced the outward-gradient ring push with a
  mouse-halo attraction. Particles drift toward an ideal radius
  (~0.20) around the cursor, with exp-bell falloff so they don't
  collapse onto the cursor or feel influenced from across the canvas.
  `ringField()` helper is now unused but kept for future use.

- **JS uniforms**: `uPointSize` 2.8→14 (256-tier) / 3.6→20 (128-tier);
  `uBaseAlpha` 0.6→0.055. Individual particles are below the
  perception threshold for "dot" but 65k of them additively composite
  into a continuous cloud. With the much lower per-particle alpha,
  the cumulative brightness never saturates to white.

- **ParticleField tick loop**: asymmetric ring-active fade — `alpha
  = 0.14` ramping in (fast cursor response), `0.012` decaying out
  (slow glow trail after the pointer moves away). Matches the brief
  "glow longer + attractive to mouse but always in motion".

- **ParticleHero index.tsx**: added an always-on indigo radial
  gradient behind the WebGL canvas, so the hero never reads as
  visually empty between frames — the canvas additively paints the
  dynamic cloud on top. Removed the white-dot stipple from the
  static fallback (it was the most likely source of the "weisse
  punkte" complaint for any visitor on the fallback path).

## 2. SMS login — pre-select country picker from visitor's geo-IP

The country picker on `/login` previously defaulted to `'CH'` for
everyone. Visitors from DE / AT / US / etc. had to manually scroll
to their dial code — small friction but it sits on the highest-stakes
conversion step in the funnel.

- **New API route** `apps/api/src/routes/geo.ts` →
  `GET /v1/geo/country` returns `{ country: 'CH' | 'DE' | … | null }`
  by reading Cloudflare's `CF-IPCountry` header. Public, no auth —
  reading a 2-letter country code from a geo-IP header isn't PII
  under GDPR / DSG. `'XX'` and `'T1'` (CF's "unknown" + Tor) are
  normalised to `null`. Outside CF (dev), header is missing → null.

- **Login page** picks up the result in the existing `useEffect`,
  guards against codes not in our country list, and calls `setCountry`
  to override the `'CH'` default. Stays at `'CH'` if the detection
  fails or the visitor is on a Tor exit. Verified live: the endpoint
  returns `{"country":"DE"}` from CF's German edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:17:20 +02:00
Marco Sadjadi
035e55f00c feat(web): mobile-fit hero tiles + voluminous calmer particle field + FAQ accordion
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Three coordinated polish items requested:

1. **Hero step-rotator tiles fit mobile without horizontal scroll.**
   The previous snippets contained a 50+ char `Live at https://notion-x9.mcp.buildmymcpserver.com` URL that overflowed the ~295 px text area on a 375 px viewport. Rewrote all three snippets to be naturally short — same product story, no full URLs. The <pre> drops `overflow-x-auto` and gains `whitespace-pre-wrap break-words` so any token that does exceed the column wraps gracefully instead of forcing a scrollbar.

2. **ParticleHero — more volumetric, slower, steadier at load-in.**
   The "stuttery / too fast" feedback came from two issues compounding: tiny dots (1.8 px on 256-tier, with 0.42 base alpha) gave the eye too few pixels to track between frames, so individual particles read as snapping rather than drifting; and the simplex-noise drift evolved at 0.08 time-scale with 0.045 velocity, fast enough that frame-to-frame deltas exceeded a tracked particle's diameter.

   Render uniforms tuned:
   - `uPointSize` 1.8 → 2.8 (256-tier), 2.4 → 3.6 (128-tier)
   - `uBaseAlpha` 0.42 → 0.60

   Simulation shader tuned:
   - Drift noise time scale 0.08 → 0.045 (the most impactful single change — particles now move at half the previous speed)
   - Drift velocity magnitude 0.045 → 0.028
   - Ring breathing noise time scale 0.35 → 0.22
   - Ring polar-wave time scales 1.2 / 0.7 → 0.7 / 0.42

   Net effect: same number of particles (65k) but each individually larger, brighter, and moving more slowly. The cumulative additive bloom is denser without the jitter that read as visual stutter.

3. **FAQ collapsed into a native `<details>` accordion.**
   Crawlers and screen readers still see every Q+A in the SSR'd HTML — `<details><summary>...</summary><p>answer</p></details>` is the standard semantic pattern for disclosure widgets. Users see one question at a time and expand on demand, which keeps the page from feeling like an endless wall of marketing text below the fold.

   Container narrowed `max-w-6xl` → `max-w-3xl` for accordion typography (long-form prose reads better single-column). The default WebKit disclosure-triangle marker is suppressed with `list-none` + `[&_summary::-webkit-details-marker]:hidden`, and a `lucide-react` `ChevronDown` icon rotates 180° via `group-open:rotate-180` to indicate state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:35:03 +02:00
Marco Sadjadi
6f8b8da151 feat(web): glow-pulse on primary CTAs + hero fills full first viewport
All checks were successful
Deploy to Production / deploy (push) Successful in 1m1s
Two coordinated polish moves:

1. **<PulseLink> / <PulseButton>** — new `apps/web/components/pulse.tsx`.
   Click anywhere on a wrapped link or button and a small indigo dot
   detonates from the click point, scaling 1x→80x over 650ms before
   fading to transparent. Same visual language as the hero load-in
   glow — the click effectively says "this is the brand reaching back."

   The dot lives in a `pointer-events: none` overlay, so it never
   blocks the underlying navigation. `overflow-hidden + relative` are
   added to the host so the bloom stays inside the rounded shape.
   `glow-pulse` keyframe sits in globals.css next to the existing
   `pulse-dot` / `shimmer` / `fade-in` definitions; reduced-motion
   suppresses the animation to instant-opacity-0 so the click flow
   is preserved without the bloom.

   Wired into the highest-conversion CTAs only — the user explicitly
   asked "wo's Sinn macht":
   - Hero "Start building free" + "Read the docs"
   - Marketing header Login / Dashboard button
   - Dashboard header "+ New server" pill

   Deliberately NOT applied to dashboard nav links, logout, destructive
   buttons, form internals, carousel dots — pulse on every click would
   be noise.

2. **Hero fills 100svh − nav** (`min-height: calc(100svh - 3rem)`).
   `svh` (small viewport height) instead of `vh` so the hero doesn't
   jump when the mobile address bar hides/shows. The 3rem subtracts
   the sticky marketing nav (h-12 = 48px), so the hero ends right at
   the loadscreen's natural bottom edge.

   `flex items-center` plus the inner grid's existing `md:items-center`
   keep the content vertically centred inside the tall section. The
   ParticleHero background now has cinematic-scale room and the indigo
   radial-glow + dot-mask read as the dominant background motif —
   which is the effect the user loved at load-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:20:25 +02:00
Marco Sadjadi
0cf9c66b6b feat(web): restore tall hero + carousel slide + viewport-fixed scroll cue
All checks were successful
Deploy to Production / deploy (push) Successful in 1m0s
Three coordinated tweaks to the landing-page above-the-fold:

1. **Hero padding restored to py-14/sm:py-20/md:py-28** (was py-12/14/16).
   Compressing it for the scroll-cue position fight made the hero feel
   cramped and gave the ParticleHero background less room to breathe.
   With the cue moved out (see #3), there's no reason to shrink the hero.

2. **Step rotator switches to carousel-style horizontal slide.** The
   AnimatePresence transition was a fade+y-shift cross-fade — clean but
   sequential. Now the leaving card slides left out (x:-220) while the
   entering card slides right in (x:220→0), both coexisting in the same
   3D-space and inheriting the same mouse-tilt. The container gets
   `min-h-[240px]` so the absolutely-positioned cards have layout to
   anchor to (claude_desktop_config.json is the tallest at 7 lines).
   Reduced-motion still gets the opacity-only cross-fade — sliding
   content sideways is exactly the kind of motion that preference is
   meant to suppress.

3. **`<ScrollCue>` extracted into its own client component**, fixed-
   positioned at viewport bottom (bottom-5) with a frosted pill style.
   Fades to opacity:0 once `window.scrollY > 80`, so it doesn't shadow
   the rest of the page. Lives next to `<section>` in page.tsx rather
   than inside the hero — that way it anchors to the loadscreen's
   natural bottom edge whether the hero is short or tall.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:11:42 +02:00
Marco Sadjadi
e4e437c44c feat(web): hero redesign — cycling step rotator + full-width video section
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Restructures the landing page above-the-fold into two distinct sections:

1. **Hero — left copy + cycling tile, no static stack of three blocks**
   New `<HeroStepRotator>` (Framer Motion client component) shows ONE
   tile centred in the column, cycling prompt.txt → build.log →
   claude_desktop_config.json every 3.5s. Auto-advance pauses on hover
   and exposes a 3-dot tablist so users can jump to any step. The active
   dot grows wide with an accent glow.

   Mouse interaction: spring-smoothed 3D tilt on rotateX/rotateY plus a
   radial glow that translates toward the cursor — both driven by motion
   values, so the transforms stay on the GPU compositor instead of
   re-rendering on every mousemove. `useReducedMotion()` strips the
   tilt + glow translation and collapses the page transition to an
   instant cross-fade (the rotation itself still advances — it's content,
   not decoration).

   Hero padding tightened (py-12/14/16 vs py-14/20/28) so the video
   section below is teased above the fold. New scroll cue ("see it run"
   + animated chevron) sits at the bottom of the hero, anchored to
   #flow.

2. **Flow video — full-width edge-to-edge under the hero (new section)**
   The hero.mp4 / hero.webm pair moves out of the "How it works"
   section into its own #flow section. No max-w wrapper — it spans the
   viewport with `w-full aspect-video`, so on a 1080p monitor the video
   gets the full 1920px width. Adds a subtle radial vignette so the
   black edges blend into the page chrome.

3. **"How it works" — now lean**
   Video removed (it's the flow section now). Just the three textual
   cards as supporting copy.

Adds `framer-motion@11.18.2` to apps/web/package.json. Build passes
typecheck + Next.js production build with no new warnings; LCP path is
untouched since the rotator is client-hydrated after first paint and
Framer Motion is tree-shaken to the components we import.

Note: visitors with `prefers-reduced-motion: reduce` will still see the
video's poster instead of autoplay — Chrome blocks the network fetch
entirely for autoplay media when reduced-motion is set. The flow video
remains visible for the rest, and the step rotator continues to cycle
its content (with instant cross-fade instead of slide+scale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:05:28 +02:00
Marco Sadjadi
22ba23f353 fix(video): make Beat 2 visible — bigger particles, parallel schematic stroke
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
User report: "I only see 'Search our Notion workspace' — no video."
Cause: Beat 2 (frames 55-165) was a near-empty dead moment. Particles
were 1.5-2.5px on a 1080p canvas (nearly invisible), and the server
schematic didn't start drawing until local frame 30 (= global 85),
leaving a 30-frame gap of empty space mid-clip. The viewer's brain
correctly registered "the video stops after Beat 1."

Fixes:
- 60 particles (was 36) at radius 6→3 with SVG Gaussian-blur glow
  filter, always indigo (was an indecisive two-color split).
- Schematic stroke starts at local frame 8 (was 30) so the box draws
  IN PARALLEL with particle convergence — eye always has something
  to track.
- Central radial-glow attractor visible the whole beat — gives the
  "something is forming here" cue before the schematic appears.
- Server schematic enlarged 460×300 → 720×420 so it commands
  attention rather than feeling small.
- Inner tool-row dots and port dots doubled in size with stronger
  drop-shadow.
- Beat 3 schematic + client panel sizes scaled to match, and the
  wire base position adjusted (server CX moved from 960 to 760 so
  the wire has room to breathe before reaching the client).
- Poster frame moved from 60 (mid-fade dead spot) to 180 (Beat 3
  Connection layout — the most "this is a real product" shot).

File sizes still well under budget: 514 KB mp4, 319 KB webm, 29 KB poster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:06:26 +02:00
Marco Sadjadi
fd147f9998 feat(web): Remotion hero video — Section 2 (prompt → server → connect)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m13s
New @bmm/video workspace at remotion/. Renders an 8s 1920×1080 H.264
+ WebM + JPG poster sequence that visualises the three-step "How it
works" pitch literally:

- Beat 1 (0-2s): "Search our Notion workspace" word-by-word entrance
  with spring-in from below + brief indigo under-glow + monospace
  prompt.txt label. Blinking cursor bridges the loop seam.
- Beat 2 (2-5s): each prompt word detonates into ~9 particles per
  word; particles drift, then magnetically converge onto target slots
  along a server schematic that strokes itself on. Scan-line sweep +
  corner labels (mcp-notion, OAuth 2.1, search_pages, get_page_content)
  sell that this is a real artefact, not a placeholder.
- Beat 3 (5-8s): Claude Desktop client panel slides in from the right;
  a Bézier wire animates between server and client; three data-packet
  dots travel along the wire; 200-OK tag pops; green live-dot pulses
  on the server. Last 12 frames fade to black so frame 239 ≈ frame 0
  and browser <video loop> has no visible seam.

Brand palette is hard-coded in lib/colors.ts to match globals.css —
keeps the Remotion bundle self-contained (no Tailwind import needed).
springIn / softSpring / clampLerp / rand helpers in lib/easings.ts
power the motion vocabulary. Concurrency=1 + yuv420p in the config
gives a deterministic render that plays on every <video> tag.

File sizes: hero.mp4 449 KB, hero.webm 258 KB, hero-poster.jpg 33 KB —
all well under the 3 MB / 250 KB ceilings.

Section 2 ("How it works") now opens with the video in a
border-bordered aspect-video panel between the heading and the three
existing cards. autoPlay+muted+loop+playsInline satisfies every mobile
autoplay policy; motion-reduce:hidden swaps in the static poster for
prefers-reduced-motion users.

Scripts:
- pnpm --filter @bmm/video render:all  (mp4 + webm + poster)
- pnpm --filter @bmm/video to-web      (copy to apps/web/public/videos/)
- pnpm --filter @bmm/video build       (both, end-to-end)

`to-web` is the script name because `publish` collides with pnpm's
built-in npm-publish command which refused to run with an unclean tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:57:08 +02:00
Marco Sadjadi
591a1cb575 ops: backup hardening + restore drill + self-hosted uptime monitor
All checks were successful
Deploy to Production / deploy (push) Successful in 1m10s
Adds /opt/bmm-ops/ scripts (deployed separately from the app, so tar
overlays don't clobber them) for three previously-missing production
readiness items:

1. Backup hardening (backup.sh):
   - Previous cron one-liner did pg_dump | gzip with no validation.
   - Now: pipefail-safe pg_dump, gunzip -t integrity check, pg_dump
     header sanity (scans first 5 lines — line 1 is just "--", actual
     "PostgreSQL database dump" comment lands on line 2), size-warning
     under 1KB, atomic move-into-place so partial backups never replace
     the previous good file. 14-day retention preserved.
   - Optional offsite via BMM_BACKUP_REMOTE (rclone). Reads env via
     grep+cut, NOT `source` — the .env.production has unquoted text
     values (e.g. ADMIN_NAME) that crash a sourced shell.

2. Restore drill (restore-test.sh, Sun 04:30 UTC weekly):
   - Restores the newest backup into a throwaway DB inside the same
     Postgres container, verifies the core tables exist (users,
     sessions, oauth_tokens, mcp_servers), drops the temp DB. Proves
     backups are actually restorable, not just byte-streams that look
     like backups. Silent-corruption detector.

3. Self-hosted uptime monitor (uptime-check.sh, every 5 min):
   - Probes homepage + /api/health + /robots.txt.
   - Edge-triggered alerting: SMS via Twilio only on up→down and
     down→up transitions (avoids SMS storm during sustained outages).
   - Pings HEALTHCHECKS_HEARTBEAT_URL on every success — when the box
     itself dies the heartbeat stops and the external watchdog alerts
     (covers the gap that self-hosted monitors can't see their own
     box failing).

notify.sh is the shared helper: Twilio SMS if all four creds set,
optional webhook to HEALTHCHECKS_FAIL_URL, always logs to syslog. Never
fails loudly — broken notification path still lands in journalctl
-t bmm-ops.

README.md documents the 3-2-1 strategy, manual full-recovery
procedure, and how to enable offsite (R2 / B2 / Hetzner Storage Box).

Smoke-tested all three on prod: backup wrote 8004 bytes with checks
passing, restore-test confirmed schema, uptime probe returned up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:46:42 +02:00
Marco Sadjadi
2267daadd4 perf(web): server-only StaticCodeBlock for above-the-fold marketing
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
PageSpeed Insights mobile reported LCP element render delay of 2.3s
on the hero — the largest visible element is the build.log <pre> with
"> Generating spec... OK ..." text. TTFB is 0ms (CF cache hit), so the
delay was pure client-side: Lighthouse waited for the JS bundle to
parse and the 'use client' CodeBlock boundary to hydrate before it
considered the element "rendered."

CodeBlock pulls in lucide-react (Copy/Check icons) plus a useState
boundary just for the copy button. Above the fold on marketing, none
of that is needed — the user just needs to see the snippet.

Split:
- New `static-code-block.tsx`: server component, no 'use client',
  no icons, no copy button. Pure SSR markup that paints with the HTML.
- Marketing landing now uses StaticCodeBlock for all three hero
  snippets (prompt.txt / build.log / claude_desktop_config.json).
- Interactive CodeBlock stays in use for dashboard pages where users
  actually want to copy snippets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:30:41 +02:00
Marco Sadjadi
9f1135325c feat(web): drop 'newest' sort + width-cap categories on /templates
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
Two narrow fixes for mobile chip-row width:
- Removed the 'newest' sort button. Trending and Top cover the use
  cases; newest was largely redundant with Top sorted on createdAt.
- Capped the categories <select> at 140px (160px on sm+). Long
  category names were stretching the box and pushing the
  horizontally-scrollable chip row beyond a sane width on phones.
  Native <select> truncates the visible label with ellipsis; the
  dropdown panel still shows full names when opened.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:27:57 +02:00
Marco Sadjadi
00c6692c7a feat(web): mobile-responsive /templates + drop pre-launch SiteBanner
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
Two related polish items:

1. Remove the global blue Preview banner from app/layout.tsx and delete
   the SiteBanner component. The component's own comment said "Remove
   once the service is open for production use" — Stripe live billing,
   OAuth, and per-runner TLS are all wired now, so the pre-launch notice
   is misleading.

2. Mobile-responsive treatment for the standalone /templates page (it
   lives outside (dashboard) layout, so it didn't inherit the new
   mobile chrome from the dashboard pass):
   - Top header tightened: "/templates" breadcrumb + Dashboard link +
     "+ New server" pill all hidden on mobile (the avatar UserMenu +
     bottom MobileActionBar cover those paths).
   - Logged-in users now get the same MobileActionBar tab-bar at the
     bottom (Market tab active), giving consistent app-shell across
     dashboard pages.
   - Filter row stacks vertically on mobile with search on top (thumb
     reach), then a horizontally-scrollable chip row for scope / sort /
     category so segmented controls don't squeeze below their min-width.
   - h1 scales 32px → 24px on mobile; padding tightened to px-4 py-8.
   - main gets pb-24 when logged in so cards clear the tab bar.

Logged-out marketplace browsing keeps the simpler marketing chrome
(Logo + "Start building" CTA) — no tab-bar, since visitors don't have
a dashboard to navigate into yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 06:43:56 +02:00
Marco Sadjadi
f80bd8afbe feat(web): app-like mobile dashboard — bottom tab bar, minimal top
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
Top header on mobile was cramped: Logo + 5 icon-only nav buttons + avatar
crammed into a 48px-tall row. Felt like a desktop nav shrunk down.

Pivot to native-mobile-app pattern:
- Top mobile: just Logo (left) + UserMenu avatar (right). Desktop top nav
  is `hidden sm:flex` so it disappears on phones.
- Bottom: full tab bar replacing the single-button MobileActionBar.
  Five destinations: Overview · Servers · Create (FAB-style center) ·
  Market · Settings.
- "Create" is a raised FAB-style button (round accent fill, -mt-3 to
  overlap the bar border) — same prominent-action pattern as Instagram /
  Notion mobile.
- Active tab gets accent color + aria-current=page.
- Audit demoted from primary nav on mobile (low frequency); still
  reachable via direct /audit URL.

Desktop unchanged — top nav stays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:15:44 +02:00
Marco Sadjadi
a8e6f4fabd fix(web): UserMenu + CountryPicker dropdowns frosted (Tailwind v4 bug)
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
Same Tailwind-v4 bracket-arbitrary issue we hit on the marketing burger
menu: bg-[--color-bg-elevated] compiles to `background-color:
--color-bg-elevated` (no var() wrap → invalid color → transparent).
Both dropdowns were rendering see-through against the dashboard.

Switch both to the proven pattern: backdrop-blur-md class + inline
style for backgroundColor + borderColor using color-mix() and explicit
var(). 88% elevated-panel fill gives a clear frosted-glass look while
keeping the menu items readable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:04:02 +02:00
Marco Sadjadi
c656bd3189 fix(web): UserMenu crashes for phone-only signups (null email + name)
All checks were successful
Deploy to Production / deploy (push) Successful in 56s
Dashboard layout threw TypeError: Cannot read properties of null (reading
'charAt') the moment a phone-only user reached any dashboard page —
user.email and user.name are both null for fresh SMS signups, and
the initial-letter computation didn't tolerate it.

Fallback chain for the visible identifier: name → email → phone →
'Account'. Avatar colour seed falls back to userId. The secondary line
under the name also uses phone when email is null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:59:45 +02:00
Marco Sadjadi
d0f3c202eb fix(tls): pivot per-runner TLS to path-routing on single subdomain
All checks were successful
Deploy to Production / deploy (push) Successful in 54s
The per-subdomain approach (*.mcp.buildmymcpserver.com) failed at the
Cloudflare edge — Universal SSL only covers ONE-level wildcards, so the
TLS handshake on slug.mcp.buildmymcpserver.com hits SSL alert 40
handshake_failure. The two paths to fix that (CF Advanced Cert Manager
at $10/mo, or a Let's-Encrypt wildcard via DNS-01 with certbot) both
trade either money or ops for the URL aesthetic.

Pivot to path-routing on the single subdomain mcp.buildmymcpserver.com,
which IS covered by free Universal SSL. publicUrl format changes from
  https://<slug>.mcp.buildmymcpserver.com  →  https://mcp.buildmymcpserver.com/<slug>
No recurring cost, works with the existing CF setup, MCP clients don't
care about the URL shape (it comes from the wizard's install snippet).

Code changes:
- generator/lib/deploy.ts:
    * publicUrl computed as `${MCP_DOMAIN}/${slug}` instead of `${slug}.${MCP_DOMAIN}`
    * writeRunnerMapEntry writes one-line nginx snippet:
        if ($bmm_slug = "<slug>") { set $bmm_port <port>; }
      (was: a map-entry pair "<slug>.<MCP_DOMAIN> <port>;")
- setup-runner-tls.sh:
    * nginx vhost is now single server_name mcp.buildmymcpserver.com
    * regex location captures (?<bmm_slug>...)(?<bmm_path>/.*)?
    * includes runner-map.combined inside the location block so the
      generated if-snippets set $bmm_port; unknown slug → 404
    * proxy_pass strips the slug prefix: /<slug>/foo → 127.0.0.1:port/foo
    * Prereq docs updated: just A-record for mcp (no wildcard needed),
      same Origin CA cert reused
    * Added /health endpoint at vhost root for monitoring

Systemd watcher + map dir + volume mounts unchanged — same file paths,
just different snippet content. Re-running setup-runner-tls.sh on the
host overwrites the wildcard vhost with the new path-based one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:51:30 +02:00
Marco Sadjadi
8c6f04f034 feat: oauth refresh-token grant + per-runner subdomain TLS plumbing
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
OAUTH REFRESH-TOKEN
- oauth_tokens.subject column added (migration applied to prod DB): stores
  the JWT sub claim from the original authorization so refreshes can
  re-mint with the same identity without re-walking the (consumed) code.
- Authorization-code branch now writes subject AND uses a 30-day
  expires_at for the row (was 1h — same as access token, which killed
  refresh after 1h).
- New refresh_token grant branch:
    * looks up token by refresh-hash + expiry
    * client_id must match, client_secret verified if confidential
    * RFC 8707: requested resource must equal stored resource
    * OAuth 2.1 rotation: atomic UPDATE WHERE old_hash → new access JWT,
      new refresh token, extended expiry; loser of a race sees invalid_grant
- Access TTL (1h) and refresh TTL (30d) extracted as constants.

Clients no longer have to re-authorize hourly. Closes Zb-001.

PER-RUNNER SUBDOMAIN TLS (Z1-002)
Code path:
- New MCP_DOMAIN env (e.g. "mcp.buildmymcpserver.com") + RUNNER_MAP_DIR
  (default /var/runner-map) in generator config.
- deployContainer: writes /var/runner-map/<slug>.conf with content
  "slug.MCP_DOMAIN port;" and computes publicUrl as
  https://<slug>.<MCP_DOMAIN>. Falls back to http://host:port when
  MCP_DOMAIN is unset (zero behaviour change until host is configured).
- stopContainer (both api/lib/docker.ts and generator/lib/deploy.ts) now
  accepts an optional slug arg and removes the map fragment. Callers
  (DELETE /v1/servers/:id, admin template takedown) updated.

Infra path (one-time host setup — Marco runs as root):
- scripts/setup-runner-tls.sh:
    1. nginx vhost matching *.mcp.buildmymcpserver.com via regex →
       reads slug→port from /opt/buildmymcpserver/runner-map.combined
    2. systemd inotify service watches the map dir, combines fragments
       on any change, reloads nginx
    3. installs inotify-tools if missing, idempotent
- Prereqs documented at top: Cloudflare wildcard DNS proxied, Origin CA
  cert for *.mcp.buildmymcpserver.com, SSL mode Full (strict).
- After running: edit docker-compose.prod.yml to mount the map dir into
  api + generator, set MCP_DOMAIN in env, recreate containers.

Closes Zb-001 fully. Closes Z1-002 on the code side; one Marco-on-host
action away from closing it on the infra side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:09:06 +02:00
Marco Sadjadi
e9827b1f77 feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
All checks were successful
Deploy to Production / deploy (push) Successful in 51s
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.

COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.

Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
Marco Sadjadi
1cccdbdff1 fix(auth): logout actually clears the session cookie in Chrome
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
The clearCookie call on /v1/auth/logout was passing only {path:'/'},
missing the httpOnly + sameSite + secure flags the setCookie used. In
production (secure=true), Chrome treats a Set-Cookie clear directive
without Secure as a *different* cookie — it creates an empty insecure
cookie and leaves the original Secure session cookie in place. Result:
users who clicked "Sign out" stayed logged in for the full 30-day
session lifetime in the browser's view (DB session was destroyed
correctly; only the cookie persisted).

Now both setCookie and clearCookie pull from sessionCookieOpts() so
the attributes can't drift apart again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:14:12 +02:00
Marco Sadjadi
091454d273 fix(web): single Login/Dashboard button on marketing header
All checks were successful
Deploy to Production / deploy (push) Successful in 51s
Logged-out state was showing two CTAs ("Sign in" link + "Start building"
button) both going to /login — confusing because the prominent purple
button never literally said "Login". Consolidate to one button whose
label flips with auth state: "Login" when out, "Dashboard" when in.
Same slot, same colour, no header layout shift.

Defaults to "Login" while the /v1/auth/me probe is in flight so the
common (anonymous) visitor sees no flicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:30:27 +02:00
Marco Sadjadi
b248adf5c0 feat(auth): email login soft-disabled until SMTP/Resend is wired
All checks were successful
Deploy to Production / deploy (push) Successful in 54s
Closes the dependency on an unbuilt email sender. New EMAIL_AUTH_ENABLED
env flag (default false). When off:

- POST /v1/auth/magic-link  → 503 email_auth_disabled
- POST /v1/auth/verify       → 503 email_auth_disabled
- GET  /v1/auth/providers    → { email: false, sms, google, github }
- Login page: hides the email/phone tab toggle (only one method),
  hides the email form entirely, defaults to SMS/phone tab

Flipping EMAIL_AUTH_ENABLED=true re-enables the magic-link routes and
re-shows the email form section. Schema (magic_links table) unchanged
so this is a 1-env-flip re-enable, not a re-implementation.

SECURITY: closes audit finding Za-001 (account-takeover via
cross-provider email lookup). Without a magic-link flow, an attacker
who controls a target's inbox can no longer claim an existing
OAuth-created account. The remaining provider-mixing surface (Google
↔ GitHub at same email) requires controlling the OAuth provider
account itself, which is each provider's own security boundary.

Active login methods now: Google OAuth · GitHub OAuth · SMS code
(Twilio) · admin password (seeded, single user).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:51:57 +02:00
Marco Sadjadi
aa79a71357 security: sovereign-audit Pass-2 fixes — auth-lib, oauth, templates
All checks were successful
Deploy to Production / deploy (push) Successful in 54s
Six confirmed findings closed (3 MEDIUM, 3 LOW). Tier-1 surfaces from
Pass-1 re-verified non-regressed; this pass deepened the audit on the
auth library, OAuth issuer, and template marketplace.

Za-002 MEDIUM (scrypt cost) — bump SCRYPT_N from 2^14 → 2^17 (131072)
  matching current OWASP guidance for password hashing in 2026. Hash
  format embeds N (`scrypt$N$salt$hash`), so the existing admin
  password at the old cost still verifies — backward-compatible. Also
  added explicit maxmem ceilings since Node's default (~32MiB) is
  insufficient for the new N.

Za-003 MEDIUM (single-use race) — consumeMagicLink was SELECT-then-
  UPDATE; two parallel redemptions could both win and mint two
  sessions from the same token. Now uses the same atomic
  `UPDATE … WHERE id = ? AND consumedAt IS NULL RETURNING id` pattern
  /oauth/token already had — loser of the race gets
  invalid_or_expired_token.

Za-004 LOW (membership ordering) — `.orderBy(memberships.createdAt)`
  added so when org-invites eventually let a user belong to multiple
  orgs, the same one wins every login instead of insertion-order
  roulette. Latent-bug pre-empt.

Zb-002 LOW (OAuth register spam) — /oauth/register now per-IP daily
  rate-limited at 20/day (well above any legitimate MCP-client
  bootstrap pattern). Prevents DB-row spam.

Zc-001 MEDIUM (banned-pattern drift) — three separate copies of
  BANNED_PATTERNS had drifted apart. The publish-time scanner in
  templates.ts was MISSING the 7 new patterns added in Pass-1
  (process.binding, dlopen, .constructor.constructor, vm.runIn*,
  globalThis['..']). Single source of truth in @bmm/llm now exports
  SHARED_BANNED_PATTERNS; templates.ts composes PUBLISH_BANNED_PATTERNS
  = SHARED ∪ code-only-extras (dynamic import, fs.rm, setTimeout-with-
  string, process.kill, jailbreak markers).

Zc-002 LOW (N+1) — /v1/templates list was issuing one COUNT(*) per
  template (101 queries for a 100-row page). Now one grouped query
  with templateId GROUP BY, merged in JS. p95 doesn't degrade with
  marketplace growth.

DEFERRED (documented, scoped for next sprint):
  Za-001 HIGH — Account takeover via cross-provider email lookup.
    Requires schema change (users.primaryProvider). Mitigation in
    /settings/account banner planned.
  Zb-001 MEDIUM — /oauth/token refresh_token grant: advertised in
    AS metadata but unsupported_grant_type. Either implement (~40
    LOC) or strip from metadata.
  Zc-003 LOW — Admin takedown partial-failure consistency.
  Zd-001 IMPROVE — DEK cache invalidation across replicas (single-
    instance today).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:15:54 +02:00
Marco Sadjadi
f8af3fc0fd security: sovereign-audit Phase 2 fixes — trustProxy, Docker hardening, banned-pattern overhaul
All checks were successful
Deploy to Production / deploy (push) Successful in 55s
Five confirmed findings from the sovereign-audit pass, ordered by severity:

Z3-001 CRITICAL — Fastify now trustProxy:true so req.ip resolves to the
real visitor IP via X-Forwarded-For instead of always being the nginx /
docker-bridge peer. Every per-IP rate-limit in the codebase was silently
collapsed into one global counter; this restores them.

Z1-001 CRITICAL — runner container hardening flags (--read-only,
--cap-drop=ALL, --security-opt=no-new-privileges:true, --pids-limit=100,
--memory=512m, --cpus=0.5, tmpfs /tmp) were sitting commented-out as a
TODO despite /security promising them. Now applied unconditionally on
production/staging; opt-out flag RUNNER_DISABLE_HARDENING=1 for Win-dev.

Z2-001 + Z2-002 CRITICAL / MEDIUM — banned-pattern blacklist tightened
(Function(...) without `new`, process.binding, process.dlopen,
.constructor.constructor, _load, vm.runIn*Context, globalThis['..'],
"system prompt override"). scanForInjection now also walks tool.name and
every inputSchema property description, not only implementation +
description — closes the prompt-injection-into-AI-client surface that
downstream clients (Claude Desktop, Cursor) read verbatim. The duplicate
BANNED_PATTERNS in apps/api/src/routes/servers.ts deleted in favour of
the single shared scanForInjection export from @bmm/llm.

Z4-001 HIGH — /v1/auth/magic-link gained the two-axis daily rate-limit
the SMS endpoint already had: 10/IP/day + 5/email/day. Combined with the
trustProxy fix above these are now real per-visitor limits.

Z4-002 MEDIUM — magic-link callback URL no longer printed to stdout in
production. In dev it still prints (so devs can click the link); in
production we log only "issued, URL withheld" and a loud error if no
email sender is wired (Resend integration is the actual launch
blocker — left as a TODO).

Z6-001 MEDIUM — /v1/builds/:id/stream WebSocket now refuses cross-origin
upgrades. SameSite=Lax already mitigates in modern browsers; this is the
defense-in-depth against browser bugs and non-browser clients.

FALSE POSITIVES dismissed: slug path-traversal (schema regex
^[a-z][a-z0-9-]*$ in @bmm/types catches it); session-after-promote
(getSession re-fetches isAdmin from DB on every request).

DEFERRED (not blockers, tracked):
- Z1-002 generated-server HTTPS — needs nginx wildcard subdomain TLS
- Z1-003 docker image cleanup cron
- Z2-001 v2 — real sandbox runtime (multi-week refactor)
- Z3-002 rawBody-per-request memory — branch on webhook path only
- Z5-001 multi-user org RBAC for billing — gated on Team feature
- Email sender integration (Resend) — launch blocker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:02:59 +02:00
Marco Sadjadi
1c58977596 feat: user menu + profile page + in-app subscription management
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
User-facing identity:
- UserMenu component in dashboard header: avatar (deterministic colour from
  email hash), email + name, current plan badge, dropdown to Profile /
  Billing / Support / Your data / (Admin panel if isAdmin) / Sign out
- /settings/profile: editable display name; email + phone shown read-only
  (changing them requires support ticket — magic-link flow assumed)
- GET + PATCH /v1/account/profile

In-app subscription management (no more Stripe Portal redirect for the
common flows — cancellation, plan switch, invoice viewing all in-app):
- Billing status now combines DB state with a live Stripe lookup of the
  subscription details + last 5 invoices. Single roundtrip.
- POST /v1/billing/cancel       → schedules cancel_at_period_end
- POST /v1/billing/reactivate   → undo scheduled cancel
- POST /v1/billing/change-plan  → prorated swap between any tier+cycle
- /settings/billing rewritten: current plan card with renew/cancel date,
  big cancel button + reactivate flow, plan-switcher grid, invoice list with
  PDF + hosted-invoice links
- Stripe portal still linked at the bottom as the escape hatch for rare
  actions (payment-method update, address change). New-subscription Checkout
  still uses Stripe-hosted Checkout (industry standard for PCI).

Stripe SDK v22 / API 2024-09 fix: current_period_end moved to subscription
items; updated read paths accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:46:36 +02:00
Marco Sadjadi
1b8f61df5f fix(admin): make whole support-ticket row clickable
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
Table-cell Link only wrapped the subject text — clicks on email/status/time
cells did nothing, which read as 'cannot open ticket' for the admin. Convert
to a flex-grid Link wrapping the entire row, same pattern as the user-side
/settings/support list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:36:31 +02:00
Marco Sadjadi
20910f5466 fix(admin): Support entry in sidebar + awaiting-admin badge
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
The /admin/support page existed but was invisible from the panel — sidebar
NAV array didn't list it. Adds Support as the 2nd nav item (right after
Overview, since unanswered tickets are the most-time-sensitive thing an
admin checks). Sidebar polls /v1/admin/support/counts every 30s and renders
an amber count badge next to the entry when tickets are awaiting_admin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:23:33 +02:00
Marco Sadjadi
ef30baf52a feat: Swiss-compliant launch — Impressum/AGB/Contact, support panel, DSG exports, cookie banner
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
Legal (Swiss minimum, no individual named):
- Impressum page (UWG Art. 3 lit. s) — provider, contact via support panel,
  no email required, jurisdiction = Switzerland
- AGB page — subscription terms, payment, cancellation, suspension on payment
  fail, 14-day money-back, AI-processing-per-tier disclosure, Swiss law +
  Swiss venue, modeled after typical Schweizer SaaS terms
- Privacy: Stripe added as subprocessor with full data-flow disclosure

Support panel replaces email contact entirely:
- @bmm/db: support_status enum + support_tickets + support_messages tables,
  migration applied to prod DB
- @bmm/api: support routes (user create/list/view/reply, admin list/view/reply
  /set-status), public /v1/contact for logged-out visitors with per-IP rate
  limit of 3 submissions/day to prevent spam-flood
- Web: /settings/support (list + new), /settings/support/[id] (conversation),
  /admin/support, /admin/support/[id]
- Public /contact form with email collection for guest tickets

Data rights (DSG Art. 25 / GDPR Art. 15+20):
- /v1/account/export returns user-scoped JSON of profile, org, servers,
  builds, audit, support tickets and messages — excludes hashes, encrypted
  secrets, other-user data
- /settings/account: download button + deletion-via-ticket workflow

Production-readiness gaps closed:
- org.suspended now blocks /v1/servers POST and /v1/servers/preview (402);
  webhook flagged this state but enforcement was missing
- Cookie banner: minimal, essential-cookies-only disclosure (Swiss DSG +
  GDPR compliant without dark-pattern consent UI), mounts on both layouts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:12:06 +02:00
Marco Sadjadi
c2a21fc3cd feat(billing): Stripe Checkout + Customer Portal + signed webhook
Some checks failed
Deploy to Production / deploy (push) Failing after 46s
- @bmm/api: stripe@22 SDK, plan-aware price-id lookup, Redis-backed event
  idempotency (7d TTL covers Stripe's retry window), startup warning when
  STRIPE_PRICE_* env vars contain product ids (prod_) by mistake
- routes/billing.ts:
    POST /v1/billing/checkout-session  → Stripe-hosted Checkout, SEPA+card,
                                          auto-VAT via Stripe Tax, tax_id
                                          collection for B2B, address required
    POST /v1/billing/portal            → Customer Portal session
    GET  /v1/billing/status            → drives the settings/billing UI
    POST /v1/billing/webhook           → signed, idempotent, handles
                                          checkout.session.completed,
                                          subscription.{created,updated,deleted},
                                          invoice.{paid,payment_failed}
- index.ts: rawBody-aware JSON parser so Stripe signature verify gets the
  exact payload bytes
- web: /settings/billing page (status, upgrade flow, manage-billing portal,
  auto-checkout when arriving with ?tier=… from the pricing CTAs), pricing
  page CTAs point to /settings/billing?tier=…
- Payment-failure path: suspend org only after 3rd failed attempt (Stripe
  Smart Retries handles the soft-retries). Suspended orgs keep their running
  servers but cannot create new ones (enforcement is in /v1/servers POST as
  a follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:30:42 +02:00
Marco Sadjadi
defb4186b4 fix(quotas): tighten Team/Enterprise daily preview caps to stay profitable
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
The earlier caps (Team 150/day, Enterprise 1000/day) used Sonnet/Opus pricing
that put max-usage above the tier's monthly revenue — a Bot with a Team
subscription could out-cost €199 in Anthropic spend. Drop to 50/day Team
and 200/day Enterprise; both now keep ~55-65% margin even when maxed.

Pricing page Team feature line updated to match (150 -> 50). Build caps
loosened slightly less since the 24h cache TTL makes most builds cache-hits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:14:07 +02:00
Marco Sadjadi
bc174c1302 feat: tiered LLM (GLM free / Claude paid) + rate limits + quota enforcement
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
The free tier was hemorrhaging Anthropic cost with no abuse cap (no rate
limit on /preview, Opus default in the build worker, 5-min cache TTL that
made cache-miss the common case). This switches free users to GLM, paid
users to Claude tiers, and tightens every leak found in the audit.

Backend:
- @bmm/llm: GLM provider via Zhipu's OpenAI-compatible endpoint, pickPreviewModel
  + pickBuildModel helpers, plan-aware ModelChoice
- preview-cache TTL 5min -> 24h (kills the cache-miss path)
- /v1/servers/preview: picks model from caller's plan, returns model name to UI
- /v1/servers POST: enforces SERVER_LIMITS per plan (402), rate-limits builds
- daily rate-limit on preview (5/40/150/1000) and build (3/20/100/500)
- /v1/auth/me returns plan so the wizard can show the right model name
- generator worker: GLM default, Anthropic Sonnet fallback if GLM errors

Frontend:
- Wizard fetches plan, shows "<model> is drafting the tool spec" pre-emptively,
  upgrade hint for hobby users, friendly errors for 402 / 429
- Pricing page: AI-model line per tier (Open-tier / Haiku / Sonnet / Opus),
  Team €149 -> €199, Enterprise €499 -> €999, daily-preview limit per tier
- Privacy + Security: explicit subprocessor disclosure for Anthropic (US) /
  Zhipu (CN) and which tier uses which

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:50:00 +02:00
Marco Sadjadi
66128c73d8 fix(web): mobile menu background via inline style (Tailwind v4 quirk)
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
Tailwind v4's `bg-[--color-X]` bracket-arbitrary syntax does not wrap the
value in var(), so it compiles to `background-color: --color-bg-elevated`
— an invalid color, which the browser falls back to transparent. The
mobile menu was the one element that depended solely on this utility for
its background, so it rendered with none.

Use an inline style with explicit var() and color-mix to match the nav
bar's frosted look (var(--color-bg) at 80% + backdrop-blur).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:43:57 +02:00
Marco Sadjadi
389446ea16 fix(web): solid background for the marketing mobile menu
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s
The dropdown was bg/95 + backdrop-blur — fragile across mobile browsers
where backdrop-filter is unreliable, leaving 5% transparency that read as
"no background". Switch to a solid elevated panel with a soft shadow and
an explicit z-index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:24:36 +02:00
Marco Sadjadi
dc5bbaa0ae feat(web): mobile bottom action bar for + New server
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
On phones the dashboard top bar is tight with the nav icons + the primary
action crammed alongside. Move the action into a sticky bottom bar in the
thumb zone, leave the top bar to navigation. Hidden on the create-wizard
route since that page owns its own action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:19:31 +02:00
Marco Sadjadi
083b6e5d41 fix(preview): switch spec generation to Haiku 4.5 to fit the proxy window
All checks were successful
Deploy to Production / deploy (push) Successful in 51s
Sonnet still overran Cloudflare's edge timeout — the 504 fired at 90s but
the proxy had already cut the connection, so the browser saw a headerless
524 reported as a CORS error.

Measured against the live API: Haiku 4.5 generates the spec at ~200 tok/s,
so a full 8k-token spec completes in ~40s. With a hard 60s timeout and no
retries the route is guaranteed to answer well inside the proxy window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:03:12 +02:00
Marco Sadjadi
e198d44e1e fix(preview): stop spec generation timing out behind the edge proxy
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
The /v1/servers/preview route ran claude-opus-4-7 synchronously; full spec
generation routinely exceeded Cloudflare's ~100s proxy cap, so the browser
received a headerless 524 and reported it as a CORS failure.

- preview now uses claude-sonnet-4-6 with a 45s per-attempt timeout and one
  retry — comfortably inside the proxy budget
- generateSpec maps an exhausted timeout to SpecTimeoutError; the route
  returns a clean 504 (with CORS headers) instead of a stalled connection
- analyze step: live elapsed-seconds counter as freeze-proof, plus a
  reduced-motion exception so the loading spinner keeps spinning (a status
  indicator, which WCAG exempts from reduced-motion)
- textarea resize grip restyled to dark theme (light hatch on dark square)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:52:48 +02:00
Marco Sadjadi
5d0d5668d8 feat(web): country-code picker, auth-aware header, dedupe new-server CTA
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
- login: SMS step now has a 60-country dial-code <select> (CH default)
  and a national-number input, combined into strict E.164 client-side
- marketing header: probe /v1/auth/me, show "Dashboard" when signed in
  instead of the Sign in / Start building CTAs
- dashboard overview: drop the duplicate "+ New server" button, the
  navbar one is the single source

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:41:19 +02:00
Marco Sadjadi
88c7262a08 fix(web): mobile-responsive hero, marketing site, docs and dashboard
All checks were successful
Deploy to Production / deploy (push) Successful in 1m13s
- Hero h1 was a fixed text-[44px] — overflowed narrow phones. Now
  text-[30px] sm:text-[40px] md:text-[56px].
- Hero grid children get min-w-0 so the code blocks' overflow-x-auto
  actually constrains instead of widening the page.
- Marketing nav: the inline links were hidden below md with no fallback.
  Added a hamburger MobileMenu; "Sign in" collapses into it on the
  smallest screens.
- Section vertical padding is now responsive (py-14 sm:py-20).
- globals.css: overflow-x: clip on <html> as a safety net.
- docs: the 240px sidebar is hidden below lg, article gets min-w-0.
- dashboard header: nav labels collapse to icons on small screens.

Verified: next build passes (40/40 pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:25:26 +02:00
Marco Sadjadi
2e5bf5b44b fix(web): self-destructing sw.js to evict the stale GoDaddy Airo worker
All checks were successful
Deploy to Production / deploy (push) Successful in 1m0s
The domain was parked on GoDaddy Airo, which registered a Workbox
service worker. It keeps serving cached GoDaddy pages in browsers that
visited the parked domain. Serving a self-destruct sw.js makes those
browsers wipe the caches and unregister the worker on their next visit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:06:56 +02:00
Marco Sadjadi
cc3c5ad444 feat(auth): GitHub OAuth login + SMS one-time-code login
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.

SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
  rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
  user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
  per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
  (number -> 6-digit code with one-time-code autofill).

SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
Marco Sadjadi
f5107922a0 perf(web): inline CSS + modern browserslist
All checks were successful
Deploy to Production / deploy (push) Successful in 1m11s
- experimental.inlineCss: drop the render-blocking CSS request — the
  Tailwind bundle is inlined into the HTML head (faster FCP/LCP on mobile).
- browserslist pinned to modern engines so Next/SWC stops emitting
  polyfills for Baseline features (Array.at, Object.fromEntries, …).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:57:30 +02:00
Marco Sadjadi
36a1adf4d7 fix(web): banner contrast meets WCAG AA
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
White on #6366f1 was 4.47:1 — just under the 4.5:1 minimum for small
text (Lighthouse a11y flag). Darkened the banner to #4f46e5 (6.3:1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:31:34 +02:00
Marco Sadjadi
b843394d0f feat(web): full SEO stack — metadata, JSON-LD, sitemap, robots, OG image
Some checks failed
Deploy to Production / deploy (push) Failing after 46s
Ported and adapted from the BuildMyDiscord SEO setup:

- lib/seo.ts — single source for site constants, the FAQ data (shared by
  the rendered FAQ and the FAQPage schema so they never drift) and JSON-LD
  builders.
- Rich root metadata: title template, keywords, Open Graph, Twitter card,
  robots directives, canonical.
- JSON-LD: Organization + WebSite + SoftwareApplication sitewide, FAQPage
  on the landing page. No AggregateRating — there are no real reviews yet.
- app/robots.ts — allow all, explicit allow-list for AI answer-engine
  crawlers (GPTBot, ClaudeBot, PerplexityBot, …), disallow private routes.
- app/sitemap.ts — every public marketing + docs route.
- app/opengraph-image.tsx — monochrome on-brand 1200x630 share card.
- app/manifest.ts + public/llms.txt.
- Per-page metadata for pricing, changelog, security, privacy, terms,
  docs, templates and status.
- opengraph-image + apple-icon pinned to the edge runtime — next/og
  crashes during a Node-runtime prerender.

Verified: next build passes; /robots.txt, /sitemap.xml,
/manifest.webmanifest and /opengraph-image all generate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:16:40 +02:00
Marco Sadjadi
617886352c fix(web): banner background renders via inline color
bg-[--color-accent] does not resolve under Tailwind v4 — the banner bar
showed near-black. Set #6366f1 inline so the preview notice is clearly
visible regardless of theme wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:01:50 +02:00
Marco Sadjadi
cd428d5ba3 style(web): biome — drop redundant role, format banner files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:57:49 +02:00
Marco Sadjadi
390cf5e8a1 feat(web): sitewide pre-launch preview banner
Clear notice that the service is not yet open for production use.
Temporary — remove SiteBanner once live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:57:16 +02:00
Marco Sadjadi
c016bf237b feat(deploy): nginx vhost serves :443 with a self-signed origin cert
All checks were successful
Deploy to Production / deploy (push) Successful in 49s
Lets Cloudflare run in Full mode (encrypted Cloudflare<->origin) instead
of Flexible (plaintext origin hop). Full (strict) is a later swap to a
Cloudflare Origin Certificate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:10:22 +02:00
Marco Sadjadi
a288179954 fix(docker): healthcheck must hit 127.0.0.1, not localhost
The servers bind IPv4 (0.0.0.0) only. busybox wget resolves `localhost`
to ::1 first and does not fall back to IPv4, so the healthcheck failed
with "connection refused" and the container showed as unhealthy while
serving fine. Verified on the production api container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:07:01 +02:00
Marco Sadjadi
c7e6537c64 fix(deploy): rework prod artifacts to match the actual Hetzner box
Server recon (read-only SSH) showed the box already runs ~8 apps behind a
host-level nginx, with Gitea + an Actions runner. The host-networking
design collided with contentra on port 3001.

- docker-compose.prod.yml: bridge networking + per-app network, house
  style; api/web/postgres/redis publish to 127.0.0.1 on verified-free
  ports (4000/4001/5440/6390); only the generator keeps host networking
  (no listening port, needs the host namespace for runner-port probing).
- Drop the Traefik config; the box uses a host nginx. Add a ready nginx
  vhost in infra/nginx/buildmymcpserver.conf (listen 80, Cloudflare TLS).
- Add .gitea/workflows/deploy.yml mirroring the buildmydiscord pipeline.
- Narrow the generated-MCP port range to 4400-4900 (clear of screencraft
  on 4321).
- .env.production.example + DEPLOY.md rewritten for buildmymcpserver.com
  and the real topology.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:48:57 +02:00
Marco Sadjadi
a54f6218a7 docs(deploy): flag buildmymcp.com vs buildmymcpserver.com domain mismatch
The request said buildmymcp.com; the GoDaddy tab and the repo are named
buildmymcpserver.com. Added a top-of-file callout so the domain is resolved
before any DNS/nameserver change rather than baked in wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:37:59 +02:00
Marco Sadjadi
e46a9a1cf8 feat(web): surface the template marketplace on the landing page
The marketplace is the distribution channel — fork a working server or
publish your own — but it was absent from the landing page. Adds a
section between Examples and Pricing with a second conversion path into
/templates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:37:06 +02:00
Marco Sadjadi
8a7ffe673d feat(deploy): production Dockerfiles, compose stack, and runbook
- Multi-stage Dockerfiles for web/api/generator (pnpm workspace install,
  tsx runtime — workspace packages are raw TS, same model as runner-template).
- docker-compose.prod.yml: postgres + redis + the three app services.
  api/generator/web use host networking so the generator's host-port probe
  is correct and every service shares one address space; api + generator
  mount the Docker socket. Binds nothing on 80/443 — safe beside other apps.
- Optional Traefik reverse proxy in infra/traefik/ (heavily gated — only if
  the box has no existing proxy).
- .env.production.example, .dockerignore, DEPLOY.md (Cloudflare zone, GoDaddy
  nameserver switch, server deploy, Google Cloud Console OAuth app).
- api/generator `start` now runs via tsx; `node dist/index.js` could never
  resolve the raw-TS workspace imports.

All three images verified building clean; the API container boots under tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:37:02 +02:00
Marco Sadjadi
2b098c5d33 fix(web): wrap useSearchParams in Suspense so next build can prerender
/servers/new and /login/callback call useSearchParams() directly, which
bails the page out of static rendering and fails `next build` during
prerender. Split each into a thin Suspense wrapper + inner component.
Latent since `next dev` never prerenders — only surfaces in a prod build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:36:56 +02:00
Marco Sadjadi
38aa5875d3 feat(auth): add "Continue with Google" OAuth 2.0 login
Server-side authorization-code flow: /v1/auth/google redirects to the
consent screen with a CSRF state cookie; /v1/auth/google/callback
exchanges the code, validates the ID token (iss/aud/exp/email_verified),
and mints a 30-day session via upsertOAuthLogin. /v1/auth/providers lets
the login UI hide the button until GOOGLE_OAUTH_ID/SECRET are set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:26:44 +02:00
Marco Sadjadi
a68e882092 feat(crypto): envelope encryption + key rotation via admin panel
Closes structural weakness #4 from the audit (single global key, no rotation,
no KMS path). Customer secrets now use envelope encryption with a real
rotation story.

Model:
  KEK — Key Encryption Key, 32 bytes from env (SECRETS_ENCRYPTION_KEY). Never
        stored in the DB. Root of trust.
  DEK — Data Encryption Key, 32 random bytes we generate, stored in the new
        encryption_keys table *wrapped* (AES-256-GCM encrypted) with the KEK.
        Secrets are encrypted with the DEK.

Schema:
- encryption_keys (version, wrappedDek, active, rotatedBy, createdAt, retiredAt)
- secrets.keyId — which DEK encrypted this row. NULL = legacy (KEK-direct,
  pre-envelope); decryptSecret handles both and the first rotation migrates
  legacy rows onto a DEK.

crypto.ts (full rewrite):
- ensureActiveKey() — boot-time, loads keys + creates v1 if none. Fail-closed:
  index.ts process.exit(1) if it throws — the API will not serve if encryption
  can't initialize.
- encryptSecret() — encrypts with the active DEK, returns { value, keyId }.
- decryptSecret(value, keyId) — DEK path or legacy KEK-direct path.
- rotateKeys() — mints a fresh DEK, re-encrypts EVERY secret under it inside a
  single transaction (decrypt-old / encrypt-new per row), retires the old key,
  activates the new one. A partial failure is recoverable because every row
  carries its own keyId.
- encryptionStatus() — active version, key history, secret + legacy counts.

Admin:
- GET  /v1/admin/encryption        — status
- POST /v1/admin/encryption/rotate — triggers rotateKeys, audit-logged as
  admin.encryption.rotate with { newVersion, reEncrypted }.
- /admin/encryption page — active-key/secret/legacy cards, Rotate button with
  confirm, key-history table, plain-English how-it-works. Added to admin nav.

Verified end-to-end:
- boot → encryption_keys v1 active, '[crypto] envelope encryption ready'
- created a server with secret MY_API_KEY → stored ciphertext, keyId = v1
- POST rotate → { newVersion: 2, reEncrypted: 1 }; ciphertext changed, keyId
  now v2, v1 retired, v2 active. The decrypt-then-reencrypt round-trip
  succeeded (rotation throws otherwise) — the secret is provably recoverable.
- admin UI renders the status + history correctly.

Deferred, named honestly (not built this iteration):
- worker reads secrets from the DB instead of the BullMQ job-data plaintext
  copy — would also remove plaintext secrets from Redis. Separate change with
  its own risk surface on the iterate/fork flows.
- per-server secret-value rotation UI
- audit_log hash-chaining (tamper-evidence)
- rate limiting on auth endpoints
2026-05-20 22:36:08 +02:00
Marco Sadjadi
8d47b20ae5 fix(generator): iterate orphaned the previous container — rolling deploy
Sovereign-audit follow-up. The audit's finding pass missed this: every
Iterate (version > 1) ran allocatePort -> a NEW port and deployContainer -> a
NEW container, then pointed the DB row at it — and never stopped the old
container. The previous version kept running forever, holding a host port,
with the old secrets baked into its env, untracked (its containerId was
overwritten in the DB by deployContainer). Same bug class as API-SERVERS-001
but on the iterate path.

Fix: the worker captures the server's current containerId before the build
mutates the row, and after the new container is confirmed live + the DB
updated, it stops the old one. This also makes the 'rolling deploy' the UI
promises actually true — the old version stays up until the new one is live,
then is retired.

deploy.ts stopContainer now returns { ok, detail } (was void) so the worker
can log the outcome.

Verified: generator typecheck clean.
2026-05-20 20:58:30 +02:00
186 changed files with 24579 additions and 1315 deletions

34
.dockerignore Normal file
View File

@@ -0,0 +1,34 @@
# Dependencies — reinstalled inside the image
node_modules
**/node_modules
# Build output / caches
.next
**/.next
dist
**/dist
.turbo
**/.turbo
*.tsbuildinfo
**/*.tsbuildinfo
coverage
# Generated MCP build contexts — recreated at runtime in a volume
build-context
# Secrets — never bake into an image (injected via env_file at runtime)
.env
.env.*
!.env.example
!.env.production.example
# OAuth signing keys — persisted in a named volume, not the image
keys
# Local / VCS noise
.git
.gitignore
.DS_Store
*.log
.vscode
.idea

View File

@@ -1,5 +1,8 @@
# ---- Core ----
NODE_ENV=development
# Local dev only: skip runner container hardening (--read-only etc. break on
# Windows Docker Desktop). NEVER set this in .env.production. (GEN-002)
RUNNER_DISABLE_HARDENING=1
# ---- Database ----
DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm
@@ -10,11 +13,33 @@ BETTER_AUTH_SECRET=replace-me-with-32-bytes-of-random-hex-1234567890abcdef
BETTER_AUTH_URL=http://localhost:3001
NEXT_PUBLIC_APP_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:4000
# Google Search Console HTML-tag verification token (content attribute only).
# Leave empty in dev; set in production, then submit /sitemap.xml in GSC.
NEXT_PUBLIC_GSC_VERIFICATION=
# ---- GitHub OAuth (optional in dev) ----
# ---- GitHub OAuth ("Continue with GitHub") ----
# Create at https://github.com/settings/applications/new
# Authorized callback URL: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/github/callback
GITHUB_OAUTH_ID=
GITHUB_OAUTH_SECRET=
# ---- Twilio SMS (phone one-time-code login) ----
# Credentials + a verified sender number from the Twilio console.
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_SMS_FROM=
# ---- Google OAuth (optional — "Continue with Google") ----
# Create at https://console.cloud.google.com/apis/credentials
# Authorized redirect URI must be: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/google/callback
# e.g. dev: http://localhost:4000/v1/auth/google/callback
# prod: https://api.buildmymcp.com/v1/auth/google/callback
GOOGLE_OAUTH_ID=
GOOGLE_OAUTH_SECRET=
# Public URL of this API, used to build the OAuth redirect URI.
CONTROL_PLANE_PUBLIC_URL=http://localhost:4000
# ---- Anthropic ----
ANTHROPIC_API_KEY=

97
.env.production.example Normal file
View File

@@ -0,0 +1,97 @@
# ============================================================================
# Production environment for buildmymcpserver.com
# Copy to .env.production on the server and fill every value marked CHANGE-ME.
# Never commit the filled file — .env.production is gitignored.
#
# Used two ways by docker-compose.prod.yml:
# 1. compose interpolation -> docker compose --env-file .env.production ...
# 2. container env -> env_file: .env.production
# ============================================================================
# ---- Core ----
NODE_ENV=production
# ---- Postgres (the compose file owns the container) ----
POSTGRES_USER=bmm
POSTGRES_PASSWORD=CHANGE-ME-strong-db-password
POSTGRES_DB=bmm
# ---- Host ports (loopback only — picked free on the shared box) ----
POSTGRES_PORT=5440
REDIS_PORT=6390
API_PORT=4000
WEB_PORT=4001
# ---- Connection strings ----
# api + web reach the DBs over the compose network (service names).
# The generator overrides these to 127.0.0.1 (it uses host networking).
DATABASE_URL=postgresql://bmm:CHANGE-ME-strong-db-password@postgres:5432/bmm
REDIS_URL=redis://redis:6379
# ---- API ----
PORT=4000
# ---- Public URLs (must match the Cloudflare DNS records) ----
NEXT_PUBLIC_APP_URL=https://buildmymcpserver.com
NEXT_PUBLIC_API_URL=https://api.buildmymcpserver.com
# Used to build the Google OAuth redirect URI and as the JWKS origin.
CONTROL_PLANE_PUBLIC_URL=https://api.buildmymcpserver.com
# Reachable by generated MCP containers — must be public so they can resolve it.
CONTROL_PLANE_URL=https://api.buildmymcpserver.com
OAUTH_ISSUER=https://api.buildmymcpserver.com
# ---- Crypto ----
# REQUIRED in production. The API refuses to boot on the all-zero placeholder.
# Generate with: openssl rand -hex 32
SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32
# ---- Admin bootstrap (upserted idempotently on API boot) ----
ADMIN_EMAIL=CHANGE-ME-admin@example.com
ADMIN_PASSWORD=CHANGE-ME-strong-admin-password
ADMIN_NAME=CHANGE-ME-Admin
# ---- Anthropic (empty = mock generation; set for real Claude generation) ----
ANTHROPIC_API_KEY=
# ---- Google OAuth ("Continue with Google") ----
# Google Cloud Console -> APIs & Services -> Credentials -> OAuth client (Web).
# Authorized redirect URI must be EXACTLY:
# https://api.buildmymcpserver.com/v1/auth/google/callback
GOOGLE_OAUTH_ID=
GOOGLE_OAUTH_SECRET=
# ---- OAuth signing keys (RS256 JWKS) ----
# Auto-generated on first boot into this dir; persisted in the bmm_keys volume.
OAUTH_KEY_DIR=./keys
# ---- Runner / Generator ----
# Host used in a generated server's public URL (http://RUNNER_HOST:<port>).
# Generated MCP containers bind host ports in RUNNER_PORT_RANGE_* — this range
# is kept clear of every other app already running on the box.
# NOTE: per-server subdomain routing through nginx is not wired yet — a
# generated server is currently reachable at the host port directly. Treat
# public exposure of generated servers as a follow-up before GA. See DEPLOY.md.
RUNNER_HOST=buildmymcpserver.com
RUNNER_PORT_RANGE_START=4400
RUNNER_PORT_RANGE_END=4900
# ---- Stripe (billing) ----
# Secret key (server-side only — NEVER expose). From Stripe Dashboard → Developers → API keys.
STRIPE_SECRET_KEY=CHANGE-ME-sk_live_...
# Publishable key (safe to expose). Used by the embedded in-app checkout.
STRIPE_PUBLISHABLE_KEY=CHANGE-ME-pk_live_...
# Same publishable key, exposed to the web client bundle at BUILD time (the web
# image is rebuilt by the deploy, so this must be set before deploying or the
# in-app checkout shows "not configured"). Keep it identical to STRIPE_PUBLISHABLE_KEY.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=CHANGE-ME-pk_live_...
# Webhook signing secret — from the endpoint you create at /v1/billing/webhook.
STRIPE_WEBHOOK_SECRET=CHANGE-ME-whsec_...
# Price IDs (price_… not prod_…) from each product's pricing in the Dashboard.
STRIPE_PRICE_PRO_MONTHLY=CHANGE-ME-price_...
STRIPE_PRICE_PRO_YEARLY=CHANGE-ME-price_...
STRIPE_PRICE_TEAM_MONTHLY=CHANGE-ME-price_...
STRIPE_PRICE_TEAM_YEARLY=CHANGE-ME-price_...
# ---- Observability (optional) ----
SENTRY_DSN=
OTEL_EXPORTER_OTLP_ENDPOINT=

View File

@@ -0,0 +1,36 @@
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: bmm-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: hetzner
steps:
- name: Pull from Gitea + rebuild containers
run: |
set -eo pipefail
: "${HOME:=/root}"
export HOME
cd /opt/buildmymcpserver
git fetch gitea main
git reset --hard gitea/main
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
docker system prune -f
- name: Health check
run: |
set -e
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4000/health 2>/dev/null || echo 000)
if [ "$code" = "200" ]; then echo "API healthy after $i attempts"; exit 0; fi
echo "wait $i/30 (got $code)"
sleep 5
done
docker logs bmm-api --tail 60 || true
exit 1

236
DEPLOY.md Normal file
View File

@@ -0,0 +1,236 @@
# Deploying buildmymcpserver.com
End-to-end runbook for the production deploy on the shared Hetzner box.
Steps marked **[you]** require logging into a third-party account (Cloudflare,
GoDaddy, Google) — those must be done by a human. Steps marked **[server]** run
on the box over SSH.
---
## 0. The target box — what is already there
`213.239.213.217` — Debian 12, Docker 29 + Compose v5, 62 GB RAM, 151 GB free.
It is a **shared box running ~8 other production apps** (buildmydiscord,
savesphere, ava, contentra, screencraft, helixmind, prishtina-bot, …).
Verified house pattern — this deploy follows it exactly:
- Each app lives in `/opt/<app>` and runs via `docker compose` on a **bridge
network**, publishing ports to `127.0.0.1`.
- A **host-level nginx** owns `:80` / `:443`. Each app has a vhost in
`/etc/nginx/sites-enabled/` that proxies its domain to its loopback port.
- TLS is terminated by **Cloudflare** (proxied DNS); origins serve plain HTTP.
- **Gitea** runs on the box (`gitea-gitea-1`, web on `127.0.0.1:3020`, SSH on
`:2222`) with an Actions runner labelled `hetzner`. Apps deploy via a
`.gitea/workflows/deploy.yml` that does `git fetch` + `docker compose up`.
**Do not** start anything that binds `:80`/`:443` — the host nginx owns them.
### Ports this deploy uses (all verified free on the box)
| Service | Host bind | Notes |
|-----------|----------------------|----------------------------------------|
| web | `127.0.0.1:4001` | nginx → buildmymcpserver.com |
| api | `127.0.0.1:4000` | nginx → api.buildmymcpserver.com |
| postgres | `127.0.0.1:5440` | loopback only |
| redis | `127.0.0.1:6390` | loopback only |
| generated | `44004900` | MCP runner containers (host ports) |
---
## 1. Cloudflare — create the zone **[you]**
1. Log in to <https://dash.cloudflare.com>.
2. **Add a site**`buildmymcpserver.com`**Free** plan.
3. Cloudflare scans existing DNS. **Write down every record it finds first**
anything not recreated in Cloudflare stops resolving after step 3.
4. Note the **two nameservers** Cloudflare assigns.
### DNS records to create in Cloudflare
| Type | Name | Content | Proxy |
|------|-------|----------------------|--------------|
| A | `@` | `213.239.213.217` | Proxied (🟠) |
| A | `api` | `213.239.213.217` | Proxied (🟠) |
| A | `www` | `213.239.213.217` | Proxied (🟠) |
**SSL/TLS mode:** **Full**. The origin nginx vhost listens on :443 with a
self-signed cert (step 5), so Cloudflare↔origin is encrypted. Never use
**Flexible**. For **Full (strict)**, replace the self-signed cert with a
Cloudflare Origin Certificate.
---
## 2. ⚠️ Order of operations
> **Recreate ALL existing DNS records in Cloudflare (step 1) BEFORE changing the
> nameservers at GoDaddy (step 3).**
Once GoDaddy points at Cloudflare, Cloudflare's zone is authoritative. Anything
not copied into it — MX, TXT, other subdomains — stops resolving. Copy first.
---
## 3. GoDaddy — point the domain at Cloudflare **[you]**
Only after step 1's records exist in Cloudflare:
1. Log in to <https://dcc.godaddy.com>.
2. `buildmymcpserver.com`**Domain Settings → Nameservers → Change**.
3. **Enter my own nameservers (custom)** → the two from Cloudflare.
4. Save. Propagation: minutes, up to 24 h. Cloudflare shows the zone **Active**
when it has taken over.
---
## 4. Deploy the stack **[server]**
The app is installed at `/opt/buildmymcpserver`. To deploy or redeploy by hand:
```bash
cd /opt/buildmymcpserver
# First time only: create the env file and fill every CHANGE-ME value
cp .env.production.example .env.production
openssl rand -hex 32 # -> SECRETS_ENCRYPTION_KEY
nano .env.production
# Build + start (this is exactly what the Gitea pipeline runs)
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
# First time only: create the database schema
docker compose --env-file .env.production -f docker-compose.prod.yml \
exec -T api pnpm --filter @bmm/db push
# Status / logs
docker compose --env-file .env.production -f docker-compose.prod.yml ps
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f api
```
`.env.production` essentials:
- `SECRETS_ENCRYPTION_KEY` — real 32-byte hex. The API **refuses to boot** in
production on the all-zero placeholder.
- `DATABASE_URL` password must equal `POSTGRES_PASSWORD`.
- `NEXT_PUBLIC_API_URL` is compiled into the web bundle — after changing it,
rebuild web: `... up -d --build web`.
Health check: `curl http://127.0.0.1:4000/health``{"ok":true,...}`.
---
## 5. nginx vhost + origin cert **[server]**
The vhost serves :80 and :443; the :443 listener needs an origin certificate.
A self-signed cert is enough for Cloudflare **Full** mode:
```bash
mkdir -p /etc/ssl/buildmymcpserver
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /etc/ssl/buildmymcpserver/origin.key \
-out /etc/ssl/buildmymcpserver/origin.crt \
-subj "/CN=buildmymcpserver.com"
cp /opt/buildmymcpserver/infra/nginx/buildmymcpserver.conf \
/etc/nginx/sites-available/buildmymcpserver
ln -sf /etc/nginx/sites-available/buildmymcpserver \
/etc/nginx/sites-enabled/buildmymcpserver
nginx -t && systemctl reload nginx
```
`nginx -t` must pass before the reload — a reload of a bad config is rejected,
so the other live sites are never at risk.
---
## 6. Google login — Google Cloud Console **[you]**
1. Log in to <https://console.cloud.google.com>.
2. **Create a project** — e.g. `buildmymcpserver`.
3. **APIs & Services → OAuth consent screen:** External; app name
`BuildMyMCPServer`; scopes `openid`, `userinfo.email`, `userinfo.profile`;
add yourself as a test user or **Publish**.
4. **Credentials → Create credentials → OAuth client ID → Web application.**
**Authorized redirect URI** — exactly:
```
https://api.buildmymcpserver.com/v1/auth/google/callback
```
5. Put the Client ID + secret into `.env.production`:
```
GOOGLE_OAUTH_ID=...apps.googleusercontent.com
GOOGLE_OAUTH_SECRET=...
```
6. Apply: `docker compose --env-file .env.production -f docker-compose.prod.yml up -d api`.
When `GOOGLE_OAUTH_ID`/`SECRET` are set the **Continue with Google** button
appears automatically; when unset it stays hidden and magic-link login is used.
---
## 7. Verify live
- `https://buildmymcpserver.com` — landing page over HTTPS.
- `https://api.buildmymcpserver.com/health` — `{"ok":true,...}`.
- `/login` — magic link, plus Continue with Google once step 6 is done.
- `/admin/login` — admin via `ADMIN_EMAIL` / `ADMIN_PASSWORD`.
- Wizard → create a server → build reaches `live`.
---
## 8. Gitea pipeline (continuous deploy)
`.gitea/workflows/deploy.yml` is in the repo and mirrors the buildmydiscord
pattern (`runs-on: hetzner`, `git fetch` + `docker compose up -d --build` +
health check). To activate it:
1. Create a repo on the box's Gitea (`https://<gitea-host>`), e.g.
`DancingTedDanson/buildmymcpserver`.
2. On the box, add it as a remote and push:
```bash
cd /opt/buildmymcpserver
git remote add gitea <gitea-ssh-url>
git push gitea main
```
3. From then on, every push to `main` rebuilds and redeploys automatically.
Until then, deploy by hand with the step 4 command — it is byte-identical to
what the pipeline runs.
---
## 9. Operations
```bash
cd /opt/buildmymcpserver
C="docker compose --env-file .env.production -f docker-compose.prod.yml"
$C ps # status
$C logs -f generator # tail a service
$C up -d --build # redeploy after a code change
$C up -d --build web # rebuild only web (e.g. NEXT_PUBLIC_API_URL changed)
$C restart api # restart one service
$C down # stop the stack — named volumes (data) survive
```
**Rollback:** `$C down`, check out the previous commit, redeploy. Volumes
`bmm_pg / bmm_redis / bmm_keys / bmm_build_context` survive `down`. `down -v`
destroys them — never use it.
**Back up the DB before a schema change:**
`$C exec -T postgres pg_dump -U bmm bmm > backup-$(date +%F).sql`
---
## Known follow-ups
1. **Generated-server routing.** Generated MCP servers get a
`http://buildmymcpserver.com:<port>` URL on ports 44004900. Those ports are
not opened on the firewall and not proxied by subdomain — wire
`*.mcp.buildmymcpserver.com` through nginx before exposing generated servers
publicly.
2. **Magic-link email** is printed to the API log, not sent. Wire a real
transport (Resend / SES) before relying on email sign-in.
3. **Cloudflare SSL** — once confirmed working on **Full**, an optional
hardening step is a Cloudflare Origin Certificate + nginx `listen 443 ssl`
for **Full (strict)**.

35
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
# Control plane (Fastify). Runs via tsx — workspace packages are consumed as raw
# TypeScript, so there is no separate compile step (same model as runner-template).
# Build context must be the repo root: docker build -f apps/api/Dockerfile .
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
# ---- deps: install the whole workspace from the lockfile ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY apps/generator/package.json apps/generator/
COPY apps/runner-template/package.json apps/runner-template/
COPY packages/auth/package.json packages/auth/
COPY packages/db/package.json packages/db/
COPY packages/llm/package.json packages/llm/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
# ---- runtime ----
FROM deps AS runtime
# docker CLI: the API stops/removes generated MCP containers via the host daemon.
RUN apk add --no-cache docker-cli
ENV NODE_ENV=production
COPY . .
WORKDIR /app/apps/api
EXPOSE 4000
# Use 127.0.0.1, not localhost: the server binds IPv4 only, and busybox wget
# resolves localhost to ::1 first — which would refuse and fail the check.
HEALTHCHECK --interval=20s --timeout=4s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:4000/health || exit 1
CMD ["pnpm", "start"]

View File

@@ -5,7 +5,7 @@
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"start": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
},
@@ -22,6 +22,7 @@
"fastify": "5.2.0",
"ioredis": "5.4.1",
"jose": "5.9.6",
"stripe": "^22.1.1",
"zod": "3.25.76"
},
"devDependencies": {

View File

@@ -8,6 +8,7 @@ const Env = z.object({
NEXT_PUBLIC_APP_URL: z.string().default('http://localhost:3001'),
OAUTH_KEY_DIR: z.string().default('./keys'),
ANTHROPIC_API_KEY: z.string().optional(),
GLM_API_KEY: z.string().optional(),
SECRETS_ENCRYPTION_KEY: z
.string()
.min(64, '32 bytes hex required')
@@ -16,6 +17,27 @@ const Env = z.object({
ADMIN_EMAIL: z.string().email().optional(),
ADMIN_PASSWORD: z.string().min(8).optional(),
ADMIN_NAME: z.string().optional(),
GOOGLE_OAUTH_ID: z.string().optional(),
GOOGLE_OAUTH_SECRET: z.string().optional(),
GITHUB_OAUTH_ID: z.string().optional(),
GITHUB_OAUTH_SECRET: z.string().optional(),
TWILIO_ACCOUNT_SID: z.string().optional(),
TWILIO_AUTH_TOKEN: z.string().optional(),
TWILIO_SMS_FROM: z.string().optional(),
// Email magic-link login is OFF by default — no SMTP/Resend wired yet.
// Set EMAIL_AUTH_ENABLED=true once an email sender is configured; the
// magic-link routes + login-page form section will switch back on.
EMAIL_AUTH_ENABLED: z
.union([z.literal('true'), z.literal('false'), z.literal('1'), z.literal('0')])
.transform((v) => v === 'true' || v === '1')
.default('false'),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
STRIPE_PRICE_PRO_MONTHLY: z.string().optional(),
STRIPE_PRICE_PRO_YEARLY: z.string().optional(),
STRIPE_PRICE_TEAM_MONTHLY: z.string().optional(),
STRIPE_PRICE_TEAM_YEARLY: z.string().optional(),
});
export const config = Env.parse({
@@ -26,11 +48,27 @@ export const config = Env.parse({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
OAUTH_KEY_DIR: process.env.OAUTH_KEY_DIR,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
GLM_API_KEY: process.env.GLM_API_KEY,
SECRETS_ENCRYPTION_KEY: process.env.SECRETS_ENCRYPTION_KEY,
CONTROL_PLANE_PUBLIC_URL: process.env.CONTROL_PLANE_PUBLIC_URL,
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
ADMIN_NAME: process.env.ADMIN_NAME,
GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID,
GOOGLE_OAUTH_SECRET: process.env.GOOGLE_OAUTH_SECRET,
GITHUB_OAUTH_ID: process.env.GITHUB_OAUTH_ID,
GITHUB_OAUTH_SECRET: process.env.GITHUB_OAUTH_SECRET,
TWILIO_ACCOUNT_SID: process.env.TWILIO_ACCOUNT_SID,
TWILIO_AUTH_TOKEN: process.env.TWILIO_AUTH_TOKEN,
TWILIO_SMS_FROM: process.env.TWILIO_SMS_FROM,
EMAIL_AUTH_ENABLED: process.env.EMAIL_AUTH_ENABLED,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_PUBLISHABLE_KEY: process.env.STRIPE_PUBLISHABLE_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY,
STRIPE_PRICE_PRO_YEARLY: process.env.STRIPE_PRICE_PRO_YEARLY,
STRIPE_PRICE_TEAM_MONTHLY: process.env.STRIPE_PRICE_TEAM_MONTHLY,
STRIPE_PRICE_TEAM_YEARLY: process.env.STRIPE_PRICE_TEAM_YEARLY,
});
// INFRA-001: refuse to boot in production with the placeholder encryption key.

View File

@@ -1,22 +1,81 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import websocket from '@fastify/websocket';
import { seedAdmin } from '@bmm/auth';
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import websocket from '@fastify/websocket';
import Fastify from 'fastify';
import { config } from './config.js';
import { authRoutes } from './routes/auth.js';
import { serverRoutes } from './routes/servers.js';
import { oauthRoutes } from './routes/oauth.js';
import { settingsRoutes } from './routes/settings.js';
import { ensureActiveKey } from './lib/crypto.js';
import { validateStripePriceConfig } from './lib/stripe.js';
import { accountRoutes } from './routes/account.js';
import { adminRoutes } from './routes/admin.js';
import { authRoutes } from './routes/auth.js';
import { billingRoutes } from './routes/billing.js';
import { oauthRoutes } from './routes/oauth.js';
import { serverRoutes } from './routes/servers.js';
import { settingsRoutes } from './routes/settings.js';
import { supportRoutes } from './routes/support.js';
import { templateRoutes } from './routes/templates.js';
// Stripe webhook signature verification requires the raw request body, so we
// stash a copy on req.rawBody during JSON parsing. Merges into Fastify's
// FastifyRequest interface declaration alongside `user` from plugins/session.
declare module 'fastify' {
interface FastifyRequest {
rawBody?: Buffer;
}
}
const app = Fastify({
logger: {
level: config.NODE_ENV === 'production' ? 'info' : 'debug',
},
// We run behind nginx + Cloudflare — both prepend the real client IP into
// X-Forwarded-For. Without trustProxy=true Fastify reports the nginx peer
// (always 127.0.0.1 / docker-bridge) for req.ip, which silently collapses
// every per-IP rate-limit into a single global counter. (See Z3-001.)
trustProxy: true,
});
// Replace the default JSON parser with one that keeps the raw buffer for the
// Stripe-webhook signature check. Must run BEFORE any route registration.
app.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => {
const buf = body as Buffer;
req.rawBody = buf;
if (buf.length === 0) return done(null, undefined);
try {
done(null, JSON.parse(buf.toString('utf8')));
} catch (err) {
done(err as Error, undefined);
}
},
);
// RFC 6749 §3.2 makes application/x-www-form-urlencoded the mandatory wire
// format for the OAuth token endpoint, and most DCR-emitting clients
// (Claude Desktop included) post it that way without negotiating. Fastify
// has no built-in parser for it, so without this every POST /oauth/token
// hit 415 before reaching our handler. Parsed into a plain object so the
// existing zod schemas don't need to change.
app.addContentTypeParser(
'application/x-www-form-urlencoded',
{ parseAs: 'string' },
(_req, body, done) => {
const text = body as string;
if (!text) return done(null, {});
try {
const params = new URLSearchParams(text);
const out: Record<string, string> = {};
for (const [k, v] of params) out[k] = v;
done(null, out);
} catch (err) {
done(err as Error, undefined);
}
},
);
await app.register(cors, {
origin: [config.NEXT_PUBLIC_APP_URL],
credentials: true,
@@ -26,12 +85,30 @@ await app.register(websocket, { options: { maxPayload: 1024 * 1024 } });
app.get('/health', async () => ({ ok: true, ts: Date.now() }));
// Fail-closed: initialize envelope encryption before serving any request that
// could write a secret. If the encryption subsystem can't come up, don't run.
try {
await ensureActiveKey();
app.log.info('[crypto] envelope encryption ready');
} catch (err) {
app.log.error({ err }, '[crypto] failed to initialize encryption — refusing to start');
process.exit(1);
}
await app.register(authRoutes);
await app.register(serverRoutes);
await app.register(oauthRoutes);
await app.register(settingsRoutes);
await app.register(adminRoutes);
await app.register(templateRoutes);
await app.register(billingRoutes);
await app.register(supportRoutes);
await app.register(accountRoutes);
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier
// to find at boot.
validateStripePriceConfig({ warn: (msg) => app.log.warn(msg) });
// Bootstrap admin user from env (idempotent)
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {

View File

@@ -1,30 +1,195 @@
import crypto from 'node:crypto';
import { count, createDb, desc, eq, encryptionKeys, secrets, sql } from '@bmm/db';
import { config } from '../config.js';
const ALGO = 'aes-256-gcm';
const db = createDb();
function getKey(): Buffer {
const hex = config.SECRETS_ENCRYPTION_KEY;
const buf = Buffer.from(hex, 'hex');
/**
* Envelope encryption.
*
* KEK — Key Encryption Key. 32 bytes from env (SECRETS_ENCRYPTION_KEY).
* Never stored in the database. The root of trust.
* DEK — Data Encryption Key. 32 random bytes, generated by us, stored in
* the encryption_keys table *wrapped* (AES-256-GCM encrypted) with
* the KEK. Secrets are encrypted with the DEK.
*
* Rotation mints a fresh DEK and re-encrypts every secret under it, so a
* suspected DEK compromise is recoverable without ever touching the KEK.
* Legacy secrets (keyId = null) were encrypted directly with the KEK before
* envelope encryption existed; decryptSecret handles them, and the first
* rotation migrates them onto a DEK.
*/
function getKEK(): Buffer {
const buf = Buffer.from(config.SECRETS_ENCRYPTION_KEY, 'hex');
if (buf.length !== 32) {
throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
}
return buf;
}
export function encryptSecret(plaintext: string): string {
// Low-level AES-256-GCM with an explicit key. Payload: iv.tag.ciphertext (base64).
function aesEncrypt(key: Buffer, plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGO, getKey(), iv);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
}
export function decryptSecret(payload: string): string {
function aesDecrypt(key: Buffer, payload: string): string {
const [ivB64, tagB64, encB64] = payload.split('.');
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_secret_payload');
const decipher = crypto.createDecipheriv(ALGO, getKey(), Buffer.from(ivB64, 'base64'));
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_ciphertext');
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
const dec = Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]);
return dec.toString('utf8');
return Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]).toString(
'utf8',
);
}
// In-memory DEK cache: encryption_keys.id -> raw 32-byte DEK.
const dekCache = new Map<string, Buffer>();
let activeKeyId: string | null = null;
async function loadKeys(): Promise<void> {
const rows = await db.select().from(encryptionKeys);
const kek = getKEK();
dekCache.clear();
activeKeyId = null;
for (const row of rows) {
// wrappedDek decrypts to the base64 of the 32-byte DEK
const dek = Buffer.from(aesDecrypt(kek, row.wrappedDek), 'base64');
if (dek.length !== 32) throw new Error(`corrupt DEK for key version ${row.version}`);
dekCache.set(row.id, dek);
if (row.active) activeKeyId = row.id;
}
}
/**
* Boot-time: load existing keys and, if there is no active key yet, create
* version 1. Must run before any encryptSecret call. Fail-closed: if this
* throws, the API must not start.
*/
export async function ensureActiveKey(): Promise<void> {
await loadKeys();
if (activeKeyId) return;
const dek = crypto.randomBytes(32);
const wrapped = aesEncrypt(getKEK(), dek.toString('base64'));
const [row] = await db
.insert(encryptionKeys)
.values({ version: 1, wrappedDek: wrapped, active: true })
.returning();
if (!row) throw new Error('failed to create initial encryption key');
dekCache.set(row.id, dek);
activeKeyId = row.id;
}
export interface EncryptResult {
value: string;
keyId: string;
}
export function encryptSecret(plaintext: string): EncryptResult {
if (!activeKeyId) {
throw new Error('encryption not initialized — ensureActiveKey() must run at boot');
}
const dek = dekCache.get(activeKeyId);
if (!dek) throw new Error('active DEK missing from cache');
return { value: aesEncrypt(dek, plaintext), keyId: activeKeyId };
}
export function decryptSecret(value: string, keyId: string | null): string {
if (!keyId) {
// Legacy: encrypted directly with the KEK before envelope encryption.
return aesDecrypt(getKEK(), value);
}
const dek = dekCache.get(keyId);
if (!dek) throw new Error(`unknown encryption key id: ${keyId}`);
return aesDecrypt(dek, value);
}
export interface RotateResult {
newVersion: number;
reEncrypted: number;
}
/**
* Mint a fresh DEK and re-encrypt every secret under it in a single
* transaction. Legacy (KEK-direct) secrets are migrated in the same pass.
*/
export async function rotateKeys(rotatedBy: string): Promise<RotateResult> {
await loadKeys();
const newDek = crypto.randomBytes(32);
const wrapped = aesEncrypt(getKEK(), newDek.toString('base64'));
const [{ maxV } = { maxV: 0 }] = await db
.select({ maxV: sql<number>`coalesce(max(${encryptionKeys.version}), 0)` })
.from(encryptionKeys);
const nextVersion = Number(maxV) + 1;
const reEncrypted = await db.transaction(async (tx) => {
const [newKey] = await tx
.insert(encryptionKeys)
.values({ version: nextVersion, wrappedDek: wrapped, active: false, rotatedBy })
.returning();
if (!newKey) throw new Error('failed to insert new encryption key');
const all = await tx.select().from(secrets);
for (const s of all) {
const plain = decryptSecret(s.encryptedValue, s.keyId);
await tx
.update(secrets)
.set({ encryptedValue: aesEncrypt(newDek, plain), keyId: newKey.id })
.where(eq(secrets.id, s.id));
}
await tx
.update(encryptionKeys)
.set({ active: false, retiredAt: new Date() })
.where(eq(encryptionKeys.active, true));
await tx.update(encryptionKeys).set({ active: true }).where(eq(encryptionKeys.id, newKey.id));
return { count: all.length, keyId: newKey.id };
});
// Commit succeeded — update the in-memory cache.
dekCache.set(reEncrypted.keyId, newDek);
activeKeyId = reEncrypted.keyId;
return { newVersion: nextVersion, reEncrypted: reEncrypted.count };
}
export interface EncryptionStatus {
activeVersion: number | null;
keyCount: number;
secretCount: number;
legacySecretCount: number;
keys: {
version: number;
active: boolean;
createdAt: Date;
retiredAt: Date | null;
}[];
}
export async function encryptionStatus(): Promise<EncryptionStatus> {
const keys = await db.select().from(encryptionKeys).orderBy(desc(encryptionKeys.version));
const [{ c: secretCount } = { c: 0 }] = await db.select({ c: count() }).from(secrets);
const [{ c: legacy } = { c: 0 }] = await db
.select({ c: count() })
.from(secrets)
.where(sql`${secrets.keyId} is null`);
return {
activeVersion: keys.find((k) => k.active)?.version ?? null,
keyCount: keys.length,
secretCount: Number(secretCount),
legacySecretCount: Number(legacy),
keys: keys.map((k) => ({
version: k.version,
active: k.active,
createdAt: k.createdAt,
retiredAt: k.retiredAt,
})),
};
}

View File

@@ -1,14 +1,43 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
/**
* Per-runner nginx map fragment cleanup. Mirrors the generator-side helper
* (apps/generator/src/lib/deploy.ts) — when MCP_DOMAIN is set, the host
* runs an inotify watcher over the map dir that reloads nginx on any
* change. We remove the fragment here so the slug stops serving 502 the
* moment the user deletes their server.
*
* No-op if MCP_DOMAIN isn't configured (legacy http://host:port URLs are
* still in use). Idempotent — missing files are fine.
*/
const MCP_DOMAIN = process.env.MCP_DOMAIN ?? '';
const RUNNER_MAP_DIR = process.env.RUNNER_MAP_DIR ?? '/var/runner-map';
async function removeRunnerMapEntry(slug: string): Promise<void> {
if (!MCP_DOMAIN || !slug) return;
try {
await fs.rm(path.join(RUNNER_MAP_DIR, `${slug}.conf`), { force: true });
} catch {
/* ignore — not critical */
}
}
/**
* Stop and remove a generated MCP container by container id.
* Resolves regardless of outcome — failures are logged but never blocking.
* Production: should be moved to a Coolify HTTP-API call.
* Also drops the slug's nginx map fragment so the public URL stops 502'ing
* the moment the container goes away.
*/
export async function stopContainer(containerId: string): Promise<{ ok: boolean; detail: string }> {
export async function stopContainer(
containerId: string,
slug?: string,
): Promise<{ ok: boolean; detail: string }> {
if (!containerId || containerId.length < 4) {
return { ok: false, detail: 'invalid_container_id' };
}
if (slug) await removeRunnerMapEntry(slug);
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
const child = spawn('docker', ['rm', '-f', containerId], {
stdio: ['ignore', 'pipe', 'pipe'],

48
apps/api/src/lib/plan.ts Normal file
View File

@@ -0,0 +1,48 @@
import { createDb, eq, organizations } from '@bmm/db';
import type { Plan } from '@bmm/llm';
const db = createDb();
/** Look up an org's current plan. Defaults to 'hobby' if the org row is gone
* for any reason — fail-closed to the least expensive tier. */
export async function getOrgPlan(orgId: string): Promise<Plan> {
const [row] = await db
.select({ plan: organizations.plan })
.from(organizations)
.where(eq(organizations.id, orgId))
.limit(1);
return (row?.plan ?? 'hobby') as Plan;
}
export interface OrgBilling {
plan: Plan;
suspended: boolean;
suspendedReason: string | null;
}
/** Like getOrgPlan but also reports suspension state. Use in routes that
* should refuse new work when a subscription is past-due / unpaid. */
export async function getOrgBilling(orgId: string): Promise<OrgBilling> {
const [row] = await db
.select({
plan: organizations.plan,
suspended: organizations.suspended,
suspendedReason: organizations.suspendedReason,
})
.from(organizations)
.where(eq(organizations.id, orgId))
.limit(1);
return {
plan: (row?.plan ?? 'hobby') as Plan,
suspended: row?.suspended ?? false,
suspendedReason: row?.suspendedReason ?? null,
};
}
/** Max MCP servers per org by plan. Enforced at POST /v1/servers. */
export const SERVER_LIMITS: Record<Plan, number> = {
hobby: 1,
pro: 5,
team: 25,
enterprise: Number.MAX_SAFE_INTEGER,
};

View File

@@ -1,8 +1,11 @@
import crypto from 'node:crypto';
import { getRedis } from './redis.js';
import type { GeneratorSpec } from '@bmm/types';
import { getRedis } from './redis.js';
const TTL_SECONDS = 5 * 60;
// 24h: previews are LLM-priced; a long TTL eliminates the cache-miss path on
// the build worker (each miss = another LLM call). Specs are tiny JSON (~5KB),
// Redis-memory impact is negligible.
const TTL_SECONDS = 24 * 60 * 60;
function key(previewId: string): string {
return `preview:${previewId}`;

View File

@@ -1,6 +1,15 @@
import type { Plan } from '@bmm/llm';
import { Queue } from 'bullmq';
import { getRedis } from './redis.js';
// BullMQ priority: LOWER number = processed sooner. Paid tiers jump ahead of
// free in the shared build queue — this is what makes the "priority build
// queue" plan claim actually true.
const PLAN_PRIORITY: Record<Plan, number> = { enterprise: 1, team: 2, pro: 3, hobby: 4 };
export function buildPriority(plan: Plan): number {
return PLAN_PRIORITY[plan] ?? 4;
}
export interface BuildJobData {
buildId: string;
serverId: string;
@@ -17,7 +26,14 @@ let queue: Queue<BuildJobData> | null = null;
export function getBuildQueue(): Queue<BuildJobData> {
if (!queue) {
queue = new Queue<BuildJobData>('build', { connection: getRedis() });
queue = new Queue<BuildJobData>('build', {
connection: getRedis(),
// Explicit job lifecycle. attempts:1 because a build is non-idempotent
// (allocates a host port, runs a container, spends an LLM call) — a blind
// BullMQ retry would double-spend; users re-run via /iterate instead.
// removeOnComplete/Fail caps Redis growth. (GEN-007)
defaultJobOptions: { attempts: 1, removeOnComplete: 100, removeOnFail: 500 },
});
}
return queue;
}

View File

@@ -0,0 +1,62 @@
import type { Plan } from '@bmm/llm';
import { getRedis } from './redis.js';
const DAY_SEC = 24 * 60 * 60;
function todayKey(): string {
return new Date().toISOString().slice(0, 10);
}
export interface RateLimitResult {
ok: boolean;
remaining: number;
resetIn: number;
}
/**
* Daily counter via Redis INCR. Atomic — no race window between read & write.
* First INCR (count === 1) sets the TTL so the key auto-rolls at midnight UTC.
*/
export async function checkDailyLimit(
scope: string,
userId: string,
max: number,
): Promise<RateLimitResult> {
const key = `ratelimit:${scope}:${userId}:${todayKey()}`;
const redis = getRedis();
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, DAY_SEC);
const ttl = await redis.ttl(key);
return {
ok: count <= max,
remaining: Math.max(0, max - count),
resetIn: ttl > 0 ? ttl : DAY_SEC,
};
}
// Per-tier daily limits on the two LLM-priced actions.
// Preview = ~€0.002-0.115/call (model-dependent) · Build = ~€0.005-0.22/call.
//
// Caps are set so that even a max-usage power-user stays profitable at the
// tier's price point. Critical for Team/Enterprise where Sonnet/Opus tokens
// add up fast — a runaway Bot with a Team subscription could otherwise
// out-cost the €199 monthly revenue. Math (max-case):
// Pro: 40 prev × €0.020 × 30 = €24/mo → margin €25 (~50%)
// Team: 50 prev × €0.058 × 30 = €87/mo → margin €112 (~56%)
// Enterprise: 200 prev × €0.060 × 30 = €360/mo → margin €639 (~64%)
// Build caps are looser because the 24h cache TTL means most builds are
// cache-HITS (no LLM call) — the cap is mostly about runner-port / hosting
// budget, not token cost.
export const PREVIEW_DAILY_LIMIT: Record<Plan, number> = {
hobby: 5,
pro: 40,
team: 50,
enterprise: 200,
};
export const BUILD_DAILY_LIMIT: Record<Plan, number> = {
hobby: 3,
pro: 20,
team: 30,
enterprise: 100,
};

30
apps/api/src/lib/sms.ts Normal file
View File

@@ -0,0 +1,30 @@
import { config } from '../config.js';
/** True when Twilio credentials + a sender number are all configured. */
export function smsConfigured(): boolean {
return Boolean(config.TWILIO_ACCOUNT_SID && config.TWILIO_AUTH_TOKEN && config.TWILIO_SMS_FROM);
}
/** Send an SMS via the Twilio REST API (no SDK — a single authenticated POST). */
export async function sendSms(to: string, body: string): Promise<void> {
const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_SMS_FROM } = config;
if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN || !TWILIO_SMS_FROM) {
throw new Error('sms_not_configured');
}
const auth = Buffer.from(`${TWILIO_ACCOUNT_SID}:${TWILIO_AUTH_TOKEN}`).toString('base64');
const res = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${TWILIO_ACCOUNT_SID}/Messages.json`,
{
method: 'POST',
headers: {
authorization: `Basic ${auth}`,
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ To: to, From: TWILIO_SMS_FROM, Body: body }),
},
);
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`twilio_${res.status}: ${detail.slice(0, 180)}`);
}
}

109
apps/api/src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,109 @@
import type { Plan } from '@bmm/llm';
import Stripe from 'stripe';
import { config } from '../config.js';
import { getRedis } from './redis.js';
/**
* Stripe client (null when no secret key is configured — e.g. in dev/test).
* `apiVersion` pinned to the current default to avoid silent breakage when
* Stripe rolls out new defaults.
*/
export const stripe: Stripe | null = config.STRIPE_SECRET_KEY
? new Stripe(config.STRIPE_SECRET_KEY, {
// Must match the version the installed SDK (stripe@22) is built against —
// its types expose ui_mode: 'embedded_page', which only exists from this
// version on. Pinning the older '2025-10-29.acacia' made Stripe reject the
// embedded checkout create call (acacia still used ui_mode: 'embedded').
apiVersion: '2026-04-22.dahlia',
typescript: true,
// Fail fast + visibly. Without a tight timeout, a wedged Stripe call (bad
// version, egress hiccup) hangs past Cloudflare's ~100s edge limit, and
// CF returns its own 5xx WITHOUT our CORS headers — which surfaces in the
// browser as an opaque "No Access-Control-Allow-Origin" error instead of
// the real failure. 20s keeps us well inside the edge limit so the handler
// returns a proper 502 (with CORS) the client can actually read.
timeout: 20_000,
maxNetworkRetries: 2,
})
: null;
export type PriceTier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
export function priceIdForTier(tier: PriceTier): string | undefined {
switch (tier) {
case 'pro_monthly':
return config.STRIPE_PRICE_PRO_MONTHLY;
case 'pro_yearly':
return config.STRIPE_PRICE_PRO_YEARLY;
case 'team_monthly':
return config.STRIPE_PRICE_TEAM_MONTHLY;
case 'team_yearly':
return config.STRIPE_PRICE_TEAM_YEARLY;
}
}
/** Reverse map: which plan does a Stripe price id belong to. Unknown → hobby. */
export function planFromPriceId(priceId: string | undefined): Plan {
if (!priceId) return 'hobby';
if (
priceId === config.STRIPE_PRICE_PRO_MONTHLY ||
priceId === config.STRIPE_PRICE_PRO_YEARLY
) {
return 'pro';
}
if (
priceId === config.STRIPE_PRICE_TEAM_MONTHLY ||
priceId === config.STRIPE_PRICE_TEAM_YEARLY
) {
return 'team';
}
return 'hobby';
}
/**
* Idempotency for Stripe webhooks. Stripe retries failed deliveries — we must
* dedupe by event.id or we'd e.g. double-cancel a subscription. SET NX with a
* 7-day TTL covers Stripe's full retry window.
*
* Returns true if this event was already processed (caller should skip).
*/
export async function isDuplicateEvent(eventId: string): Promise<boolean> {
const redis = getRedis();
const key = `stripe:event:${eventId}`;
const set = await redis.set(key, '1', 'EX', 7 * 24 * 60 * 60, 'NX');
return set === null;
}
/**
* Roll back the idempotency marker for an event whose handler FAILED, so
* Stripe's retry re-processes it. Without this, the marker set by the failed
* first attempt makes every retry look like a duplicate and the event is lost
* forever (e.g. a paid org that never gets upgraded). (BILL-003)
*/
export async function clearProcessedEvent(eventId: string): Promise<void> {
const redis = getRedis();
await redis.del(`stripe:event:${eventId}`);
}
/**
* Sanity-check that price-id env vars actually contain price ids — a common
* setup mistake is to paste the product id (prod_…) instead. Logs loudly on
* boot so we discover misconfiguration before the first checkout attempt.
*/
export function validateStripePriceConfig(log: { warn: (msg: string) => void }): void {
const checks: Array<[string, string | undefined]> = [
['STRIPE_PRICE_PRO_MONTHLY', config.STRIPE_PRICE_PRO_MONTHLY],
['STRIPE_PRICE_PRO_YEARLY', config.STRIPE_PRICE_PRO_YEARLY],
['STRIPE_PRICE_TEAM_MONTHLY', config.STRIPE_PRICE_TEAM_MONTHLY],
['STRIPE_PRICE_TEAM_YEARLY', config.STRIPE_PRICE_TEAM_YEARLY],
];
for (const [name, value] of checks) {
if (!value) continue;
if (!value.startsWith('price_')) {
log.warn(
`[stripe] ${name} does not start with "price_" (got "${value.slice(0, 6)}…") — ` +
'Stripe Checkout will reject this. Paste the PRICE id (price_…) from the product page, not the product id (prod_…).',
);
}
}
}

View File

@@ -0,0 +1,269 @@
import {
auditLog,
builds,
createDb,
desc,
eq,
inArray,
mcpServers,
memberships,
organizations,
supportMessages,
supportTickets,
users,
} from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js';
import { stopContainer } from '../lib/docker.js';
import { requireAuth } from '../plugins/session.js';
const SESSION_COOKIE = 'bmm_session';
const db = createDb();
export async function accountRoutes(app: FastifyInstance): Promise<void> {
// ─── Profile: read + update ───────────────────────────────────────────
app.get('/v1/account/profile', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [row] = await db
.select({
id: users.id,
email: users.email,
name: users.name,
phone: users.phone,
isAdmin: users.isAdmin,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, user.userId))
.limit(1);
if (!row) return reply.code(404).send({ error: 'user_not_found' });
return reply.send({ profile: row });
});
app.patch('/v1/account/profile', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Body = z.object({
name: z.string().min(1).max(128).optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
if (!parsed.data.name) return reply.send({ ok: true, changed: false });
await db
.update(users)
.set({ name: parsed.data.name })
.where(eq(users.id, user.userId));
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'account.profile_updated',
resourceType: 'user',
ipAddress: req.ip,
});
return reply.send({ ok: true, changed: true });
});
/**
* GDPR Art. 15 / Swiss DSG Art. 25 — right of access. Returns every record
* we hold that belongs to the calling user. Excludes hashed passwords,
* encrypted secret payloads, and any other user's data. Streamed as JSON
* attachment so the browser downloads it directly.
*/
app.get('/v1/account/export', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [userRow] = await db.select().from(users).where(eq(users.id, user.userId)).limit(1);
const [org] = await db
.select()
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
const orgServers = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.orgId, user.orgId));
const serverIds = orgServers.map((s) => s.id);
const orgBuilds =
serverIds.length > 0
? await db.select().from(builds).where(inArray(builds.serverId, serverIds))
: [];
const userAudit = await db
.select()
.from(auditLog)
.where(eq(auditLog.userId, user.userId))
.orderBy(desc(auditLog.createdAt))
.limit(1000);
const userTickets = await db
.select()
.from(supportTickets)
.where(eq(supportTickets.userId, user.userId));
const ticketIds = userTickets.map((t) => t.id);
const userTicketMessages =
ticketIds.length > 0
? await db
.select()
.from(supportMessages)
.where(inArray(supportMessages.ticketId, ticketIds))
: [];
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'account.export',
resourceType: 'account',
ipAddress: req.ip,
});
reply
.header('Content-Type', 'application/json; charset=utf-8')
.header(
'Content-Disposition',
`attachment; filename="buildmymcpserver-export-${Date.now()}.json"`,
);
return reply.send({
exportedAt: new Date().toISOString(),
_format: 'BuildMyMCPServer Account Export v1',
_excluded: [
'password hashes',
'encrypted secret payloads',
'session tokens',
'other users in the same organization',
],
user: userRow
? {
id: userRow.id,
email: userRow.email,
name: userRow.name,
phone: userRow.phone,
isAdmin: userRow.isAdmin,
createdAt: userRow.createdAt,
}
: null,
organization: org
? {
id: org.id,
slug: org.slug,
name: org.name,
plan: org.plan,
createdAt: org.createdAt,
}
: null,
servers: orgServers.map((s) => ({
id: s.id,
slug: s.slug,
name: s.name,
status: s.status,
publicUrl: s.publicUrl,
toolsSchema: s.toolsSchema,
createdAt: s.createdAt,
})),
builds: orgBuilds.map((b) => ({
id: b.id,
serverId: b.serverId,
version: b.version,
prompt: b.prompt,
status: b.status,
createdAt: b.createdAt,
})),
audit: userAudit.map((a) => ({
id: a.id,
action: a.action,
resourceType: a.resourceType,
resourceId: a.resourceId,
metadata: a.metadata,
ipAddress: a.ipAddress,
createdAt: a.createdAt,
})),
supportTickets: userTickets,
supportMessages: userTicketMessages,
});
});
/**
* GDPR Art. 17 / Swiss DSG Art. 32 — right to erasure. Self-service account
* deletion. Requires the caller to re-type their email (or phone) as a
* confirmation guard. For every org where the caller is the SOLE member, we
* stop its running containers and hard-delete the org (FK cascade removes its
* servers, builds, logs and encrypted secrets). Orgs with other members are
* left intact — only the caller's membership goes. Finally the user row is
* deleted (cascade drops sessions; audit/ticket/template refs are set null).
*/
app.delete('/v1/account', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Body = z.object({ confirm: z.string().min(1) });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const [row] = await db
.select({ email: users.email, phone: users.phone })
.from(users)
.where(eq(users.id, user.userId))
.limit(1);
if (!row) return reply.code(404).send({ error: 'user_not_found' });
// Confirmation: must match the account's own email or phone.
const expected = (row.email ?? row.phone ?? '').trim().toLowerCase();
if (!expected || parsed.data.confirm.trim().toLowerCase() !== expected) {
return reply.code(400).send({
error: 'confirm_mismatch',
detail: 'Type your account email (or phone) exactly to confirm deletion.',
});
}
const memberRows = await db
.select({ orgId: memberships.orgId })
.from(memberships)
.where(eq(memberships.userId, user.userId));
const orgIds = [...new Set(memberRows.map((m) => m.orgId))];
const deletedOrgIds: string[] = [];
for (const orgId of orgIds) {
const members = await db
.select({ userId: memberships.userId })
.from(memberships)
.where(eq(memberships.orgId, orgId));
// Only erase the org if this user is its sole member — never nuke a
// teammate's data. (Multi-member orgs: just the membership is dropped
// when the user row is deleted below.)
if (members.length > 1) continue;
// Stop live containers before the cascade removes their DB rows.
const servers = await db
.select({ containerId: mcpServers.containerId, slug: mcpServers.slug })
.from(mcpServers)
.where(eq(mcpServers.orgId, orgId));
for (const s of servers) {
if (s.containerId) {
try {
await stopContainer(s.containerId, s.slug ?? undefined);
} catch {
// best-effort — a leftover container must not block erasure
}
}
}
await db.delete(organizations).where(eq(organizations.id, orgId));
deletedOrgIds.push(orgId);
}
// Audit while userId is still valid (the row survives erasure; its userId
// is set null by cascade). No orgId — those rows are already gone.
await audit({
userId: user.userId,
action: 'account.deleted',
resourceType: 'account',
metadata: { deletedOrgIds, email: row.email ?? null },
ipAddress: req.ip,
});
// Delete the user — cascade removes sessions; nulls audit/ticket/template refs.
await db.delete(users).where(eq(users.id, user.userId));
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return reply.send({ ok: true, deletedOrgIds });
});
}

View File

@@ -1,6 +1,4 @@
import type { FastifyInstance } from 'fastify';
import { spawn } from 'node:child_process';
import { z } from 'zod';
import {
adminSettings,
auditLog,
@@ -20,10 +18,13 @@ import {
users,
} from '@bmm/db';
import { SYSTEM_PROMPT } from '@bmm/llm';
import { requireAdmin } from '../plugins/session.js';
import { getRedis } from '../lib/redis.js';
import { getBuildQueue } from '../lib/queue.js';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js';
import { encryptionStatus, rotateKeys } from '../lib/crypto.js';
import { getBuildQueue } from '../lib/queue.js';
import { getRedis } from '../lib/redis.js';
import { requireAdmin } from '../plugins/session.js';
const db = createDb();
@@ -46,11 +47,26 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
newUsersLast7d,
newServersLast7d,
] = await Promise.all([
db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(toolCallMetrics).then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(users)
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(organizations)
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(mcpServers)
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(builds)
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(toolCallMetrics)
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(mcpServers)
@@ -80,11 +96,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
.groupBy(mcpServers.status);
// Recent activity from audit log
const recent = await db
.select()
.from(auditLog)
.orderBy(desc(auditLog.createdAt))
.limit(15);
const recent = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(15);
// Builds in last 24h with status
const recentBuilds = await db
@@ -122,12 +134,20 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(parsed.data.limit);
const rows = await db
.select()
.from(users)
.orderBy(desc(users.createdAt))
.limit(parsed.data.limit);
const filtered = parsed.data.search
? rows.filter((u) =>
u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) ||
(u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false),
)
? rows.filter((u) => {
const q = parsed.data.search!.toLowerCase();
return (
(u.email?.toLowerCase().includes(q) ?? false) ||
(u.phone?.toLowerCase().includes(q) ?? false) ||
(u.name?.toLowerCase().includes(q) ?? false)
);
})
: rows;
// attach org + server count
@@ -279,7 +299,12 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
const rows = await db
.select({
server: mcpServers,
org: { id: organizations.id, name: organizations.name, slug: organizations.slug, plan: organizations.plan },
org: {
id: organizations.id,
name: organizations.name,
slug: organizations.slug,
plan: organizations.plan,
},
})
.from(mcpServers)
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
@@ -298,7 +323,11 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
const p = Params.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
const [server] = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.id, p.data.id))
.limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' });
// Get last build's prompt
@@ -356,7 +385,11 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
const p = Params.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
const [server] = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.id, p.data.id))
.limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' });
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
@@ -467,10 +500,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
redisOk = pong === 'PONG';
const q = getBuildQueue();
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
queueDepth =
(counts.waiting ?? 0) +
(counts.active ?? 0) +
(counts.delayed ?? 0);
queueDepth = (counts.waiting ?? 0) + (counts.active ?? 0) + (counts.delayed ?? 0);
} catch {
// remains false
}
@@ -565,5 +595,28 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
return reply.send({ ok: true });
});
// ---- Encryption: status + key rotation ----
app.get('/v1/admin/encryption', { preHandler: requireAdmin }, async (_req, reply) => {
return reply.send(await encryptionStatus());
});
app.post('/v1/admin/encryption/rotate', { preHandler: requireAdmin }, async (req, reply) => {
try {
const result = await rotateKeys(req.user!.userId);
await audit({
orgId: req.user!.orgId,
userId: req.user!.userId,
action: 'admin.encryption.rotate',
resourceType: 'encryption_key',
metadata: { newVersion: result.newVersion, reEncrypted: result.reEncrypted },
ipAddress: req.ip,
});
return reply.send({ ok: true, ...result });
} catch (err) {
app.log.error({ err }, 'encryption key rotation failed');
return reply.code(500).send({ error: 'rotation_failed', detail: (err as Error).message });
}
});
void inArray; // referenced for future bulk operations
}

View File

@@ -1,28 +1,154 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import crypto from 'node:crypto';
import {
consumeMagicLink,
consumeSmsCode,
destroySession,
getSession,
issueMagicLink,
issueSmsCode,
loginWithPassword,
upsertOAuthLogin,
} from '@bmm/auth';
import { audit } from '../lib/audit.js';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { getOrgPlan } from '../lib/plan.js';
import { checkDailyLimit } from '../lib/rate-limit.js';
import { sendSms, smsConfigured } from '../lib/sms.js';
const SESSION_COOKIE = 'bmm_session';
const OAUTH_STATE_COOKIE = 'bmm_oauth_state';
/**
* Single source of truth for the session cookie's flags. setCookie AND
* clearCookie MUST agree on (path, sameSite, secure, httpOnly) — when they
* drift, Chrome treats the clear directive as a brand-new cookie with
* different security attributes and silently leaves the original one in
* place. That's what bit us until now: logout looked successful (200 OK
* with a Set-Cookie clear) but the real session cookie persisted because
* the clear omitted Secure+HttpOnly.
*/
function sessionCookieOpts(): {
httpOnly: true;
sameSite: 'lax';
path: '/';
secure: boolean;
} {
return {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
};
}
const GoogleClaims = z.object({
iss: z.string(),
aud: z.string(),
exp: z.number(),
email: z.string().email(),
email_verified: z.union([z.boolean(), z.string()]).optional(),
name: z.string().optional(),
});
/**
* Decode (NOT signature-verify) a Google ID token payload. Signature verification
* is unnecessary here because the token is fetched directly from Google's token
* endpoint over TLS, authenticated with our client secret — an intermediary-free
* channel, per Google's own guidance. We still validate iss / aud / exp / email
* below as defense-in-depth.
*/
function decodeGoogleIdToken(idToken: string): z.infer<typeof GoogleClaims> {
const parts = idToken.split('.');
if (parts.length !== 3 || !parts[1]) throw new Error('malformed_id_token');
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
return GoogleClaims.parse(JSON.parse(json));
}
function googleRedirectUri(): string {
return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/google/callback`;
}
function googleConfigured(): boolean {
return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET);
}
function githubConfigured(): boolean {
return Boolean(config.GITHUB_OAUTH_ID && config.GITHUB_OAUTH_SECRET);
}
function githubRedirectUri(): string {
return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/github/callback`;
}
// In-memory per-IP throttle for SMS-code requests — SMS costs money per send,
// so cap how often one IP can trigger a send regardless of which number.
const smsIpHits = new Map<string, number[]>();
function smsIpRateOk(ip: string, max = 5, windowMs = 10 * 60 * 1000): boolean {
const now = Date.now();
const hits = (smsIpHits.get(ip) ?? []).filter((t) => now - t < windowMs);
if (hits.length >= max) {
smsIpHits.set(ip, hits);
return false;
}
hits.push(now);
smsIpHits.set(ip, hits);
return true;
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/v1/auth/magic-link', async (req, reply) => {
// Email auth is off by default — no SMTP wired yet. Closes the
// account-takeover-via-magic-link path (Za-001) until an email sender
// is configured AND a primaryProvider column lets us bind users to a
// single login method.
if (!config.EMAIL_AUTH_ENABLED) {
return reply.code(503).send({
error: 'email_auth_disabled',
detail: 'Email login is currently unavailable. Use Google, GitHub, or SMS.',
});
}
const Body = z.object({ email: z.string().email() });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' });
// Two-axis rate-limit: per-IP (prevents IP-flooding the endpoint) and
// per-email (prevents inbox-flooding a specific target). Both required
// because the IP cap protects us, the email cap protects the recipient.
const ipOk = await checkDailyLimit('magic_ip', req.ip, 10);
if (!ipOk.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: 'Too many magic-link requests from this IP. Try again tomorrow.',
});
}
const emailOk = await checkDailyLimit('magic_email', parsed.data.email.toLowerCase(), 5);
if (!emailOk.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: 'Too many magic-link requests for this email. Try again tomorrow.',
});
}
try {
const { token, expiresAt } = await issueMagicLink(parsed.data.email);
const callbackUrl = `${config.NEXT_PUBLIC_APP_URL}/login/callback?token=${token}`;
// Dev transport: print to stdout. Production: send via Resend / SES.
// In dev we print the link to stdout so the developer can click it.
// In production we must NEVER log the full token — anyone with
// `docker logs` access would silently impersonate any user.
if (config.NODE_ENV !== 'production') {
app.log.info({ to: parsed.data.email, expiresAt }, `[magic-link] -> ${callbackUrl}`);
console.log(`\n[magic-link] ${parsed.data.email} ->\n ${callbackUrl}\n`);
} else {
app.log.info(
{ to: parsed.data.email, expiresAt },
'[magic-link] issued (URL withheld from logs)',
);
// TODO(launch): hook up Resend / SES here. Until then, production
// magic-link is effectively dead — fail loud rather than silent.
app.log.error('magic-link email sender not configured — link cannot reach user');
}
return reply.send({ ok: true });
} catch (e) {
app.log.error(e);
@@ -31,6 +157,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
});
app.post('/v1/auth/verify', async (req, reply) => {
if (!config.EMAIL_AUTH_ENABLED) {
return reply.code(503).send({ error: 'email_auth_disabled' });
}
const Body = z.object({ token: z.string().min(10) });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_token' });
@@ -68,7 +197,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const token = req.cookies[SESSION_COOKIE];
const session = await getSession(token);
if (!session) return reply.code(401).send({ error: 'unauthorized' });
return reply.send({ user: session });
// Plan is on the org, not the session — look it up fresh so a Stripe
// upgrade is reflected without forcing a re-login.
const plan = await getOrgPlan(session.orgId);
return reply.send({ user: { ...session, plan } });
});
app.post('/v1/auth/admin/login', async (req, reply) => {
@@ -120,7 +252,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const token = req.cookies[SESSION_COOKIE];
const session = token ? await getSession(token) : null;
if (token) await destroySession(token);
reply.clearCookie(SESSION_COOKIE, { path: '/' });
reply.clearCookie(SESSION_COOKIE, sessionCookieOpts());
if (session) {
await audit({
orgId: session.orgId,
@@ -132,4 +264,294 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}
return reply.send({ ok: true });
});
// Which login providers are configured. Lets the UI hide buttons + forms
// when their backing infra isn't wired. `email` defaults to false because
// we haven't bought an SMTP provider yet — flipping EMAIL_AUTH_ENABLED to
// true re-enables the magic-link form section.
app.get('/v1/auth/providers', async (_req, reply) => {
return reply.send({
google: googleConfigured(),
github: githubConfigured(),
sms: smsConfigured(),
email: config.EMAIL_AUTH_ENABLED,
});
});
// Step 1: hand the browser off to Google's consent screen.
app.get('/v1/auth/google', async (_req, reply) => {
if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
return reply.code(503).send({ error: 'google_oauth_not_configured' });
}
const state = crypto.randomBytes(16).toString('base64url');
reply.setCookie(OAUTH_STATE_COOKIE, state, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 600,
});
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
url.searchParams.set('client_id', config.GOOGLE_OAUTH_ID);
url.searchParams.set('redirect_uri', googleRedirectUri());
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('state', state);
url.searchParams.set('access_type', 'online');
url.searchParams.set('prompt', 'select_account');
return reply.redirect(url.toString());
});
// Step 2: Google redirects back here with an auth code. Exchange it, verify
// the ID token, mint a session, drop the user on the dashboard.
app.get('/v1/auth/google/callback', async (req, reply) => {
const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
const Query = z.object({
code: z.string().min(10).optional(),
state: z.string().min(8).optional(),
error: z.string().optional(),
});
const q = Query.safeParse(req.query);
const cookieState = req.cookies[OAUTH_STATE_COOKIE];
reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });
if (!q.success || q.data.error || !q.data.code || !q.data.state) {
return reply.redirect(`${loginUrl}?error=google_failed`);
}
// CSRF: the state echoed back by Google must match the one we set.
// Length-check first — timingSafeEqual throws on a length mismatch.
if (
!cookieState ||
cookieState.length !== q.data.state.length ||
!crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
) {
return reply.redirect(`${loginUrl}?error=google_state`);
}
if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
return reply.redirect(`${loginUrl}?error=google_failed`);
}
try {
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code: q.data.code,
client_id: config.GOOGLE_OAUTH_ID,
client_secret: config.GOOGLE_OAUTH_SECRET,
redirect_uri: googleRedirectUri(),
grant_type: 'authorization_code',
}),
});
if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
const tokens = (await tokenRes.json()) as { id_token?: string };
if (!tokens.id_token) throw new Error('no_id_token');
const claims = decodeGoogleIdToken(tokens.id_token);
if (claims.iss !== 'accounts.google.com' && claims.iss !== 'https://accounts.google.com') {
throw new Error('bad_iss');
}
if (claims.aud !== config.GOOGLE_OAUTH_ID) throw new Error('bad_aud');
if (claims.exp * 1000 < Date.now()) throw new Error('token_expired');
const verified = claims.email_verified === true || claims.email_verified === 'true';
if (!verified) throw new Error('email_unverified');
const session = await upsertOAuthLogin(
{ email: claims.email, name: claims.name ?? null },
{ ipAddress: req.ip, userAgent: req.headers['user-agent'] },
);
reply.setCookie(SESSION_COOKIE, session.sessionToken, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60,
});
await audit({
orgId: session.orgId,
userId: session.userId,
action: 'auth.login',
resourceType: 'session',
metadata: { email: session.email, provider: 'google' },
ipAddress: req.ip,
});
return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
} catch (err) {
app.log.warn({ err }, 'google oauth callback failed');
return reply.redirect(`${loginUrl}?error=google_failed`);
}
});
// ---- GitHub OAuth ----
app.get('/v1/auth/github', async (_req, reply) => {
if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) {
return reply.code(503).send({ error: 'github_oauth_not_configured' });
}
const state = crypto.randomBytes(16).toString('base64url');
reply.setCookie(OAUTH_STATE_COOKIE, state, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 600,
});
const url = new URL('https://github.com/login/oauth/authorize');
url.searchParams.set('client_id', config.GITHUB_OAUTH_ID);
url.searchParams.set('redirect_uri', githubRedirectUri());
url.searchParams.set('scope', 'read:user user:email');
url.searchParams.set('state', state);
return reply.redirect(url.toString());
});
app.get('/v1/auth/github/callback', async (req, reply) => {
const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
const Query = z.object({
code: z.string().min(8).optional(),
state: z.string().min(8).optional(),
error: z.string().optional(),
});
const q = Query.safeParse(req.query);
const cookieState = req.cookies[OAUTH_STATE_COOKIE];
reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });
if (!q.success || q.data.error || !q.data.code || !q.data.state) {
return reply.redirect(`${loginUrl}?error=github_failed`);
}
if (
!cookieState ||
cookieState.length !== q.data.state.length ||
!crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
) {
return reply.redirect(`${loginUrl}?error=github_state`);
}
if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) {
return reply.redirect(`${loginUrl}?error=github_failed`);
}
try {
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: config.GITHUB_OAUTH_ID,
client_secret: config.GITHUB_OAUTH_SECRET,
code: q.data.code,
redirect_uri: githubRedirectUri(),
}),
});
if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
const tokens = (await tokenRes.json()) as { access_token?: string };
if (!tokens.access_token) throw new Error('no_access_token');
// GitHub's API rejects requests without a User-Agent header.
const ghHeaders = {
authorization: `Bearer ${tokens.access_token}`,
accept: 'application/vnd.github+json',
'user-agent': 'BuildMyMCPServer',
};
const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders });
if (!userRes.ok) throw new Error(`user_fetch_${userRes.status}`);
const ghUser = (await userRes.json()) as { name?: string; login?: string };
// /user omits the email when it is private — /user/emails always lists it.
const emailRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders });
if (!emailRes.ok) throw new Error(`email_fetch_${emailRes.status}`);
const emails = (await emailRes.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
}>;
const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified);
if (!primary) throw new Error('no_verified_email');
const session = await upsertOAuthLogin(
{ email: primary.email, name: ghUser.name ?? ghUser.login ?? null },
{ ipAddress: req.ip, userAgent: req.headers['user-agent'] },
);
reply.setCookie(SESSION_COOKIE, session.sessionToken, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60,
});
await audit({
orgId: session.orgId,
userId: session.userId,
action: 'auth.login',
resourceType: 'session',
metadata: { email: session.email, provider: 'github' },
ipAddress: req.ip,
});
return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
} catch (err) {
app.log.warn({ err }, 'github oauth callback failed');
return reply.redirect(`${loginUrl}?error=github_failed`);
}
});
// ---- SMS one-time-code login ----
app.post('/v1/auth/sms/request', async (req, reply) => {
if (!smsConfigured()) return reply.code(503).send({ error: 'sms_not_configured' });
const Body = z.object({ phone: z.string().min(8).max(24) });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_phone' });
if (!smsIpRateOk(req.ip)) return reply.code(429).send({ error: 'rate_limited' });
try {
const { phone, code } = await issueSmsCode(parsed.data.phone);
await sendSms(phone, `${code} is your BuildMyMCPServer login code. Valid for 10 minutes.`);
return reply.send({ ok: true });
} catch (e) {
const msg = (e as Error).message;
if (msg === 'invalid_phone') return reply.code(400).send({ error: 'invalid_phone' });
if (msg === 'rate_limited') return reply.code(429).send({ error: 'rate_limited' });
app.log.warn({ err: e }, 'sms request failed');
return reply.code(400).send({ error: 'sms_request_failed' });
}
});
app.post('/v1/auth/sms/verify', async (req, reply) => {
const Body = z.object({
phone: z.string().min(8).max(24),
code: z.string().regex(/^\d{6}$/),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
try {
const session = await consumeSmsCode(parsed.data.phone, parsed.data.code, {
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
reply.setCookie(SESSION_COOKIE, session.sessionToken, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60,
});
await audit({
orgId: session.orgId,
userId: session.userId,
action: 'auth.login',
resourceType: 'session',
metadata: { provider: 'sms' },
ipAddress: req.ip,
});
return reply.send({ ok: true, user: { id: session.userId, orgId: session.orgId } });
} catch (e) {
const msg = (e as Error).message;
const status: Record<string, number> = {
invalid_or_expired_code: 400,
invalid_code: 400,
too_many_attempts: 429,
invalid_phone: 400,
};
if (status[msg]) return reply.code(status[msg]).send({ error: msg });
app.log.warn({ err: e }, 'sms verify failed');
return reply.code(400).send({ error: 'sms_verify_failed' });
}
});
}

View File

@@ -0,0 +1,557 @@
import { createDb, eq, organizations } from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import {
type PriceTier,
clearProcessedEvent,
isDuplicateEvent,
planFromPriceId,
priceIdForTier,
stripe,
} from '../lib/stripe.js';
import { requireAuth } from '../plugins/session.js';
const db = createDb();
const TierBody = z.object({
tier: z.enum(['pro_monthly', 'pro_yearly', 'team_monthly', 'team_yearly']),
});
export async function billingRoutes(app: FastifyInstance): Promise<void> {
// ─── Checkout ────────────────────────────────────────────────────────────
app.post('/v1/billing/checkout-session', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const parsed = TierBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const priceId = priceIdForTier(parsed.data.tier as PriceTier);
if (!priceId) {
return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier });
}
const [org] = await db
.select({ stripeCustomerId: organizations.stripeCustomerId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org) return reply.code(404).send({ error: 'org_not_found' });
try {
const session = await stripe.checkout.sessions.create({
// Embedded UI: the payment form mounts INSIDE our dashboard via Stripe.js
// instead of redirecting to checkout.stripe.com. Keeps the flow in-app
// (critical for the installed PWA, which otherwise pops out to the
// system browser). Embedded mode uses return_url, not success/cancel_url.
// NOTE: stripe-node v22 / API 2025-10 renamed this enum 'embedded' →
// 'embedded_page'; it returns a client_secret for @stripe/react-stripe-js
// EmbeddedCheckout. ('hosted' is now 'hosted_page'.)
ui_mode: 'embedded_page',
mode: 'subscription',
payment_method_types: ['card', 'sepa_debit'],
line_items: [{ price: priceId, quantity: 1 }],
// Reuse Stripe customer if we have one — keeps invoices on one account
// even when the user upgrades/downgrades repeatedly.
...(org.stripeCustomerId
? { customer: org.stripeCustomerId }
: { customer_email: user.email ?? undefined }),
client_reference_id: user.orgId,
metadata: { orgId: user.orgId, userId: user.userId, tier: parsed.data.tier },
subscription_data: {
metadata: { orgId: user.orgId, userId: user.userId },
},
return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
billing_address_collection: 'required',
allow_promotion_codes: true,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.checkout_initiated',
resourceType: 'subscription',
metadata: { tier: parsed.data.tier },
ipAddress: req.ip,
});
// client_secret drives the embedded form; sessionId for optional verification.
return reply.send({ clientSecret: session.client_secret, sessionId: session.id });
} catch (err) {
app.log.error({ err }, 'checkout session create failed');
const msg = err instanceof Error ? err.message : 'unknown_error';
return reply.code(502).send({ error: 'checkout_failed', detail: msg });
}
});
// ─── Customer Portal ─────────────────────────────────────────────────────
app.post('/v1/billing/portal', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const [org] = await db
.select({ stripeCustomerId: organizations.stripeCustomerId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeCustomerId) {
return reply.code(409).send({
error: 'no_customer_yet',
detail: 'Subscribe first to access the billing portal.',
});
}
try {
const session = await stripe.billingPortal.sessions.create({
customer: org.stripeCustomerId,
return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing`,
});
return reply.send({ url: session.url });
} catch (err) {
app.log.error({ err }, 'portal session create failed');
return reply.code(502).send({ error: 'portal_failed' });
}
});
// ─── Billing status — drives the /settings/billing UI ────────────────────
// Combines our DB state (plan, suspension) with a live Stripe lookup of the
// subscription + recent invoices, so the page can render cancel buttons +
// invoice links inline without a second round-trip.
app.get('/v1/billing/status', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [org] = await db
.select({
plan: organizations.plan,
stripeCustomerId: organizations.stripeCustomerId,
stripeSubscriptionId: organizations.stripeSubscriptionId,
suspended: organizations.suspended,
suspendedReason: organizations.suspendedReason,
})
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org) return reply.code(404).send({ error: 'org_not_found' });
const base = {
plan: org.plan,
hasCustomer: Boolean(org.stripeCustomerId),
hasSubscription: Boolean(org.stripeSubscriptionId),
suspended: org.suspended,
suspendedReason: org.suspendedReason,
};
if (!stripe || !org.stripeSubscriptionId || !org.stripeCustomerId) {
return reply.send(base);
}
try {
const [sub, invoices] = await Promise.all([
stripe.subscriptions.retrieve(org.stripeSubscriptionId),
stripe.invoices.list({ customer: org.stripeCustomerId, limit: 5 }),
]);
const item = sub.items.data[0];
const price = item?.price;
// Stripe v2024-09 moved period boundaries onto subscription items;
// for our single-item subs they're equivalent to the old sub-level field.
const currentPeriodEnd = item?.current_period_end ?? 0;
return reply.send({
...base,
subscription: {
id: sub.id,
status: sub.status,
currentPeriodEnd,
cancelAtPeriodEnd: sub.cancel_at_period_end,
priceId: price?.id ?? null,
amount: price?.unit_amount ?? null,
currency: price?.currency ?? null,
interval: price?.recurring?.interval ?? null,
},
invoices: invoices.data.map((inv) => ({
id: inv.id,
number: inv.number,
status: inv.status,
amountPaid: inv.amount_paid,
currency: inv.currency,
created: inv.created,
pdfUrl: inv.invoice_pdf,
hostedUrl: inv.hosted_invoice_url,
})),
});
} catch (err) {
app.log.warn({ err }, 'stripe status fetch failed — returning db-only');
return reply.send({ ...base, _stripeError: true });
}
});
// ─── In-app cancellation (no portal redirect) ────────────────────────────
// Schedules cancellation at period end — user keeps paid features until the
// billing date already paid for, and Stripe automatically deletes the sub
// after that. The webhook handler converts that to plan='hobby'.
app.post('/v1/billing/cancel', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
const sub = await stripe.subscriptions.update(org.stripeSubscriptionId, {
cancel_at_period_end: true,
});
const cancelAt = sub.items.data[0]?.current_period_end ?? null;
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.cancel_scheduled',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
metadata: { cancelAt },
ipAddress: req.ip,
});
return reply.send({ ok: true, cancelAt });
} catch (err) {
app.log.error({ err }, 'cancel failed');
return reply.code(502).send({ error: 'cancel_failed' });
}
});
// ─── Reactivate (undo scheduled cancellation) ────────────────────────────
app.post('/v1/billing/reactivate', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
await stripe.subscriptions.update(org.stripeSubscriptionId, {
cancel_at_period_end: false,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.reactivated',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
ipAddress: req.ip,
});
return reply.send({ ok: true });
} catch (err) {
app.log.error({ err }, 'reactivate failed');
return reply.code(502).send({ error: 'reactivate_failed' });
}
});
// ─── In-app plan change (upgrade/downgrade between Pro/Team monthly/yearly)
app.post('/v1/billing/change-plan', { preHandler: requireAuth }, async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
const user = req.user!;
const parsed = TierBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const newPriceId = priceIdForTier(parsed.data.tier as PriceTier);
if (!newPriceId) {
return reply.code(503).send({ error: 'price_not_configured', tier: parsed.data.tier });
}
const [org] = await db
.select({ stripeSubscriptionId: organizations.stripeSubscriptionId })
.from(organizations)
.where(eq(organizations.id, user.orgId))
.limit(1);
if (!org?.stripeSubscriptionId) {
return reply.code(409).send({ error: 'no_active_subscription' });
}
try {
const current = await stripe.subscriptions.retrieve(org.stripeSubscriptionId);
const itemId = current.items.data[0]?.id;
if (!itemId) return reply.code(500).send({ error: 'subscription_item_missing' });
await stripe.subscriptions.update(org.stripeSubscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations',
});
// Reconcile the local plan immediately instead of waiting for the
// customer.subscription.updated webhook — otherwise quota enforcement
// reads a stale tier in the gap between this call and webhook delivery.
// Idempotent: the webhook will set the same value. (BILL-001)
await db
.update(organizations)
.set({ plan: planFromPriceId(newPriceId) })
.where(eq(organizations.id, user.orgId));
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'billing.plan_changed',
resourceType: 'subscription',
resourceId: org.stripeSubscriptionId,
metadata: { tier: parsed.data.tier },
ipAddress: req.ip,
});
return reply.send({ ok: true });
} catch (err) {
app.log.error({ err }, 'plan change failed');
return reply.code(502).send({ error: 'plan_change_failed' });
}
});
// ─── Webhook ─────────────────────────────────────────────────────────────
// Stripe signs the raw body — our index.ts content parser stashes the
// buffer on req.rawBody before JSON-parsing it for normal handlers.
app.post('/v1/billing/webhook', async (req, reply) => {
if (!stripe) return reply.code(503).send({ error: 'stripe_not_configured' });
if (!config.STRIPE_WEBHOOK_SECRET) {
app.log.error('webhook called without STRIPE_WEBHOOK_SECRET configured');
return reply.code(503).send({ error: 'webhook_not_configured' });
}
const signature = req.headers['stripe-signature'];
if (typeof signature !== 'string') {
return reply.code(400).send({ error: 'no_signature' });
}
const rawBody = (req as { rawBody?: Buffer }).rawBody;
if (!rawBody) {
app.log.error('webhook called without rawBody — content parser missing');
return reply.code(500).send({ error: 'no_raw_body' });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
config.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
app.log.warn({ err }, 'webhook signature verify failed');
return reply.code(400).send({ error: 'bad_signature' });
}
if (await isDuplicateEvent(event.id)) {
app.log.info({ eventId: event.id, type: event.type }, 'webhook duplicate, skipped');
return reply.send({ ok: true, deduped: true });
}
try {
await handleStripeEvent(app, event);
return reply.send({ ok: true });
} catch (err) {
// Roll back the idempotency marker so the retry actually re-runs the
// handler instead of being skipped as a duplicate. Handlers are
// idempotent (they SET state, not increment), so a rare double-process
// on concurrent retries is safe. (BILL-003)
await clearProcessedEvent(event.id);
// Return 5xx so Stripe retries with exponential backoff.
app.log.error(
{ err, eventId: event.id, type: event.type },
'webhook handler failed — Stripe will retry',
);
return reply.code(500).send({ error: 'handler_failed' });
}
});
}
// ─── Event dispatch ──────────────────────────────────────────────────────────
async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Promise<void> {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(app, event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(app, event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(app, event.data.object as Stripe.Subscription);
break;
case 'invoice.paid':
await handleInvoicePaid(app, event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(app, event.data.object as Stripe.Invoice);
break;
default:
app.log.debug({ type: event.type }, 'unhandled stripe event type');
}
}
async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> {
// Prefer the metadata we set at checkout — but DON'T blindly trust it. A
// webhook signature proves the event came from Stripe, not that
// sub.metadata.orgId is honest (metadata is editable in the dashboard/portal).
// Only honour the metadata orgId if the subscription's customer actually
// matches that org's stored stripeCustomerId; otherwise fall back to the
// customer lookup. This prevents a sub with a forged metadata.orgId from
// re-planning a victim org. (BILL-004)
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
const metaOrgId = sub.metadata?.orgId;
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) {
const [byMeta] = await db
.select({ id: organizations.id, customer: organizations.stripeCustomerId })
.from(organizations)
.where(eq(organizations.id, metaOrgId))
.limit(1);
if (byMeta && (byMeta.customer === null || byMeta.customer === customerId)) {
return byMeta.id;
}
// metadata orgId does not own this customer — ignore it and fall through.
}
const [row] = await db
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.stripeCustomerId, customerId))
.limit(1);
return row?.id ?? null;
}
async function findOrgIdForInvoice(invoice: Stripe.Invoice): Promise<string | null> {
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
if (!customerId) return null;
const [row] = await db
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.stripeCustomerId, customerId))
.limit(1);
return row?.id ?? null;
}
async function handleCheckoutCompleted(
app: FastifyInstance,
session: Stripe.Checkout.Session,
): Promise<void> {
const orgId = session.metadata?.orgId ?? session.client_reference_id ?? null;
if (!orgId) {
app.log.warn({ sessionId: session.id }, 'checkout completed without orgId');
return;
}
const customerId =
typeof session.customer === 'string' ? session.customer : session.customer?.id;
if (!customerId) return;
await db
.update(organizations)
.set({ stripeCustomerId: customerId })
.where(eq(organizations.id, orgId));
await audit({
orgId,
action: 'billing.checkout_completed',
resourceType: 'subscription',
metadata: { customerId, sessionId: session.id },
});
}
async function handleSubscriptionChange(
app: FastifyInstance,
sub: Stripe.Subscription,
): Promise<void> {
const orgId = await findOrgIdForSubscription(sub);
if (!orgId) {
app.log.warn({ subId: sub.id, customer: sub.customer }, 'sub change for unknown org');
return;
}
const priceId = sub.items.data[0]?.price.id;
const plan = planFromPriceId(priceId);
const active = sub.status === 'active' || sub.status === 'trialing';
const suspended = sub.status === 'past_due' || sub.status === 'unpaid';
await db
.update(organizations)
.set({
plan: active ? plan : 'hobby',
stripeSubscriptionId: sub.id,
suspended,
suspendedReason: suspended ? `subscription_${sub.status}` : null,
})
.where(eq(organizations.id, orgId));
await audit({
orgId,
action: 'billing.subscription_changed',
resourceType: 'subscription',
metadata: { plan, status: sub.status, subId: sub.id, priceId: priceId ?? null },
});
}
async function handleSubscriptionDeleted(
app: FastifyInstance,
sub: Stripe.Subscription,
): Promise<void> {
const orgId = await findOrgIdForSubscription(sub);
if (!orgId) {
app.log.warn({ subId: sub.id }, 'sub delete for unknown org');
return;
}
await db
.update(organizations)
.set({
plan: 'hobby',
stripeSubscriptionId: null,
suspended: false,
suspendedReason: null,
})
.where(eq(organizations.id, orgId));
await audit({
orgId,
action: 'billing.subscription_cancelled',
resourceType: 'subscription',
metadata: { subId: sub.id },
});
}
async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
const orgId = await findOrgIdForInvoice(invoice);
if (!orgId) return;
// Only the actual monthly renewal (`subscription_cycle`) resets the usage
// counter. Stripe also sends `invoice.paid` for proration/manual/one-off
// invoices (e.g. every plan up/downgrade); resetting on those would let a
// user zero their call quota on demand by churning plan changes. For
// non-cycle invoices we only clear a past-due suspension. (BILL-002)
const isRenewal = invoice.billing_reason === 'subscription_cycle';
await db
.update(organizations)
.set({
suspended: false,
suspendedReason: null,
...(isRenewal ? { callsThisPeriod: 0, periodStartsAt: new Date() } : {}),
})
.where(eq(organizations.id, orgId));
await audit({
orgId,
action: 'billing.invoice_paid',
resourceType: 'invoice',
metadata: { invoiceId: invoice.id ?? null, amountPaid: invoice.amount_paid ?? 0 },
});
}
async function handlePaymentFailed(
_app: FastifyInstance,
invoice: Stripe.Invoice,
): Promise<void> {
const orgId = await findOrgIdForInvoice(invoice);
if (!orgId) return;
const attempts = invoice.attempt_count ?? 0;
// Only suspend after the 3rd failed attempt — Stripe Smart Retries will keep
// trying for several days, so the user has time to update their card.
if (attempts >= 3) {
await db
.update(organizations)
.set({ suspended: true, suspendedReason: 'payment_failed' })
.where(eq(organizations.id, orgId));
}
await audit({
orgId,
action: 'billing.payment_failed',
resourceType: 'invoice',
metadata: { invoiceId: invoice.id ?? null, attempts },
});
}

View File

@@ -13,11 +13,18 @@ import {
oauthTokens,
} from '@bmm/db';
import { getJWKS, signAccessToken } from '../lib/jwks.js';
import { checkDailyLimit } from '../lib/rate-limit.js';
import { requireAuth } from '../plugins/session.js';
import { config } from '../config.js';
const db = createDb();
// Access-token lifetime is short so revocation propagates within the hour.
// Refresh-token lifetime is long so legitimate clients don't have to
// re-authorize daily; rotation on every refresh limits exposure if one leaks.
const ACCESS_TOKEN_TTL_S = 3600; // 1 hour
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
function sha256(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex');
}
@@ -33,24 +40,72 @@ function pkceVerify(verifier: string, challenge: string, method: string): boolea
async function resolveServerByResource(resource: string) {
const url = new URL(resource);
// Local direct runner URLs are addressed by host port, e.g.
// http://localhost:4103/mcp. Resolve those before path routing so the
// transport endpoint segment is not treated as a tenant slug.
const port = url.port ? Number(url.port) : null;
if (port !== null) {
const [s] = await db.select().from(mcpServers).where(eq(mcpServers.hostPort, port)).limit(1);
const [s] = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.hostPort, port))
.limit(1);
if (s) return s;
}
// Path routing (the prod topology on mcp.buildmymcpserver.com): the slug
// is the first path segment. Has to be checked BEFORE the subdomain
// heuristic below, otherwise we extract "mcp" from "mcp.example.com/<slug>/mcp"
// and look up the wrong server. Claude Desktop's RFC 8707 resource parameter
// matches the `resource` field we publish in RFC 9728 protected resource
// metadata, which is the path-routed MCP endpoint URL.
const firstSegment = url.pathname.split('/').filter(Boolean)[0];
if (firstSegment) {
const [s] = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.slug, firstSegment))
.limit(1);
if (s) return s;
}
// Subdomain routing — legacy / future <slug>.mcp.example.com setup.
const slug = url.hostname.split('.')[0];
if (slug) {
if (slug && slug !== 'mcp') {
const [s] = await db.select().from(mcpServers).where(eq(mcpServers.slug, slug)).limit(1);
if (s) return s;
}
return null;
}
export async function oauthRoutes(app: FastifyInstance): Promise<void> {
// Authorization Server Metadata (RFC 8414) — control-plane wide
app.get('/oauth/.well-known/oauth-authorization-server', async (_req, reply) => {
const base = `${config.CONTROL_PLANE_PUBLIC_URL}`;
return reply.send({
// Authorization Server Metadata (RFC 8414).
//
// Our issuer is `${CONTROL_PLANE_PUBLIC_URL}/oauth`. RFC 8414 §3 says the
// discovery URL is constructed by inserting "/.well-known/oauth-
// authorization-server" *between the host and the issuer path*, NOT by
// appending it after the path. So for issuer `https://api.example.com/oauth`
// the canonical location is `https://api.example.com/.well-known/oauth-
// authorization-server/oauth`.
//
// Claude Desktop's MCP SDK follows that strict construction. We previously
// only served the issuer-appended path (`/oauth/.well-known/...`), which
// is the historically-common but incorrect form, so Claude Desktop 404'd
// during discovery and reported "Registrierung beim Anmeldedienst
// fehlgeschlagen" without ever reaching the registration endpoint. We
// now serve every realistic variant pointing at the same metadata:
//
// - `/.well-known/oauth-authorization-server/oauth` — RFC 8414 strict
// - `/.well-known/oauth-authorization-server` — many clients try root
// - `/oauth/.well-known/oauth-authorization-server` — historical/Okta-style
// - `/.well-known/openid-configuration` — OIDC fallback
//
// The single source of truth is buildAsMetadata() so they cannot drift.
const buildAsMetadata = () => {
const base = config.CONTROL_PLANE_PUBLIC_URL;
return {
issuer: `${base}/oauth`,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
@@ -65,16 +120,34 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
'none',
],
scopes_supported: ['mcp:read', 'mcp:write'],
});
});
resource_parameter_supported: true,
};
};
const asMetadataHandler = async (_req: unknown, reply: { send: (body: unknown) => unknown }) =>
reply.send(buildAsMetadata());
app.get('/.well-known/oauth-authorization-server/oauth', asMetadataHandler);
app.get('/.well-known/oauth-authorization-server', asMetadataHandler);
app.get('/oauth/.well-known/oauth-authorization-server', asMetadataHandler);
app.get('/.well-known/openid-configuration', asMetadataHandler);
app.get('/oauth/jwks', async (_req, reply) => {
reply.header('cache-control', 'public, max-age=300');
return reply.send(await getJWKS());
});
// RFC 7591 Dynamic Client Registration
// RFC 7591 Dynamic Client Registration — rate-limited per-IP to prevent
// DB-row spam. 20/day per visitor IP is well above legitimate MCP-client
// bootstrap rates (each AI client registers once per resource server, ever).
// (Zb-002.)
app.post('/oauth/register', async (req, reply) => {
const rl = await checkDailyLimit('oauth_register', req.ip, 20);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: 'Too many client registrations from this IP. Try again tomorrow.',
});
}
const Body = z.object({
client_name: z.string().min(1).max(128).optional(),
redirect_uris: z.array(z.string().url()).min(1).max(10),
@@ -86,13 +159,20 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
// RFC 7591 makes `resource` optional in the registration request body.
// Claude Desktop and several other MCP clients perform a generic
// registration first and only declare the resource later during the
// authorization request (RFC 8707). When a resource is provided we
// bind the client to that server; otherwise we accept a generic
// registration and let /oauth/authorize enforce the resource → org
// check on every authorization. The token endpoint additionally
// pins the audience claim to the resource, so a generic client still
// can't mint a token usable against a server the user does not own.
let serverId: string | null = null;
if (parsed.data.resource) {
const server = await resolveServerByResource(parsed.data.resource);
if (!server) return reply.code(400).send({ error: 'invalid_resource' });
serverId = server.id;
} else {
return reply.code(400).send({ error: 'resource_required' });
}
const clientId = `bmm_${crypto.randomBytes(12).toString('hex')}`;
@@ -149,7 +229,16 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
if (!redirectOk) return reply.code(400).send({ error: 'invalid_redirect_uri' });
const server = await resolveServerByResource(parsed.data.resource);
if (!server || server.id !== client.serverId) {
if (!server) {
return reply.code(400).send({ error: 'invalid_resource' });
}
// Clients that registered against a specific server (`client.serverId`
// set) must keep authorizing against the same one. Clients that
// registered generically (`client.serverId === null`, e.g. Claude
// Desktop after RFC 7591 DCR) can authorize against any server the
// logged-in user actually owns — the org check below is the real
// boundary.
if (client.serverId !== null && server.id !== client.serverId) {
return reply.code(400).send({ error: 'invalid_resource' });
}
if (server.orgId !== user.orgId) {
@@ -190,6 +279,33 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
const parsed = Body.safeParse(body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
// RFC 6749 §2.3.1: confidential clients MAY authenticate via HTTP Basic
// (preferred) OR via client_id+client_secret in the request body. Our AS
// metadata advertises both `client_secret_basic` and `client_secret_post`,
// and Claude Desktop / most SDKs default to Basic. Without parsing the
// header here every Basic-style POST hit the "missing client_secret"
// branch and returned 401 invalid_client right after DCR succeeded —
// exactly matching the production log signature.
//
// Header credentials take precedence when present; body credentials are
// only used when the header is absent. Either form must reach the same
// validation paths below.
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Basic ')) {
try {
const decoded = Buffer.from(authHeader.slice(6).trim(), 'base64').toString('utf8');
const sep = decoded.indexOf(':');
if (sep > 0) {
const headerClientId = decodeURIComponent(decoded.slice(0, sep));
const headerSecret = decodeURIComponent(decoded.slice(sep + 1));
if (headerClientId) parsed.data.client_id = headerClientId;
if (headerSecret) parsed.data.client_secret = headerSecret;
}
} catch {
return reply.code(401).send({ error: 'invalid_client' });
}
}
if (parsed.data.grant_type === 'authorization_code') {
const { code, code_verifier, client_id, client_secret, redirect_uri, resource } = parsed.data;
if (!code || !code_verifier || !client_id || !redirect_uri || !resource) {
@@ -225,12 +341,13 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'invalid_grant' });
}
const subject = row.code.userId ?? row.client.clientId;
const accessToken = await signAccessToken({
subject: row.code.userId ?? row.client.clientId,
subject,
audience: resource,
issuer: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
scope: row.code.scope ?? '',
ttlSeconds: 3600,
ttlSeconds: ACCESS_TOKEN_TTL_S,
});
const refreshToken = crypto.randomBytes(32).toString('base64url');
await db.insert(oauthTokens).values({
@@ -239,18 +356,98 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
refreshTokenHash: sha256(refreshToken),
scope: row.code.scope ?? null,
resource,
expiresAt: new Date(Date.now() + 3600 * 1000),
subject,
// expiresAt is the REFRESH-token lifetime — 30 days. Access-token
// expiry lives inside the JWT's `exp` claim (1h, set above).
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
});
return reply.send({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
expires_in: ACCESS_TOKEN_TTL_S,
refresh_token: refreshToken,
scope: row.code.scope ?? '',
});
}
// ─── grant_type: refresh_token ─────────────────────────────────────
// OAuth 2.1 with rotation: every successful refresh issues a NEW refresh
// token and atomically invalidates the old one. If a stolen refresh token
// gets used after the legitimate client refreshed, the second use sees
// invalid_grant — that's how rotation surfaces token theft.
if (parsed.data.grant_type === 'refresh_token') {
const { refresh_token, client_id, client_secret, resource: requestedResource } =
parsed.data;
if (!refresh_token || !client_id) {
return reply.code(400).send({ error: 'invalid_request' });
}
const refreshHash = sha256(refresh_token);
const [row] = await db
.select({ token: oauthTokens, client: oauthClients })
.from(oauthTokens)
.innerJoin(oauthClients, eq(oauthClients.id, oauthTokens.clientDbId))
.where(
and(
eq(oauthTokens.refreshTokenHash, refreshHash),
gt(oauthTokens.expiresAt, new Date()),
),
)
.limit(1);
if (!row) return reply.code(400).send({ error: 'invalid_grant' });
if (row.client.clientId !== client_id) {
return reply.code(401).send({ error: 'invalid_client' });
}
if (row.client.clientSecretHash) {
if (!client_secret || sha256(client_secret) !== row.client.clientSecretHash) {
return reply.code(401).send({ error: 'invalid_client' });
}
}
// RFC 8707: requested resource must equal the stored one — refreshes
// don't allow audience changes (would be a downgrade/escalation vector).
if (requestedResource && requestedResource !== row.token.resource) {
return reply.code(400).send({ error: 'invalid_resource' });
}
const subject = row.token.subject ?? row.client.clientId;
const newAccessToken = await signAccessToken({
subject,
audience: row.token.resource ?? '',
issuer: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
scope: row.token.scope ?? '',
ttlSeconds: ACCESS_TOKEN_TTL_S,
});
const newRefreshToken = crypto.randomBytes(32).toString('base64url');
const newRefreshHash = sha256(newRefreshToken);
// Atomic rotation: UPDATE only succeeds if the row still has the OLD
// refresh-hash. Two parallel refreshes with the same token can't both
// win — the loser sees zero rows and gets invalid_grant.
const rotated = await db
.update(oauthTokens)
.set({
accessTokenHash: sha256(newAccessToken),
refreshTokenHash: newRefreshHash,
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
})
.where(
and(eq(oauthTokens.id, row.token.id), eq(oauthTokens.refreshTokenHash, refreshHash)),
)
.returning({ id: oauthTokens.id });
if (rotated.length === 0) {
return reply.code(400).send({ error: 'invalid_grant' });
}
return reply.send({
access_token: newAccessToken,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
refresh_token: newRefreshToken,
scope: row.token.scope ?? '',
});
}
return reply.code(400).send({ error: 'unsupported_grant_type' });
});
@@ -271,4 +468,3 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
});
});
}

View File

@@ -1,25 +1,48 @@
import { getSession } from '@bmm/auth';
import {
and,
buildLogs,
builds,
createDb,
desc,
eq,
mcpServers,
secrets,
sql,
templates,
} from '@bmm/db';
import {
BannedPatternError,
SpecTimeoutError,
SpecTruncatedError,
SpecValidationError,
generateSpec,
pickPreviewModel,
scanForInjection,
streamSpecFromAnthropic,
} from '@bmm/llm';
import {
BuildEvent,
CreateServerInput,
GeneratorSpec,
IterateServerInput,
PreviewInput,
type SpecEdit,
findSecretInPrompt,
} from '@bmm/types';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets, sql, templates } from '@bmm/db';
import { getSession } from '@bmm/auth';
import { stopContainer } from '../lib/docker.js';
import {
CreateServerInput,
IterateServerInput,
BuildEvent,
PreviewInput,
GeneratorSpec,
type SpecEdit,
} from '@bmm/types';
import { generateSpec, SpecValidationError, BannedPatternError } from '@bmm/llm';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { requireAuth } from '../plugins/session.js';
import { getBuildQueue } from '../lib/queue.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { encryptSecret } from '../lib/crypto.js';
import { audit } from '../lib/audit.js';
import { getForkRefTemplate } from './templates.js';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { encryptSecret } from '../lib/crypto.js';
import { stopContainer } from '../lib/docker.js';
import { SERVER_LIMITS, getOrgBilling } from '../lib/plan.js';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { buildPriority, getBuildQueue } from '../lib/queue.js';
import { BUILD_DAILY_LIMIT, PREVIEW_DAILY_LIMIT, checkDailyLimit } from '../lib/rate-limit.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { requireAuth } from '../plugins/session.js';
import { getForkRefTemplate } from './templates.js';
const db = createDb();
@@ -35,19 +58,68 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
});
app.post('/v1/servers/preview', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const parsed = PreviewInput.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
// Never let a credential reach the LLM. Reject prompts that contain a
// real-looking key/token before the model call. (Values belong in the
// separate encrypted credential fields, not the prompt.)
const leakedSecret = findSecretInPrompt(parsed.data.prompt);
if (leakedSecret) {
return reply.code(400).send({
error: 'secret_in_prompt',
detail: `Your prompt looks like it contains ${leakedSecret}. Remove it — API keys must never go in the prompt (it is sent to the AI model). You will add credentials in their own encrypted fields after the spec is generated.`,
});
}
const billing = await getOrgBilling(user.orgId);
if (billing.suspended) {
return reply.code(402).send({
error: 'subscription_suspended',
detail:
billing.suspendedReason === 'payment_failed'
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
: 'Your subscription is paused. Visit /settings/billing for details.',
suspendedReason: billing.suspendedReason,
});
}
const plan = billing.plan;
// Daily preview rate-limit per user. Free is tight (5/day) because every
// preview is a paid LLM call; paid tiers have headroom for real iteration.
const rl = await checkDailyLimit('preview', user.userId, PREVIEW_DAILY_LIMIT[plan]);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily preview limit reached for plan "${plan}" (${PREVIEW_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
plan,
limit: PREVIEW_DAILY_LIMIT[plan],
resetIn: rl.resetIn,
});
}
const choice = pickPreviewModel(plan);
try {
const { spec, source } = await generateSpec(parsed.data.prompt, {
provider: choice.provider,
apiKey: config.ANTHROPIC_API_KEY,
model: 'claude-opus-4-7',
glmApiKey: config.GLM_API_KEY,
model: choice.model,
maxTokens: choice.maxTokens,
timeoutMs: choice.timeoutMs,
maxRetries: 0,
});
const previewId = await cacheSpec(spec);
return reply.send({
previewId,
source,
plan,
modelDisplayName: choice.displayName,
modelBadge: choice.displayBadge,
upgradeHint: plan === 'hobby',
spec: {
name: spec.name,
description: spec.description,
@@ -62,23 +134,318 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
});
} catch (err) {
if (err instanceof SpecValidationError) {
// Log the actual Zod validation failure so we can see *which* field
// the model got wrong. Without this we can only see "422" in the
// logs and can't tell whether to fix the prompt, the schema, or
// the model output cleanup. Prompt is truncated to keep PII risk
// bounded.
app.log.warn(
{
zod_message: err.message,
prompt: parsed.data.prompt.slice(0, 200),
model: choice.displayName,
},
'preview_spec_invalid',
);
return reply.code(422).send({ error: 'spec_invalid', detail: err.message });
}
if (err instanceof BannedPatternError) {
app.log.warn(
{
reason: err.message,
prompt: parsed.data.prompt.slice(0, 200),
model: choice.displayName,
},
'preview_banned_pattern',
);
return reply.code(422).send({ error: 'banned_pattern', detail: err.message });
}
if (err instanceof SpecTimeoutError) {
return reply.code(504).send({
error: 'preview_timeout',
detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
});
}
if (err instanceof SpecTruncatedError) {
app.log.warn(
{
reason: err.message,
prompt: parsed.data.prompt.slice(0, 200),
model: choice.displayName,
},
'preview_spec_truncated',
);
return reply.code(422).send({
error: 'spec_too_large',
detail:
'The spec for this prompt exceeded the maximum response size. Split it into fewer tools or describe one capability per prompt.',
});
}
app.log.error(err);
return reply.code(500).send({ error: 'preview_failed', detail: (err as Error).message });
}
});
// Streaming preview — pipes the model's text deltas back as Server-Sent
// Events so Cloudflare's ~100s edge cap is irrelevant: every chunk we
// write resets the idle timer. The final event is either `spec` (success)
// or `error` (any of the typed errors raised by the LLM layer).
//
// Anthropic-only for now: GLM doesn't ship a clean streaming JSON
// contract that justifies the duplication, and hobby's 4096-token budget
// already fits the sync path comfortably. The route falls back to the
// sync endpoint if the request comes from a non-Anthropic tier.
app.post('/v1/servers/preview/stream', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const parsed = PreviewInput.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
// Never let a credential reach the LLM. Reject prompts that contain a
// real-looking key/token before the model call. (Values belong in the
// separate encrypted credential fields, not the prompt.)
const leakedSecret = findSecretInPrompt(parsed.data.prompt);
if (leakedSecret) {
return reply.code(400).send({
error: 'secret_in_prompt',
detail: `Your prompt looks like it contains ${leakedSecret}. Remove it — API keys must never go in the prompt (it is sent to the AI model). You will add credentials in their own encrypted fields after the spec is generated.`,
});
}
const billing = await getOrgBilling(user.orgId);
if (billing.suspended) {
return reply.code(402).send({
error: 'subscription_suspended',
detail:
billing.suspendedReason === 'payment_failed'
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
: 'Your subscription is paused. Visit /settings/billing for details.',
suspendedReason: billing.suspendedReason,
});
}
const plan = billing.plan;
const rl = await checkDailyLimit('preview', user.userId, PREVIEW_DAILY_LIMIT[plan]);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily preview limit reached for plan "${plan}" (${PREVIEW_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
plan,
limit: PREVIEW_DAILY_LIMIT[plan],
resetIn: rl.resetIn,
});
}
const choice = pickPreviewModel(plan);
if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) {
return reply.code(409).send({
error: 'streaming_unavailable',
detail:
'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
});
}
// SSE response. X-Accel-Buffering disables nginx's response buffering
// so each chunk lands at the client immediately rather than after the
// full response is built — critical for the keepalive-vs-CF-100s logic
// to actually work.
//
// CORS note: @fastify/cors injects Access-Control-Allow-* in the onSend
// hook, which never runs once we go straight to reply.raw — that's why
// the browser saw "blocked by CORS policy". Set the headers manually
// here, mirroring what the plugin would have added: credentials:true +
// the configured app origin. The exact reflected Origin is fine because
// we already pinned it in the cors plugin registration.
const origin = req.headers.origin;
if (origin && origin === config.NEXT_PUBLIC_APP_URL) {
reply.raw.setHeader('Access-Control-Allow-Origin', origin);
reply.raw.setHeader('Access-Control-Allow-Credentials', 'true');
reply.raw.setHeader('Vary', 'Origin');
}
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
reply.raw.flushHeaders();
const send = (event: string, data: unknown) => {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
// Heartbeat comment every 15s. Cloudflare's edge keeps the connection
// open as long as bytes flow; comments are SSE-noop but count as bytes.
const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000);
const abort = new AbortController();
req.raw.on('close', () => {
abort.abort();
clearInterval(keepalive);
});
// `resolved` is set inside the awaited handlers below — by the time
// streamSpecFromAnthropic returns, exactly one of onSpec/onError will
// have completed (handlers are awaited inside the llm package), so the
// post-stream fallback `if (!resolved)` only fires if the stream truly
// ended without either handler running (which would be a programming
// bug, not a runtime path).
let resolved = false;
try {
await streamSpecFromAnthropic(
parsed.data.prompt,
{
apiKey: config.ANTHROPIC_API_KEY,
model: choice.model,
maxTokens: choice.maxTokens,
signal: abort.signal,
},
{
onText: (delta) => send('text', delta),
onSpec: async ({ spec, source }) => {
const previewId = await cacheSpec(spec);
send('spec', {
previewId,
source,
plan,
modelDisplayName: choice.displayName,
modelBadge: choice.displayBadge,
upgradeHint: plan === 'hobby',
spec: {
name: spec.name,
description: spec.description,
tools: spec.tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
requiredSecrets: spec.requiredSecrets,
scopes: spec.scopes,
},
});
app.log.info(
{
previewId,
tools: spec.tools.length,
prompt: parsed.data.prompt.slice(0, 200),
model: choice.displayName,
},
'preview_spec_ready',
);
resolved = true;
},
onError: (err) => {
if (err instanceof SpecTruncatedError) {
app.log.warn(
{
reason: err.message,
prompt: parsed.data.prompt.slice(0, 200),
model: choice.displayName,
},
'preview_spec_truncated',
);
send('error', {
error: 'spec_too_large',
detail:
'The spec for this prompt exceeded the maximum response size. Split it into fewer tools or describe one capability per prompt.',
});
} else if (err instanceof SpecValidationError) {
app.log.warn(
{
zod_message: err.message,
prompt: parsed.data.prompt.slice(0, 200),
model: choice.displayName,
},
'preview_spec_invalid',
);
send('error', { error: 'spec_invalid', detail: err.message });
} else if (err instanceof BannedPatternError) {
send('error', { error: 'banned_pattern', detail: err.message });
} else if (err instanceof SpecTimeoutError) {
send('error', {
error: 'preview_timeout',
detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
});
} else {
app.log.error(err);
send('error', { error: 'preview_failed', detail: err.message });
}
resolved = true;
},
},
);
if (!resolved) {
app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved');
send('error', { error: 'preview_failed', detail: 'stream ended without a final event' });
}
} catch (err) {
// If the stream itself rejects (e.g. cacheSpec/Redis throws inside onSpec,
// or a network error before either handler runs) we must still tear down
// the keepalive timer and close the socket — otherwise the interval keeps
// writing to a dead connection forever, leaking a timer + FD per failure. (SRV-004)
app.log.error({ err, prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_threw');
if (!resolved) send('error', { error: 'preview_failed', detail: 'spec generation failed' });
} finally {
clearInterval(keepalive);
reply.raw.end();
}
});
app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const parsed = CreateServerInput.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
const { name, slug, prompt, secrets: secretValues, previewId, specEdit, templateId } = parsed.data;
const {
name,
slug,
prompt,
secrets: secretValues,
previewId,
specEdit,
templateId,
} = parsed.data;
// ---- Plan enforcement (must happen before any DB write) ----
const billing = await getOrgBilling(user.orgId);
if (billing.suspended) {
return reply.code(402).send({
error: 'subscription_suspended',
detail:
billing.suspendedReason === 'payment_failed'
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
: 'Your subscription is paused. Visit /settings/billing for details.',
suspendedReason: billing.suspendedReason,
});
}
const plan = billing.plan;
// Daily build rate-limit.
const rl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[plan]);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily build limit reached for plan "${plan}" (${BUILD_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
plan,
limit: BUILD_DAILY_LIMIT[plan],
resetIn: rl.resetIn,
});
}
// Server-count quota. Counted via SQL (not cached) so race risk is tiny.
const [serverCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(mcpServers)
.where(eq(mcpServers.orgId, user.orgId));
const existingCount = serverCountRow?.count ?? 0;
if (existingCount >= SERVER_LIMITS[plan]) {
return reply.code(402).send({
error: 'plan_limit_reached',
detail: `Plan "${plan}" allows ${SERVER_LIMITS[plan]} server(s); you have ${existingCount}. Upgrade to add more.`,
plan,
limit: SERVER_LIMITS[plan],
current: existingCount,
});
}
// ---- Template-fork validation ----
// templateId is user-controlled. To prevent fork_count manipulation + garbage
@@ -164,10 +531,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
for (const [key, value] of Object.entries(secretValues)) {
if (!value) continue;
const enc = encryptSecret(value);
await db.insert(secrets).values({
serverId: server.id,
key,
encryptedValue: encryptSecret(value),
encryptedValue: enc.value,
keyId: enc.keyId,
});
}
@@ -177,7 +546,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.returning();
if (!build) return reply.code(500).send({ error: 'build_create_failed' });
await getBuildQueue().add('generate', {
await getBuildQueue().add(
'generate',
{
buildId: build.id,
serverId: server.id,
orgId: user.orgId,
@@ -187,7 +558,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
serverName: name,
secrets: secretValues,
previewId,
});
},
{ priority: buildPriority(plan) },
);
await audit({
orgId: user.orgId,
@@ -240,6 +613,32 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' });
// iterate queues a full paid LLM build exactly like POST /v1/servers, so it
// must enforce the same suspension + daily-build gates. Without these a
// suspended (non-paying) or rate-capped org could generate unlimited builds
// by hitting iterate instead of create. (SRV-003)
const billing = await getOrgBilling(user.orgId);
if (billing.suspended) {
return reply.code(402).send({
error: 'subscription_suspended',
detail:
billing.suspendedReason === 'payment_failed'
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
: 'Your subscription is paused. Visit /settings/billing for details.',
suspendedReason: billing.suspendedReason,
});
}
const iterateRl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[billing.plan]);
if (!iterateRl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily build limit reached for plan "${billing.plan}" (${BUILD_DAILY_LIMIT[billing.plan]}/day). Resets in ${Math.ceil(iterateRl.resetIn / 3600)}h.`,
plan: billing.plan,
limit: BUILD_DAILY_LIMIT[billing.plan],
resetIn: iterateRl.resetIn,
});
}
const nextVersion = server.currentVersion + 1;
const [build] = await db
.insert(builds)
@@ -257,7 +656,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.set({ status: 'queued', updatedAt: new Date() })
.where(eq(mcpServers.id, server.id));
await getBuildQueue().add('generate', {
await getBuildQueue().add(
'generate',
{
buildId: build.id,
serverId: server.id,
orgId: user.orgId,
@@ -266,7 +667,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
slug: server.slug,
serverName: server.name,
secrets: parsed.data.secrets,
});
},
{ priority: buildPriority(billing.plan) },
);
await audit({
orgId: user.orgId,
@@ -312,6 +715,14 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
socket.close();
};
// Defense-in-depth Origin check. SameSite=Lax cookies already block
// cross-origin WS from JS in spec-compliant browsers, but enforcing the
// Origin server-side closes any browser-bug or non-browser-client path.
const origin = req.headers.origin;
if (origin && origin !== config.NEXT_PUBLIC_APP_URL) {
return fail('cross_origin_ws_rejected');
}
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return fail('invalid_id');
@@ -396,10 +807,13 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// otherwise it keeps serving traffic with the user's secrets baked in.
let containerStopped = false;
if (server.containerId) {
const result = await stopContainer(server.containerId);
const result = await stopContainer(server.containerId, server.slug);
containerStopped = result.ok;
if (!result.ok) {
app.log.warn({ containerId: server.containerId, detail: result.detail }, 'delete: stop failed');
app.log.warn(
{ containerId: server.containerId, detail: result.detail },
'delete: stop failed',
);
}
}
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
@@ -418,22 +832,17 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// ---- Spec-edit merge helpers ----
const BANNED_PATTERNS = [
/\beval\s*\(/,
/\bnew\s+Function\s*\(/,
/\brequire\s*\(\s*['"]child_process['"]/,
/\bchild_process\b/,
/ignore\s+previous\s+instructions/i,
/disregard\s+(the\s+)?(above|previous)/i,
];
/** Delegates to @bmm/llm's scanForInjection so the banned-pattern set is
* defined exactly once. Wraps the thrown BannedPatternError into a plain
* Error for the existing catch path in /v1/servers POST. */
function rescanInjection(spec: GeneratorSpec): void {
for (const tool of spec.tools) {
for (const pattern of BANNED_PATTERNS) {
if (pattern.test(tool.implementation) || pattern.test(tool.description)) {
throw new Error(`banned_pattern_detected: ${pattern.source}`);
}
try {
scanForInjection(spec);
} catch (err) {
if (err instanceof BannedPatternError) {
throw new Error(err.message);
}
throw err;
}
}

View File

@@ -0,0 +1,333 @@
import {
and,
createDb,
desc,
eq,
sql,
supportMessages,
supportTickets,
users,
} from '@bmm/db';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js';
import { checkDailyLimit } from '../lib/rate-limit.js';
import { requireAdmin, requireAuth } from '../plugins/session.js';
const db = createDb();
const NewTicketBody = z.object({
subject: z.string().min(3).max(200),
body: z.string().min(10).max(10_000),
});
const GuestTicketBody = z.object({
email: z.string().email(),
subject: z.string().min(3).max(200),
body: z.string().min(10).max(10_000),
});
const NewMessageBody = z.object({
body: z.string().min(1).max(10_000),
});
const StatusBody = z.object({
status: z.enum(['awaiting_admin', 'awaiting_user', 'closed']),
});
export async function supportRoutes(app: FastifyInstance): Promise<void> {
// ─── User-side ──────────────────────────────────────────────────────────
app.post('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const parsed = NewTicketBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const [ticket] = await db
.insert(supportTickets)
.values({
userId: user.userId,
orgId: user.orgId,
subject: parsed.data.subject,
status: 'awaiting_admin',
})
.returning();
if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: user.userId,
authorIsAdmin: false,
body: parsed.data.body,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.ticket_created',
resourceType: 'support_ticket',
resourceId: ticket.id,
metadata: { subject: parsed.data.subject },
ipAddress: req.ip,
});
return reply.send({ ticket });
});
app.get('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const rows = await db
.select()
.from(supportTickets)
.where(eq(supportTickets.userId, user.userId))
.orderBy(desc(supportTickets.lastMessageAt));
return reply.send({ tickets: rows });
});
app.get('/v1/support/tickets/:id', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [ticket] = await db
.select()
.from(supportTickets)
.where(
and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)),
)
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
const messages = await db
.select()
.from(supportMessages)
.where(eq(supportMessages.ticketId, ticket.id))
.orderBy(supportMessages.createdAt);
return reply.send({ ticket, messages });
});
app.post(
'/v1/support/tickets/:id/messages',
{ preHandler: requireAuth },
async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = NewMessageBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
const [ticket] = await db
.select()
.from(supportTickets)
.where(
and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)),
)
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: user.userId,
authorIsAdmin: false,
body: body.data.body,
});
await db
.update(supportTickets)
.set({
status: 'awaiting_admin',
lastMessageAt: new Date(),
updatedAt: new Date(),
})
.where(eq(supportTickets.id, ticket.id));
return reply.send({ ok: true });
},
);
// ─── Public contact form (no auth) ─────────────────────────────────────
// Satisfies UWG Art. 3 lit. s ("easy electronic contact") for non-logged-in
// visitors. Rate-limited per IP to prevent spam-flood of admin queue.
app.post('/v1/contact', async (req, reply) => {
const parsed = GuestTicketBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const rl = await checkDailyLimit('contact', req.ip, 3);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: 'Too many contact submissions from this IP. Try again tomorrow.',
});
}
const [ticket] = await db
.insert(supportTickets)
.values({
guestEmail: parsed.data.email,
subject: parsed.data.subject,
status: 'awaiting_admin',
})
.returning();
if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' });
await db.insert(supportMessages).values({
ticketId: ticket.id,
authorUserId: null,
authorIsAdmin: false,
body: parsed.data.body,
});
return reply.send({ ok: true });
});
// ─── Admin-side ────────────────────────────────────────────────────────
app.get(
'/v1/admin/support/counts',
{ preHandler: requireAdmin },
async (_req, reply) => {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(supportTickets)
.where(eq(supportTickets.status, 'awaiting_admin'));
return reply.send({ awaitingAdmin: row?.count ?? 0 });
},
);
app.get(
'/v1/admin/support/tickets',
{ preHandler: requireAdmin },
async (_req, reply) => {
const rows = await db
.select({
ticket: supportTickets,
userEmail: users.email,
userName: users.name,
})
.from(supportTickets)
.leftJoin(users, eq(users.id, supportTickets.userId))
.orderBy(desc(supportTickets.lastMessageAt))
.limit(200);
return reply.send({ tickets: rows });
},
);
app.get(
'/v1/admin/support/tickets/:id',
{ preHandler: requireAdmin },
async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [row] = await db
.select({ ticket: supportTickets, userEmail: users.email, userName: users.name })
.from(supportTickets)
.leftJoin(users, eq(users.id, supportTickets.userId))
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!row) return reply.code(404).send({ error: 'not_found' });
const messages = await db
.select()
.from(supportMessages)
.where(eq(supportMessages.ticketId, parsed.data.id))
.orderBy(supportMessages.createdAt);
return reply.send({ ticket: row.ticket, userEmail: row.userEmail, userName: row.userName, messages });
},
);
app.post(
'/v1/admin/support/tickets/:id/messages',
{ preHandler: requireAdmin },
async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = NewMessageBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
// Confirm the ticket exists first — otherwise the insert below hits a raw
// FK violation (500) instead of a clean 404. (SUP-002)
const [ticket] = await db
.select({ id: supportTickets.id })
.from(supportTickets)
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db.insert(supportMessages).values({
ticketId: parsed.data.id,
authorUserId: user.userId,
authorIsAdmin: true,
body: body.data.body,
});
await db
.update(supportTickets)
.set({
status: 'awaiting_user',
lastMessageAt: new Date(),
updatedAt: new Date(),
})
.where(eq(supportTickets.id, parsed.data.id));
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.admin_reply',
resourceType: 'support_ticket',
resourceId: parsed.data.id,
});
return reply.send({ ok: true });
},
);
app.post(
'/v1/admin/support/tickets/:id/status',
{ preHandler: requireAdmin },
async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = StatusBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
// 404 on unknown ticket instead of a silent no-op `UPDATE ... WHERE id=?`
// that returns ok:true and masks the bad id. (SUP-002)
const [ticket] = await db
.select({ id: supportTickets.id })
.from(supportTickets)
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db
.update(supportTickets)
.set({
status: body.data.status,
closedAt: body.data.status === 'closed' ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(supportTickets.id, parsed.data.id));
// Status changes were previously unaudited, unlike admin replies — close
// the compliance-trail gap. (SUP-002)
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.status_changed',
resourceType: 'support_ticket',
resourceId: parsed.data.id,
metadata: { status: body.data.status },
});
return reply.send({ ok: true });
},
);
}

View File

@@ -1,6 +1,5 @@
import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getSession } from '@bmm/auth';
import {
and,
builds,
@@ -15,30 +14,35 @@ import {
templates,
users,
} from '@bmm/db';
import { SHARED_BANNED_PATTERNS } from '@bmm/llm';
import { GeneratorSpec } from '@bmm/types';
import { getSession } from '@bmm/auth';
import { requireAuth, requireAdmin } from '../plugins/session.js';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { audit } from '../lib/audit.js';
import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js';
import { getRedis } from '../lib/redis.js';
import { stopContainer } from '../lib/docker.js';
import { cachePrebuiltCode, cacheSpec } from '../lib/preview-cache.js';
import { getRedis } from '../lib/redis.js';
import { requireAdmin, requireAuth } from '../plugins/session.js';
const db = createDb();
const BANNED_PATTERNS = [
/\beval\s*\(/,
/\bnew\s+Function\s*\(/,
/\bFunction\s*\(\s*['"`]/, // Function('code')() — no `new` needed
// Code-level extras on top of SHARED_BANNED_PATTERNS — these are concerns
// that only make sense scanning a fully-rendered server.ts (not a spec).
// Keeping them additive means @bmm/llm stays the single source of truth for
// "obvious-malicious patterns", and publish-time gets stricter checks on top.
// (Zc-001 consolidation.)
const CODE_EXTRA_PATTERNS: RegExp[] = [
/\bimport\s*\(/, // dynamic import (escape from bundle scope)
/\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form
/\bsetInterval\s*\(\s*['"`]/,
/\bchild_process\b/,
/\bfs\s*\.\s*(unlink|rmdir|rm)\b/,
/\bprocess\s*\.\s*kill\b/,
/ignore\s+previous\s+instructions/i,
/disregard\s+(the\s+)?(above|previous)/i,
/you\s+are\s+now\s+(in\s+)?(developer|jailbreak|dan)\s+mode/i,
];
const PUBLISH_BANNED_PATTERNS: readonly RegExp[] = [
...SHARED_BANNED_PATTERNS,
...CODE_EXTRA_PATTERNS,
];
// Hardcoded-credential patterns. If Claude embedded a literal API key into the
// generated code (publisher pasted it into the prompt), block the publish.
@@ -54,7 +58,7 @@ const SECRET_PATTERNS = [
];
function scanForInjection(code: string): void {
for (const pattern of BANNED_PATTERNS) {
for (const pattern of PUBLISH_BANNED_PATTERNS) {
if (pattern.test(code)) throw new Error(`banned_pattern: ${pattern.source}`);
}
}
@@ -163,7 +167,11 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`;
let attempt = 0;
while (true) {
const existing = await db.select({ id: templates.id }).from(templates).where(eq(templates.slug, slug)).limit(1);
const existing = await db
.select({ id: templates.id })
.from(templates)
.where(eq(templates.slug, slug))
.limit(1);
if (existing.length === 0) break;
attempt++;
slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`;
@@ -186,10 +194,15 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
toolsSchema: server.toolsSchema,
generatedCode: build.generatedCode,
requiredSecrets: parsed.data.secretHints,
scopes: (server.toolsSchema as Array<{ scopes?: string[] }>).reduce<string[]>(
() => ['mcp:read'],
[],
),
// Aggregate the distinct scopes actually declared by the server's tools
// (deduped), falling back to read-only. The previous reduce ignored its
// input and hardcoded ['mcp:read'] for every template regardless of what
// its tools did. (TPL-003)
scopes: (() => {
const tools = (server.toolsSchema as Array<{ scopes?: string[] }> | null) ?? [];
const all = [...new Set(tools.flatMap((t) => t.scopes ?? []))];
return all.length > 0 ? all : ['mcp:read'];
})(),
allowedDomains: parsed.data.allowedDomains ?? null,
})
.returning();
@@ -302,29 +315,41 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
.from(templates)
.leftJoin(users, eq(users.id, templates.ownerUserId))
.leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
.where(eq(templates.status, 'public'))
// Category filter belongs in the WHERE, BEFORE limit — filtering in JS
// after `.limit(50)` meant `?category=x` searched only the 50 newest
// public templates (any category), returning far fewer than `limit`. (TPL-008)
.where(
and(
eq(templates.status, 'public'),
parsed.data.category ? eq(templates.category, parsed.data.category) : undefined,
),
)
.orderBy(desc(templates.createdAt))
.limit(parsed.data.limit);
const filtered = parsed.data.category
? rows.filter((r) => r.template.category === parsed.data.category)
: rows;
const filtered = rows;
// Augment with active deployment counts
const enriched = await Promise.all(
filtered.map(async (r) => {
const [active] = await db
.select({ c: count() })
// Single grouped query — was N+1 (one COUNT per template). On a 100-row
// listing that's 101 round-trips → p95 latency cliff once the marketplace
// grows. (Zc-002.)
const templateIds = filtered.map((r) => r.template.id);
const activeCounts = new Map<string, number>();
if (templateIds.length > 0) {
const grouped = await db
.select({ id: mcpServers.templateId, c: count() })
.from(mcpServers)
.where(and(eq(mcpServers.templateId, r.template.id), eq(mcpServers.status, 'live')));
return {
.where(and(eq(mcpServers.status, 'live'), sql`${mcpServers.templateId} = ANY(${templateIds})`))
.groupBy(mcpServers.templateId);
for (const g of grouped) {
if (g.id) activeCounts.set(g.id, Number(g.c));
}
}
const enriched = filtered.map((r) => ({
...r.template,
ownerName: r.ownerName ?? r.ownerEmail?.split('@')[0] ?? null,
ownerOrgName: r.ownerOrgName,
activeDeployments: Number(active?.c ?? 0),
};
}),
);
activeDeployments: activeCounts.get(r.template.id) ?? 0,
}));
// Sort
const now = Date.now();
@@ -364,7 +389,7 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
.where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
return {
...t,
ownerName: user.email.split('@')[0],
ownerName: user.email?.split('@')[0] ?? user.phone ?? 'you',
ownerOrgName: null,
activeDeployments: Number(active?.c ?? 0),
};
@@ -468,7 +493,9 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
const validation = GeneratorSpec.safeParse(fullSpec);
if (!validation.success) {
return reply.code(500).send({ error: 'template_spec_invalid', detail: validation.error.flatten() });
return reply
.code(500)
.send({ error: 'template_spec_invalid', detail: validation.error.flatten() });
}
const previewId = await cacheSpec(validation.data);
// Persist the pre-rendered code under the same previewId so the worker uses it
@@ -543,14 +570,18 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
let stoppedContainers = 0;
if (b.data.status === 'takedown') {
const forkedServers = await db
.select({ id: mcpServers.id, containerId: mcpServers.containerId })
.select({ id: mcpServers.id, containerId: mcpServers.containerId, slug: mcpServers.slug })
.from(mcpServers)
.where(eq(mcpServers.templateId, p.data.id));
for (const fork of forkedServers) {
if (fork.containerId) {
const result = await stopContainer(fork.containerId);
const result = await stopContainer(fork.containerId, fork.slug);
if (result.ok) stoppedContainers++;
else app.log.warn({ containerId: fork.containerId, detail: result.detail }, 'takedown: stop failed');
else
app.log.warn(
{ containerId: fork.containerId, detail: result.detail },
'takedown: stop failed',
);
}
}
await db

34
apps/generator/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
# Generator worker (BullMQ). Renders generated MCP servers, builds their Docker
# images and runs them as sibling containers on the host daemon.
# Build context must be the repo root: docker build -f apps/generator/Dockerfile .
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
# ---- deps ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY apps/generator/package.json apps/generator/
COPY apps/runner-template/package.json apps/runner-template/
COPY packages/auth/package.json packages/auth/
COPY packages/db/package.json packages/db/
COPY packages/llm/package.json packages/llm/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
# ---- runtime ----
FROM deps AS runtime
# docker CLI: the worker shells out to `docker build` / `docker run` against the
# host daemon (socket mounted in compose). apps/runner-template is copied below
# and used as the build context template for every generated server.
RUN apk add --no-cache docker-cli
ENV NODE_ENV=production
COPY . .
# build-context is a mounted volume at runtime; create the dir so the path exists.
RUN mkdir -p /app/build-context
WORKDIR /app/apps/generator
CMD ["pnpm", "start"]

View File

@@ -5,7 +5,7 @@
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"start": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
},

View File

@@ -4,14 +4,24 @@ const Env = z.object({
DATABASE_URL: z.string(),
REDIS_URL: z.string().default('redis://localhost:6379'),
ANTHROPIC_API_KEY: z.string().optional(),
GLM_API_KEY: z.string().optional(),
RUNNER_HOST: z.string().default('localhost'),
RUNNER_PORT_RANGE_START: z.coerce.number().default(4100),
RUNNER_PORT_RANGE_END: z.coerce.number().default(4999),
CONTROL_PLANE_URL: z.string().default('http://host.docker.internal:4000'),
CONTROL_PLANE_PUBLIC_URL: z.string().default('http://localhost:4000'),
OAUTH_ISSUER: z.string().optional(),
MODEL_GENERATE: z.string().default('claude-opus-4-7'),
MODEL_GENERATE: z.string().default('glm-4.5'),
MODEL_FIX: z.string().default('claude-haiku-4-5-20251001'),
// When set (e.g. "mcp.buildmymcpserver.com"), each deployed runner gets a
// public URL of the form https://<slug>.<MCP_DOMAIN> instead of the legacy
// http://<RUNNER_HOST>:<port> form. Requires host-side nginx + DNS setup
// (see scripts/setup-runner-tls.sh). When unset, falls back to plain HTTP.
MCP_DOMAIN: z.string().optional(),
// Directory the generator drops per-runner map fragments into. A host-side
// inotify service combines them and reloads nginx. Mounted as a volume by
// docker-compose (see setup-runner-tls.sh).
RUNNER_MAP_DIR: z.string().default('/var/runner-map'),
});
export const config = Env.parse(process.env);

View File

@@ -39,7 +39,10 @@ export async function prepareBuildContext(
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
const imageTag = `bmm-mcp-${slug}:v${version}`;
// Include serverId in the tag: `slug` is unique only per-org, so two orgs
// sharing a slug at the same version would otherwise collide on one global
// image tag and run each other's code. Matches the contextDir scheme. (GEN-009)
const imageTag = `bmm-mcp-${serverId.slice(0, 8)}-${slug}:v${version}`;
return { contextDir, imageTag };
}
@@ -70,12 +73,21 @@ export async function staticCheck(contextDir: string): Promise<void> {
}
}
// A hung `docker build` (stalled npm install, wedged daemon) must not pin a
// worker slot forever — concurrency is 2, so two stuck builds = zero throughput
// with no alarm. Kill and fail the build past this ceiling. (GEN-008)
const BUILD_TIMEOUT_MS = 10 * 60 * 1000;
export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn('docker', ['build', '-t', imageTag, '.'], {
cwd: contextDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`docker_build_timeout (exceeded ${BUILD_TIMEOUT_MS / 1000}s)`));
}, BUILD_TIMEOUT_MS);
child.stdout.on('data', (d) => {
for (const line of d.toString().split(/\r?\n/)) {
if (line.trim()) onLog(line.trim());
@@ -86,8 +98,12 @@ export async function dockerBuild(contextDir: string, imageTag: string, onLog: (
if (line.trim()) onLog(line.trim());
}
});
child.on('error', (e) => reject(e));
child.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(`docker_build_failed (exit ${code})`));
});

View File

@@ -1,12 +1,40 @@
import { generateSpec as sharedGenerate, type GenerationResult } from '@bmm/llm';
import { type GenerationResult, generateSpec as sharedGenerate } from '@bmm/llm';
import { config } from '../config.js';
export type { GenerationResult };
/**
* Build-worker spec generation (cache-miss path). Runs async in a BullMQ
* worker — no proxy timeout. Defaults to GLM to keep this rare path cheap;
* falls back to Anthropic Sonnet on GLM failure so a temporary outage at one
* provider doesn't break builds.
*/
export async function generateSpec(prompt: string): Promise<GenerationResult> {
return sharedGenerate(prompt, {
apiKey: config.ANTHROPIC_API_KEY,
if (config.GLM_API_KEY) {
try {
return await sharedGenerate(prompt, {
provider: 'glm',
glmApiKey: config.GLM_API_KEY,
model: config.MODEL_GENERATE,
maxTokens: 8192,
timeoutMs: 180_000,
});
} catch (err) {
console.warn(
'[generator] GLM failed, falling back to Anthropic Sonnet:',
(err as Error).message,
);
}
}
if (!config.ANTHROPIC_API_KEY) {
// No keys at all → @bmm/llm returns mockSpec, which keeps builds working
// in dev without any provider configured.
return sharedGenerate(prompt, { provider: 'anthropic' });
}
return sharedGenerate(prompt, {
provider: 'anthropic',
apiKey: config.ANTHROPIC_API_KEY,
model: 'claude-sonnet-4-6',
maxTokens: 8192,
});
}

View File

@@ -1,7 +1,106 @@
import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { createDb, eq, isNotNull, mcpServers } from '@bmm/db';
import { config } from '../config.js';
/**
* Per-runner TLS via path-routing on mcp.buildmymcpserver.com. When
* MCP_DOMAIN is set, the generator publishes each container at
* https://<MCP_DOMAIN>/<slug>
* and writes a one-line nginx snippet per server into RUNNER_MAP_DIR.
* A host-side systemd inotify watcher combines the snippets into a single
* file that the nginx vhost includes inside its location block, mapping
* the captured slug to its local runner port.
*
* Path-routing (instead of per-subdomain) is the bootstrap-friendly choice:
* mcp.buildmymcpserver.com is covered by Cloudflare's free Universal SSL,
* whereas *.mcp.buildmymcpserver.com would need CF Advanced Cert Manager
* ($10/mo) or a custom Let's-Encrypt wildcard via DNS-01 (free but more
* ops). See scripts/setup-runner-tls.sh for the one-time host setup.
*
* If MCP_DOMAIN is unset, both the URL formatter and the map writer no-op
* and we fall back to the legacy http://host:port URL — zero behaviour
* change without the host-side infra in place.
*/
function runnerMapPath(slug: string): string {
return path.join(config.RUNNER_MAP_DIR, `${slug}.conf`);
}
async function writeRunnerMapEntry(slug: string, port: number): Promise<void> {
if (!config.MCP_DOMAIN) return;
// nginx snippet — included inside a `location ~` block that captures
// $bmm_slug. Each runner contributes one line; the systemd watcher
// concatenates them into /opt/buildmymcpserver/runner-map.combined.
const line = `if ($bmm_slug = "${slug}") { set $bmm_port ${port}; }\n`;
try {
await fs.mkdir(config.RUNNER_MAP_DIR, { recursive: true });
await fs.writeFile(runnerMapPath(slug), line, 'utf8');
} catch (err) {
// Don't fail the deploy if the map dir isn't mounted yet — runner still
// serves on http://host:port and the user can manually proxy.
console.warn(`[runner-tls] could not write map entry for ${slug}:`, err);
}
}
async function removeRunnerMapEntry(slug: string): Promise<void> {
if (!config.MCP_DOMAIN) return;
try {
await fs.rm(runnerMapPath(slug), { force: true });
} catch {
// Idempotent — missing file is fine.
}
}
export function computePublicUrl(slug: string, port: number): string {
if (config.MCP_DOMAIN) return `https://${config.MCP_DOMAIN}/${slug}`;
return `http://${config.RUNNER_HOST}:${port}`;
}
/**
* Container hardening flags applied on every runner deployment on Linux
* production hosts. Skipped only when explicitly disabled (dev/Windows
* Docker Desktop, which doesn't fully honour --read-only on bind mounts).
*
* Without these, a tenant container runs as root with full capabilities on
* the shared host — combined with the LLM static-check being a regex
* blacklist (Z2-001), this would let a malicious tenant execute arbitrary
* code on the host. With them, the blast radius collapses to "within the
* container", which holds only that tenant's own decrypted secrets.
*/
const HARDENING_FLAGS = [
'--read-only',
'--cap-drop=ALL',
'--security-opt=no-new-privileges:true',
'--pids-limit=100',
'--memory=512m',
'--memory-swap=512m',
'--cpus=0.5',
// /tmp needs writable space — runner-template uses it for build/cache.
'--tmpfs=/tmp:rw,nosuid,nodev,size=64m',
];
function shouldHarden(): boolean {
// Fail-CLOSED: harden by default everywhere. The only opt-out is the explicit
// RUNNER_DISABLE_HARDENING=1 flag (local Windows Docker Desktop, where
// --read-only conflicts with how volumes bind). The previous NODE_ENV gate was
// fail-OPEN — a missing/typo'd NODE_ENV silently ran tenant containers as root
// with full caps on the shared host, which is the one defense the LLM
// static-check explicitly is NOT. (GEN-002)
if (process.env.RUNNER_DISABLE_HARDENING === '1') {
console.warn(
'[deploy] container hardening DISABLED via RUNNER_DISABLE_HARDENING=1 — never set this in production',
);
return false;
}
return true;
}
// docker run / rm should return in seconds; cap them so a wedged daemon can't
// hang a worker slot indefinitely. (GEN-008)
const DOCKER_RUN_TIMEOUT_MS = 60 * 1000;
const DOCKER_STOP_TIMEOUT_MS = 60 * 1000;
const db = createDb();
async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> {
@@ -46,16 +145,9 @@ export interface DeployInput {
envVars: Record<string, string>;
}
// Production-only flags documented but unused in dev for Windows Docker Desktop compat:
// '--read-only',
// '--cap-drop=ALL',
// '--security-opt=no-new-privileges',
// '--cpus=0.5',
// '--memory=512m',
export async function deployContainer(input: DeployInput): Promise<DeployHandle> {
// In a future iteration this calls docker engine API directly via UNIX socket / named pipe.
// For Sprint 1-3 we shell out via the bound docker CLI which is portable on win/mac/linux.
// Docker CLI is portable across linux/mac/win — sufficient for now; future
// iteration will switch to the engine API via UNIX socket.
const { spawn } = await import('node:child_process');
const containerName = `bmm-mcp-${input.slug}-${Date.now().toString(36)}`;
const args = [
@@ -66,6 +158,9 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
'-p',
`${input.hostPort}:3000`,
];
if (shouldHarden()) {
args.push(...HARDENING_FLAGS);
}
for (const [k, v] of Object.entries(input.envVars)) {
args.push('-e', `${k}=${v}`);
}
@@ -75,20 +170,33 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
let err = '';
// `docker run -d` returns promptly; if it hangs (wedged daemon) don't pin a
// worker slot forever. (GEN-008)
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('docker_run_timeout'));
}, DOCKER_RUN_TIMEOUT_MS);
child.stdout.on('data', (d) => {
out += d.toString();
});
child.stderr.on('data', (d) => {
err += d.toString();
});
child.on('error', (e) => reject(e));
child.on('error', (e) => {
clearTimeout(timer);
reject(e);
});
child.on('close', async (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
return;
}
const containerId = out.trim().slice(0, 64);
const publicUrl = `http://${config.RUNNER_HOST}:${input.hostPort}`;
const publicUrl = computePublicUrl(input.slug, input.hostPort);
// Drop the nginx map fragment BEFORE persisting publicUrl so the
// user-visible URL is reachable by the time the wizard polls "live".
await writeRunnerMapEntry(input.slug, input.hostPort);
await db
.update(mcpServers)
.set({
@@ -104,12 +212,39 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
});
}
export async function stopContainer(containerId: string): Promise<void> {
export async function stopContainer(
containerId: string,
slug?: string,
): Promise<{ ok: boolean; detail: string }> {
if (!containerId || containerId.length < 4) {
return { ok: false, detail: 'invalid_container_id' };
}
// Remove the nginx map fragment first so the slug stops serving 502 from
// the proxy as soon as the container goes down. Idempotent — called
// multiple times with the same slug is fine.
if (slug) await removeRunnerMapEntry(slug);
const { spawn } = await import('node:child_process');
await new Promise<void>((resolve) => {
const child = spawn('docker', ['rm', '-f', containerId], { stdio: 'ignore' });
child.on('close', () => resolve());
child.on('error', () => resolve());
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
const child = spawn('docker', ['rm', '-f', containerId], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let err = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve({ ok: false, detail: 'stop_timeout' });
}, DOCKER_STOP_TIMEOUT_MS);
child.stderr?.on('data', (d: Buffer) => {
err += d.toString();
});
child.on('error', () => {
clearTimeout(timer);
resolve({ ok: false, detail: 'spawn_failed' });
});
child.on('close', (code) => {
clearTimeout(timer);
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` });
});
});
}

View File

@@ -38,6 +38,15 @@ function renderTool(tool: ToolSpec): string {
inputSchema: ${schemaShape},
},
async (args) => {
// The MCP SDK passes the validated tool arguments as the single
// parameter. Models trained on OpenAPI / JSON-RPC examples reach
// for "params" instead of "args", and "input" shows up too — bind
// every common alias to the same object so the generated body
// works whichever name the model picked. Without this the runner
// crashes with "ReferenceError: params is not defined" at the
// first tool call (verified in prod with the wetter server).
const params = args;
const input = args;
try {
${tool.implementation}
} catch (err) {
@@ -59,9 +68,24 @@ import Fastify from 'fastify';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { randomUUID } from 'node:crypto';
const PUBLIC_URL = process.env.PUBLIC_URL ?? 'http://localhost:3000';
const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL ?? 'http://host.docker.internal:4000';
const OAUTH_ISSUER = process.env.OAUTH_ISSUER ?? CONTROL_PLANE_URL + '/oauth';
function stripTrailingSlash(value) {
return value.replace(/\\/$/, '');
}
function protectedResourceMetadataUrl(resourceUrl) {
const url = new URL(resourceUrl);
const resourcePath = url.pathname === '/' ? '' : url.pathname;
url.pathname = '/.well-known/oauth-protected-resource' + resourcePath;
url.hash = '';
return url.toString();
}
const PUBLIC_URL = stripTrailingSlash(process.env.PUBLIC_URL ?? 'http://localhost:3000');
const CONTROL_PLANE_URL = stripTrailingSlash(process.env.CONTROL_PLANE_URL ?? 'http://host.docker.internal:4000');
const OAUTH_ISSUER = stripTrailingSlash(process.env.OAUTH_ISSUER ?? CONTROL_PLANE_URL + '/oauth');
const MCP_RESOURCE_URL = PUBLIC_URL + '/mcp';
const PROTECTED_RESOURCE_METADATA_URL = protectedResourceMetadataUrl(MCP_RESOURCE_URL);
const EXPECTED_AUDIENCES = Array.from(new Set([MCP_RESOURCE_URL, PUBLIC_URL]));
const PORT = Number.parseInt(process.env.PORT ?? '3000', 10);
const server = new McpServer(
@@ -75,15 +99,18 @@ const app = Fastify({ logger: { level: 'info' } });
app.get('/health', async () => ({ ok: true }));
app.get('/.well-known/oauth-protected-resource', async () => ({
resource: PUBLIC_URL,
const protectedResourceMetadata = async () => ({
resource: MCP_RESOURCE_URL,
authorization_servers: [OAUTH_ISSUER],
bearer_methods_supported: ['header'],
scopes_supported: ${JSON.stringify(spec.scopes)},
}));
});
app.get('/.well-known/oauth-protected-resource', protectedResourceMetadata);
app.get('/.well-known/oauth-protected-resource/*', protectedResourceMetadata);
app.get('/.well-known/oauth-authorization-server', async () => {
const r = await fetch(CONTROL_PLANE_URL + '/oauth/.well-known/oauth-authorization-server');
const r = await fetch(CONTROL_PLANE_URL + '/.well-known/oauth-authorization-server/oauth');
return await r.json();
});
@@ -96,16 +123,17 @@ app.all('/mcp', async (request, reply) => {
if (!auth || !auth.startsWith('Bearer ')) {
return reply
.code(401)
.header('WWW-Authenticate', \`Bearer resource_metadata="\${PUBLIC_URL}/.well-known/oauth-protected-resource"\`)
.header('WWW-Authenticate', \`Bearer resource_metadata="\${PROTECTED_RESOURCE_METADATA_URL}"\`)
.send({ error: 'unauthorized' });
}
const token = auth.slice(7);
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: OAUTH_ISSUER,
audience: PUBLIC_URL,
audience: EXPECTED_AUDIENCES,
});
if (payload.aud !== PUBLIC_URL) {
const audiences = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
if (!audiences.some((aud) => EXPECTED_AUDIENCES.includes(aud))) {
return reply.code(403).send({ error: 'invalid_audience' });
}
} catch (e) {

View File

@@ -1,13 +1,19 @@
import { builds, createDb, eq, mcpServers } from '@bmm/db';
import { GeneratorSpec } from '@bmm/types';
import { Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { GeneratorSpec } from '@bmm/types';
import { builds, createDb, eq, mcpServers } from '@bmm/db';
import { config } from './config.js';
import { generateSpec } from './lib/claude.js';
import { renderServerCode } from './lib/render.js';
import { dockerBuild, prepareBuildContext, staticCheck } from './lib/build.js';
import { allocatePort, deployContainer, dockerAvailable } from './lib/deploy.js';
import { generateSpec } from './lib/claude.js';
import {
allocatePort,
computePublicUrl,
deployContainer,
dockerAvailable,
stopContainer,
} from './lib/deploy.js';
import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
import { renderServerCode } from './lib/render.js';
const db = createDb();
const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
@@ -46,13 +52,29 @@ export const worker = new Worker<JobData>(
const { buildId, serverId, prompt, version, slug, secrets, previewId } = job.data;
const log = (level: 'info' | 'warn' | 'error', msg: string) => emitLog(buildId, level, msg);
// Capture the container currently serving this server (if any) BEFORE the
// build mutates the row. On an iterate (version > 1) we deploy the new
// container, then tear this old one down — rolling-deploy, no orphan.
const [priorState] = await db
.select({ containerId: mcpServers.containerId })
.from(mcpServers)
.where(eq(mcpServers.id, serverId))
.limit(1);
const oldContainerId = priorState?.containerId ?? null;
try {
await db.update(builds).set({ status: 'generating', startedAt: new Date() }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'generating', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await db
.update(builds)
.set({ status: 'generating', startedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'generating', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'generating');
let spec: GeneratorSpec | null = null;
let source: 'claude' | 'mock' | 'cached' = 'mock';
let source: 'claude' | 'glm' | 'mock' | 'cached' = 'mock';
if (previewId) {
spec = await loadCachedSpec(previewId);
@@ -77,7 +99,10 @@ export const worker = new Worker<JobData>(
let generatedCode: string;
const prebuilt = previewId ? await loadPrebuiltCode(previewId) : null;
if (prebuilt) {
await log('info', `Using pre-rendered template code (${prebuilt.length} chars) — skipping render`);
await log(
'info',
`Using pre-rendered template code (${prebuilt.length} chars) — skipping render`,
);
generatedCode = prebuilt;
} else {
generatedCode = renderServerCode(spec);
@@ -88,11 +113,20 @@ export const worker = new Worker<JobData>(
.where(eq(builds.id, buildId));
await db.update(builds).set({ status: 'building' }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'building', toolsSchema: spec.tools, updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await db
.update(mcpServers)
.set({ status: 'building', toolsSchema: spec.tools, updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'building');
await log('info', 'Preparing build context...');
const { contextDir, imageTag } = await prepareBuildContext(serverId, version, slug, generatedCode, spec);
const { contextDir, imageTag } = await prepareBuildContext(
serverId,
version,
slug,
generatedCode,
spec,
);
await log('info', `Build context at ${contextDir}`);
await log('info', 'Running static checks...');
@@ -102,8 +136,14 @@ export const worker = new Worker<JobData>(
const hasDocker = await dockerAvailable();
if (!hasDocker) {
await log('warn', 'Docker not available — skipping build/deploy. Server marked draft.');
await db.update(builds).set({ status: 'failed', errorMessage: 'docker_unavailable', finishedAt: new Date() }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'failed', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await db
.update(builds)
.set({ status: 'failed', errorMessage: 'docker_unavailable', finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'failed', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitDone(buildId, 'failed', serverId, null);
return;
}
@@ -115,11 +155,20 @@ export const worker = new Worker<JobData>(
await log('info', 'Image built.');
await db.update(builds).set({ status: 'deploying' }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'deploying', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await db
.update(mcpServers)
.set({ status: 'deploying', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'deploying');
const port = await allocatePort();
const publicUrl = `http://${config.RUNNER_HOST}:${port}`;
// The container's PUBLIC_URL must match what end-users (and Claude
// Desktop's DCR client) actually reach. When MCP_DOMAIN is set we
// route via https://<MCP_DOMAIN>/<slug>; the hardcoded loopback URL
// we used to inject caused the runner to advertise an unreachable
// resource_metadata URL in its WWW-Authenticate header, killing OAuth
// discovery from any external MCP client.
const publicUrl = computePublicUrl(slug, port);
const envVars: Record<string, string> = {
...secrets,
PUBLIC_URL: publicUrl,
@@ -130,16 +179,40 @@ export const worker = new Worker<JobData>(
};
const handle = await deployContainer({ serverId, slug, hostPort: port, imageTag, envVars });
await log('info', `Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`);
await log(
'info',
`Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`,
);
try {
await db
.update(builds)
.set({ status: 'success', finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'live', currentVersion: version, publicUrl: handle.publicUrl, updatedAt: new Date() })
.set({
status: 'live',
currentVersion: version,
publicUrl: handle.publicUrl,
updatedAt: new Date(),
})
.where(eq(mcpServers.id, serverId));
} finally {
// Rolling deploy: retire the previous container even if the success DB
// writes above threw — otherwise a DB hiccup after a healthy deploy
// leaves the old container orphaned, holding its host port. The new
// container is already live and its id is persisted in deployContainer. (GEN-007)
if (oldContainerId && oldContainerId !== handle.containerId) {
const stopped = await stopContainer(oldContainerId);
await log(
stopped.ok ? 'info' : 'warn',
stopped.ok
? `Retired previous container ${oldContainerId.slice(0, 12)}`
: `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`,
);
}
}
await emitStatus(buildId, 'success');
await emitDone(buildId, 'success', serverId, handle.publicUrl);

View File

@@ -1,7 +1,11 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund && npm install --no-save tsx@4.19.2 typescript@5.7.2
# --ignore-scripts: generated package.json carries LLM/user-chosen dependencies.
# Without this, a malicious dependency's postinstall lifecycle script would run
# at `docker build` time on the shared host. Specifiers are also validated to
# registry semver ranges at the API boundary (DependencyMap). (GEN-001)
RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund && npm install --no-save --ignore-scripts tsx@4.19.2 typescript@5.7.2
FROM node:20-alpine AS runtime
WORKDIR /app
@@ -10,6 +14,8 @@ COPY --from=deps /app/node_modules ./node_modules
COPY package.json tsconfig.json ./
COPY src ./src
EXPOSE 3000
# 127.0.0.1, not localhost: busybox wget resolves localhost to ::1 first and
# the server binds IPv4 only, so a localhost check would wrongly fail.
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
CMD ["npx", "tsx", "src/server.ts"]

47
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# syntax=docker/dockerfile:1
# Web app (Next.js 15). NEXT_PUBLIC_API_URL is inlined into the client bundle at
# BUILD time — it must be passed as a build arg, not just a runtime env var.
# Build context must be the repo root: docker build -f apps/web/Dockerfile .
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
# ---- deps ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY apps/generator/package.json apps/generator/
COPY apps/runner-template/package.json apps/runner-template/
COPY packages/auth/package.json packages/auth/
COPY packages/db/package.json packages/db/
COPY packages/llm/package.json packages/llm/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
# ---- build ----
FROM deps AS build
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Stripe publishable key — inlined into the client bundle so the embedded
# checkout can initialise. Safe to expose (publishable, not secret). Empty
# build = embedded checkout shows a "not configured" message until set.
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_TELEMETRY_DISABLED=1
COPY . .
RUN pnpm --filter @bmm/web build
# ---- runtime ----
FROM build AS runtime
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app/apps/web
EXPOSE 3001
# NOTE (INF-003): non-root `USER node` was reverted — `pnpm start` via corepack
# can't reach its root-owned cache as the node user and the deploy health-check
# doesn't cover web, so a broken web would deploy "green" but take the site down.
# Re-enable only after switching the runtime CMD to invoke next directly
# (node_modules/.bin/next) and smoke-testing the image locally.
CMD ["pnpm", "start"]

View File

@@ -1,10 +1,10 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { StatusPill } from '@/components/status-pill';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface ServerRow {
id: string;
@@ -17,12 +17,17 @@ interface ServerRow {
export default function Overview() {
const [servers, setServers] = useState<ServerRow[] | null>(null);
const [plan, setPlan] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
apiFetch<{ servers: ServerRow[] }>('/v1/servers')
.then((r) => setServers(r.servers))
.catch((e) => setErr((e as Error).message));
// Real plan from billing status — never render a hardcoded tier.
apiFetch<{ plan: string }>('/v1/billing/status')
.then((r) => setPlan(r.plan))
.catch(() => setPlan(null));
}, []);
if (err?.includes('401')) {
@@ -37,31 +42,34 @@ export default function Overview() {
return (
<div className="mx-auto max-w-7xl px-6 py-8">
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-[22px] font-semibold tracking-tight">Overview</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Your MCP servers, calls and recent builds.
</p>
</div>
<Link
href="/servers/new"
className="inline-flex h-8 items-center gap-2 rounded-md bg-[--color-accent] px-3 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
>
+ New server
</Link>
</div>
<div className="mt-6 grid gap-3 md:grid-cols-3">
<Card label="Servers" value={total.toString()} sub={`${live} live`} />
<Card label="Calls this period" value="0" sub="of 100,000" />
<Card label="Plan" value="Hobby" sub="Upgrade in Settings" />
<Card
label="Calls this period"
value="—"
sub="Per-server metrics live on each server page"
/>
<Card
label="Plan"
value={plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : '—'}
sub="Manage in Settings → Billing"
/>
</div>
<div className="mt-10">
<div className="flex items-center justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">Recent servers</h2>
<Link href="/servers" className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
<Link
href="/servers"
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
View all
</Link>
</div>
@@ -94,9 +102,15 @@ export default function Overview() {
</thead>
<tbody>
{servers.slice(0, 5).map((s) => (
<tr key={s.id} className="border-b border-[--color-border] last:border-0 hover:bg-[--color-bg-subtle]">
<tr
key={s.id}
className="border-b border-[--color-border] last:border-0 hover:bg-[--color-bg-subtle]"
>
<td className="px-4 py-2.5">
<Link href={`/servers/${s.id}`} className="font-medium hover:text-[--color-accent]">
<Link
href={`/servers/${s.id}`}
className="font-medium hover:text-[--color-accent]"
>
{s.name}
</Link>
</td>

View File

@@ -1,15 +1,21 @@
import Link from 'next/link';
import { CookieBanner } from '@/components/cookie-banner';
import { Logo } from '@/components/logo';
import { LayoutGrid, Server, Settings, FileClock, Package } from 'lucide-react';
import { MobileActionBar } from '@/components/mobile-action-bar';
import { PulseLink } from '@/components/pulse';
import { UserMenu } from '@/components/user-menu';
import { FileClock, LayoutGrid, Package, Server, Settings } from 'lucide-react';
import Link from 'next/link';
export default function DashboardLayout({ 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-7xl items-center justify-between px-6">
<div className="flex items-center gap-6">
<div className="mx-auto flex h-12 max-w-7xl items-center justify-between gap-2 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-2 sm:gap-6">
<Logo />
<nav className="flex items-center gap-1">
{/* Desktop nav — on mobile this is hidden and destinations live
in the bottom MobileActionBar tab-bar instead. */}
<nav className="hidden items-center gap-0.5 sm:flex sm:gap-1">
<NavLink href="/dashboard" icon={<LayoutGrid size={13} />}>
Overview
</NavLink>
@@ -27,15 +33,20 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</NavLink>
</nav>
</div>
<Link
<div className="flex items-center gap-1 sm:gap-2">
<PulseLink
href="/servers/new"
className="inline-flex h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex"
>
+ New server
</Link>
</PulseLink>
<UserMenu />
</div>
</div>
</header>
<main className="flex-1 bg-[--color-bg]">{children}</main>
<main className="flex-1 bg-[--color-bg] pb-20 sm:pb-0">{children}</main>
<MobileActionBar />
<CookieBanner />
</div>
);
}
@@ -55,7 +66,7 @@ function NavLink({
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12.5px] text-[--color-fg-muted] transition-colors hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
>
{icon}
{children}
<span className="hidden sm:inline">{children}</span>
</Link>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { StatusPill } from '@/components/status-pill';
import { CodeBlock } from '@/components/code-block';
@@ -38,11 +38,14 @@ type Tab = 'overview' | 'tools' | 'logs' | 'metrics' | 'secrets' | 'iterate' | '
export default function ServerDetailPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const [server, setServer] = useState<ServerDetail | null>(null);
const [builds, setBuilds] = useState<BuildSummary[]>([]);
const [tab, setTab] = useState<Tab>('overview');
const [iteratePrompt, setIteratePrompt] = useState('');
const [latestBuildId, setLatestBuildId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
async function refresh() {
const r = await apiFetch<{ server: ServerDetail; builds: BuildSummary[] }>(
@@ -72,6 +75,27 @@ export default function ServerDetailPage() {
setTab('logs');
}
async function onDelete() {
if (!server) return;
// Destructive — the running container is torn down and the row is gone.
// Browser confirm is enough at this scope (single operator, no users yet);
// upgrade to a typed-confirmation dialog once we have customer-tier data.
const sure = window.confirm(
`Delete "${server.name}" (${server.slug})? The running container is stopped and the server row is removed. This cannot be undone.`,
);
if (!sure) return;
setDeleting(true);
setDeleteError(null);
try {
await apiFetch(`/v1/servers/${server.id}`, { method: 'DELETE' });
router.push('/servers');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setDeleteError(detail?.detail ?? detail?.error ?? (e as Error).message);
setDeleting(false);
}
}
if (!server) {
return (
<div className="mx-auto max-w-7xl px-6 py-8 text-[12.5px] text-[--color-fg-muted]">Loading</div>
@@ -114,6 +138,16 @@ export default function ServerDetailPage() {
</>
)}
</div>
{deleteError && (
<div className="mt-2 text-[12px] text-[--color-danger]">
Delete failed: {deleteError}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="danger" size="sm" onClick={onDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete server'}
</Button>
</div>
</div>

View File

@@ -1,14 +1,15 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { Button } from '@/components/ui/button';
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 { Input, Label, Textarea } from '@/components/input';
import { InstallSnippets } from '@/components/install-snippets';
import { StreamingLogs } from '@/components/streaming-logs';
import { Button } from '@/components/ui/button';
import { apiFetch, apiSseStream, humanizeError } from '@/lib/api';
import { findSecretInPrompt } from '@bmm/types';
import { Loader2, RotateCcw, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
const EXAMPLE_PROMPTS = [
{
@@ -41,9 +42,15 @@ interface PreviewTool {
inputSchema: Record<string, unknown>;
}
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
interface PreviewResponse {
previewId: string;
source: 'claude' | 'mock';
source: 'claude' | 'glm' | 'mock';
plan?: Plan;
modelDisplayName?: string;
modelBadge?: 'open-tier' | 'claude-haiku' | 'claude-sonnet' | 'claude-opus';
upgradeHint?: boolean;
spec: {
name: string;
description?: string;
@@ -53,6 +60,13 @@ interface PreviewResponse {
};
}
const PREVIEW_MODEL_BY_PLAN: Record<Plan, { name: string; estimate: string }> = {
hobby: { name: 'Open-tier AI', estimate: '3060 seconds' },
pro: { name: 'Claude Haiku 4.5', estimate: '1020 seconds' },
team: { name: 'Claude Sonnet 4.6', estimate: '1540 seconds' },
enterprise: { name: 'Claude Sonnet 4.6', estimate: '1540 seconds' },
};
interface EditableTool {
name: string;
description: string;
@@ -82,9 +96,11 @@ function specToEditable(spec: PreviewResponse['spec']): EditableSpec {
};
}
export default function NewServerPage() {
function NewServerPageInner() {
const router = useRouter();
const [step, setStep] = useState<Step>('prompt');
const [elapsedSec, setElapsedSec] = useState(0);
const [userPlan, setUserPlan] = useState<Plan | null>(null);
const [prompt, setPrompt] = useState('');
const [name, setName] = useState('');
@@ -105,7 +121,11 @@ export default function NewServerPage() {
const templateSlug = searchParams.get('template');
const trySlug = (n: string) =>
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
n
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
useEffect(() => {
@@ -189,6 +209,27 @@ export default function NewServerPage() {
}
}, [preview, editable]);
// Live elapsed counter for the analyze step — a value that ticks every
// second is unambiguous proof the page is alive, even when CSS animation is
// suppressed (e.g. the OS "reduce motion" setting).
useEffect(() => {
if (step !== 'analyzing') return;
setElapsedSec(0);
const startedAt = Date.now();
const id = setInterval(() => {
setElapsedSec(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => clearInterval(id);
}, [step]);
// Plan determines which model the preview will use — we display its name
// *before* the request so the user knows what they're waiting for.
useEffect(() => {
apiFetch<{ user: { plan?: Plan } }>('/v1/auth/me')
.then((r) => setUserPlan(r.user.plan ?? 'hobby'))
.catch(() => setUserPlan('hobby'));
}, []);
async function analyze() {
setError(null);
if (prompt.trim().length < 10) {
@@ -199,22 +240,81 @@ export default function NewServerPage() {
setError('Name and slug are required.');
return;
}
// Keep credentials out of the model: block a prompt that contains a real
// key/token before it is ever sent. Credentials go in the encrypted fields
// in the next step, never in the prompt.
const leaked = findSecretInPrompt(prompt);
if (leaked) {
setError(
`Looks like your prompt contains ${leaked}. Remove it — API keys must never go in the prompt (it is sent to the AI). You'll add credentials in their own encrypted fields in the next step.`,
);
return;
}
setStep('analyzing');
// Streaming preview: pipes Anthropic's token deltas back as SSE. Cloudflare's
// ~100s edge cap doesn't bite because every chunk we receive resets the
// idle timer; the only practical limit is the model's own runtime. If the
// backend returns 409 streaming_unavailable (e.g. hobby/GLM tier), we fall
// back to the sync endpoint so the wizard still works there.
let finalResolved = false;
let sseError: { error?: string; detail?: string } | null = null;
let sseSpec: PreviewResponse | null = null;
await apiSseStream(
'/v1/servers/preview/stream',
{ prompt },
{
onEvent: (event, data) => {
if (event === 'spec') {
finalResolved = true;
sseSpec = data as PreviewResponse;
} else if (event === 'error') {
finalResolved = true;
sseError = data as { error?: string; detail?: string };
}
// 'text' deltas are ignored for now — the wizard already shows a
// spinner. We could surface partial JSON later if useful.
},
onError: (err) => {
sseError = { detail: err.message };
},
},
);
if (finalResolved && sseSpec) {
setPreview(sseSpec);
setEditable(null);
setStep('confirm');
return;
}
if (sseError && (sseError as { error?: string }).error === 'streaming_unavailable') {
// GLM / mock tier — fall back to sync.
try {
const res = await apiFetch<PreviewResponse>('/v1/servers/preview', {
method: 'POST',
body: JSON.stringify({ prompt }),
});
setPreview(res);
setEditable(null); // will re-init via useEffect
setEditable(null);
setStep('confirm');
return;
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
setError(humanizeError(e));
setStep('prompt');
return;
}
}
setError(
(sseError as { detail?: string } | null)?.detail ??
(sseError as { error?: string } | null)?.error ??
'Spec generation failed.',
);
setStep('prompt');
}
function updateTool(i: number, patch: Partial<EditableTool>) {
setEditable((prev) => {
if (!prev) return prev;
@@ -323,9 +423,7 @@ export default function NewServerPage() {
};
try {
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
'/v1/servers',
{
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>('/v1/servers', {
method: 'POST',
body: JSON.stringify({
name,
@@ -337,19 +435,28 @@ export default function NewServerPage() {
// are already in the Redis cache. Edits would invalidate the impls.
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
}),
},
);
});
setBuildId(res.build.id);
setServerId(res.server.id);
setStep('building');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: unknown } }).detail;
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
const code = detail?.error;
if (code === 'slug_taken') {
setError(
code === 'slug_taken'
? `The slug "${slug}" is already used by one of your servers — change the Slug field above.`
: (code ?? (e as Error).message),
`The slug "${slug}" is already used by one of your servers — change the Slug field above.`,
);
return;
}
if (code === 'plan_limit_reached') {
setError(`${detail?.detail ?? 'Plan limit reached.'} See /pricing to upgrade.`);
return;
}
if (code === 'rate_limited') {
setError(detail?.detail ?? 'Daily build limit reached — try again tomorrow or upgrade.');
return;
}
setError(humanizeError(e));
}
}
@@ -382,7 +489,11 @@ export default function NewServerPage() {
/>
<p className="text-[12px] leading-relaxed text-[--color-fg-subtle]">
Next step we&apos;ll show you exactly which tools we&apos;ll expose and let you tweak
the spec before we build.
the spec before we build.{' '}
<span className="text-[--color-fg-muted]">
Don&apos;t paste API keys or access tokens here you&apos;ll add each one in its own
encrypted field in the next step.
</span>
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{EXAMPLE_PROMPTS.map((p) => (
@@ -412,7 +523,9 @@ export default function NewServerPage() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slug" hint="becomes subdomain / id">Slug</Label>
<Label htmlFor="slug" hint="becomes subdomain / id">
Slug
</Label>
<Input
id="slug"
value={slug}
@@ -437,10 +550,15 @@ export default function NewServerPage() {
{step === 'analyzing' && (
<div className="mt-10 panel p-8 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
<Loader2 className="mx-auto animate-spin text-[--color-accent]" size={22} />
<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 2040 seconds.
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).name} is
drafting the tool spec. Usually{' '}
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}.
</p>
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
{elapsedSec}s elapsed
</p>
</div>
)}
@@ -488,9 +606,36 @@ export default function NewServerPage() {
</div>
</div>
)}
{!forkedTemplateTitle && (
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="confirm-name">Name</Label>
<Input
id="confirm-name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (!slug || slug === trySlug(name)) setSlug(trySlug(e.target.value));
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-slug" hint="must be unique in your workspace · part of the URL">
Slug
</Label>
<Input
id="confirm-slug"
value={slug}
onChange={(e) => setSlug(trySlug(e.target.value))}
/>
</div>
</div>
)}
<div className="panel p-4">
<div className="flex items-baseline justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we&apos;ll build</h2>
<h2 className="text-[14px] font-semibold tracking-tight">
Confirm what we&apos;ll build
</h2>
<div className="flex items-center gap-3">
{editsDirty && (
<button
@@ -502,15 +647,15 @@ export default function NewServerPage() {
</button>
)}
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
spec via {preview.source}
drafted with {preview.modelDisplayName ?? preview.source}
</span>
</div>
</div>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
<p className="mt-3 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
Edit tool names, descriptions or input schemas inline. Renaming parameters may
require an <span className="mono">Iterate</span> after build to update the
implementation the existing impl references the original names.
Edit tool names, descriptions or input schemas inline. Renaming parameters may require
an <span className="mono">Iterate</span> after build to update the implementation
the existing impl references the original names.
</p>
</div>
@@ -560,17 +705,18 @@ export default function NewServerPage() {
</div>
<div>
<h3 className="text-[13px] font-semibold tracking-tight">
Credentials we need
</h3>
<h3 className="text-[13px] font-semibold tracking-tight">API keys &amp; credentials</h3>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
implementation doesn&apos;t actually use one.
One field per key or access token entered here, separately from your prompt.
AES-256-GCM encrypted at rest, injected as env vars at runtime only. Remove any your
implementation doesn&apos;t use; add any we missed.
</p>
<div className="mt-3 space-y-2">
{editable.requiredSecrets.length === 0 && (
<p className="text-[12.5px] text-[--color-fg-muted]">
No credentials. This server runs self-contained.
None detected. If your tool calls an API that needs a key or access token, add it
below with <span className="mono">+ Add credential</span> never put secrets in
the prompt.
</p>
)}
{editable.requiredSecrets.map((key, idx) => (
@@ -625,12 +771,7 @@ export default function NewServerPage() {
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
Back
</Button>
<Button
variant="primary"
size="md"
onClick={build}
disabled={Boolean(hasSchemaErrors)}
>
<Button variant="primary" size="md" onClick={build} disabled={Boolean(hasSchemaErrors)}>
Build server
</Button>
</div>
@@ -708,6 +849,26 @@ export default function NewServerPage() {
);
}
// useSearchParams() forces client-side rendering — Next requires a Suspense
// boundary around it, or `next build` bails out of static generation.
export default function NewServerPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-[22px] font-semibold tracking-tight">New MCP server</h1>
<div className="panel mt-10 p-8 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
<p className="mt-4 text-[13px] text-[--color-fg-muted]">Loading</p>
</div>
</div>
}
>
<NewServerPageInner />
</Suspense>
);
}
const SHARE_CATEGORIES = [
'productivity',
'developer-tools',
@@ -734,9 +895,7 @@ function SharePanel({
}) {
const [share, setShare] = useState(true);
const [category, setCategory] = useState('other');
const [shortDescription, setShortDescription] = useState(
defaultShortDescription.slice(0, 280),
);
const [shortDescription, setShortDescription] = useState(defaultShortDescription.slice(0, 280));
const [hints, setHints] = useState<Record<string, string>>(() =>
Object.fromEntries(secretKeys.map((k) => [k, ''])),
);
@@ -812,8 +971,8 @@ function SharePanel({
</div>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
Your secrets stay private they are never copied into a template. But your{' '}
<span className="text-[--color-fg]">generated code becomes publicly viewable</span>{' '}
so others can audit it before forking. Unshare anytime.
<span className="text-[--color-fg]">generated code becomes publicly viewable</span> so
others can audit it before forking. Unshare anytime.
</p>
</div>
</label>
@@ -848,9 +1007,7 @@ function SharePanel({
{secretKeys.length > 0 && (
<div className="space-y-1.5">
<Label hint="optional — helps forkers know what to paste">
Credential hints
</Label>
<Label hint="optional — helps forkers know what to paste">Credential hints</Label>
{secretKeys.map((k) => (
<div key={k} className="grid grid-cols-[180px_1fr] gap-2">
<div className="mono flex h-8 items-center rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[12px] text-[--color-fg-muted]">
@@ -872,12 +1029,7 @@ function SharePanel({
<p className="text-[11px] text-[--color-fg-subtle]">
Published code is re-scanned for banned patterns and hardcoded secrets.
</p>
<Button
variant="primary"
size="md"
onClick={publish}
disabled={state === 'submitting'}
>
<Button variant="primary" size="md" onClick={publish} disabled={state === 'submitting'}>
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
</Button>
</div>

View File

@@ -0,0 +1,116 @@
'use client';
import { Button } from '@/components/ui/button';
import { apiFetch, apiUrl } from '@/lib/api';
import Link from 'next/link';
import { useState } from 'react';
export default function AccountPage() {
const [downloading, setDownloading] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [deleting, setDeleting] = useState(false);
const [delError, setDelError] = useState<string | null>(null);
async function deleteAccount() {
if (!confirmText.trim()) return;
if (!confirm('Permanently delete your account and all its data? This cannot be undone.')) return;
setDeleting(true);
setDelError(null);
try {
await apiFetch('/v1/account', {
method: 'DELETE',
body: JSON.stringify({ confirm: confirmText.trim() }),
});
window.location.href = '/';
} catch (e) {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setDelError(detail?.detail ?? detail?.error ?? (e as Error).message);
setDeleting(false);
}
}
async function downloadExport() {
setDownloading(true);
try {
// Trigger a same-origin attachment download. The cookie ships with the
// request because we're same-credentials with the API origin via CORS.
window.location.href = apiUrl('/v1/account/export');
} finally {
setTimeout(() => setDownloading(false), 1500);
}
}
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<h1 className="text-[22px] font-semibold tracking-tight">Account</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Your data, your rights. Swiss DSG Art. 25 / GDPR Art. 15 + 20.
</p>
<div className="mt-8 space-y-4">
<section className="panel p-5">
<h2 className="text-[14px] font-semibold tracking-tight">Download your data</h2>
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
One JSON file with everything we hold for your account: profile, organization, MCP
servers, build history (last 1000 entries), audit log (last 1000 events) and your
support-ticket history. Excludes password hashes, encrypted secrets and other
users&apos; data.
</p>
<div className="mt-4">
<Button variant="primary" size="md" onClick={downloadExport} disabled={downloading}>
{downloading ? 'Preparing…' : 'Download .json'}
</Button>
</div>
</section>
<section className="panel border-[--color-danger]/30 p-5">
<h2 className="text-[14px] font-semibold tracking-tight text-[--color-danger]">
Delete account
</h2>
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
Permanently erases your account and every organization where you are the only member
servers, encrypted secrets, builds and history are wiped and running containers are
stopped. This cannot be undone. Swiss DSG Art. 32 / GDPR Art. 17.
</p>
<p className="mt-3 text-[12px] text-[--color-fg-subtle]">
Type your account email (or phone) to confirm:
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="you@example.com"
className="h-9 w-64 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-3 text-[13px] outline-none transition-colors focus:border-[--color-border-strong]"
/>
<button
type="button"
onClick={deleteAccount}
disabled={deleting || !confirmText.trim()}
className="inline-flex h-9 items-center rounded-md border border-[--color-danger]/50 bg-[--color-danger]/10 px-4 text-[13px] font-medium text-[--color-danger] transition-colors hover:bg-[--color-danger]/20 disabled:opacity-50"
>
{deleting ? 'Deleting…' : 'Delete my account'}
</button>
</div>
{delError && <p className="mt-2 text-[12px] text-[--color-danger]">{delError}</p>}
</section>
<section className="panel p-5">
<h2 className="text-[14px] font-semibold tracking-tight">Cookies on this site</h2>
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
We use only strictly-necessary cookies: a session cookie (
<span className="mono">bmm_session</span>, httpOnly, 30 days) and a short-lived
OAuth-CSRF state cookie (<span className="mono">bmm_oauth_state</span>, 10 minutes
during a third-party login flow). No analytics, no tracking, no third-party cookies on
this domain.
</p>
</section>
</div>
<div className="mt-10 text-[12px] text-[--color-fg-subtle]">
<Link href="/privacy" className="hover:text-[--color-fg]">
Privacy policy
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,596 @@
'use client';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { Loader2, X } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useCallback, useEffect, useState } from 'react';
// Load Stripe.js once at module scope (Stripe's recommendation). Null when the
// publishable key isn't baked into the build — the modal then shows a clear
// "not configured" message instead of throwing.
const STRIPE_PK = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_PK ? loadStripe(STRIPE_PK) : null;
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
interface SubscriptionInfo {
id: string;
status: string;
currentPeriodEnd: number;
cancelAtPeriodEnd: boolean;
priceId: string | null;
amount: number | null;
currency: string | null;
interval: string | null;
}
interface Invoice {
id: string;
number: string | null;
status: string | null;
amountPaid: number;
currency: string;
created: number;
pdfUrl: string | null;
hostedUrl: string | null;
}
interface BillingStatus {
plan: Plan;
hasCustomer: boolean;
hasSubscription: boolean;
suspended: boolean;
suspendedReason: string | null;
subscription?: SubscriptionInfo;
invoices?: Invoice[];
_stripeError?: boolean;
}
const PLAN_LABEL: Record<Plan, string> = {
hobby: 'Hobby (free)',
pro: 'Pro',
team: 'Team',
enterprise: 'Enterprise',
};
function formatMoney(amount: number | null, currency: string | null): string {
if (amount === null || currency === null) return '—';
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100);
}
function BillingInner() {
const router = useRouter();
const searchParams = useSearchParams();
const justSubscribed = searchParams.get('success') === 'true';
const cancelledCheckout = searchParams.get('cancelled') === 'true';
const autoUpgradeTier = searchParams.get('tier') as Tier | null;
const [status, setStatus] = useState<BillingStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// When set, the in-app embedded Stripe checkout modal is open.
const [clientSecret, setClientSecret] = useState<string | null>(null);
const loadStatus = useCallback(() => {
apiFetch<BillingStatus>('/v1/billing/status')
.then(setStatus)
.catch((e) => {
const err = e as { status?: number };
if (err.status === 401) router.push('/login?returnTo=/settings/billing');
else setError((e as Error).message);
});
}, [router]);
useEffect(() => {
loadStatus();
}, [loadStatus]);
useEffect(() => {
if (!justSubscribed) return;
let tries = 0;
const id = setInterval(() => {
tries += 1;
loadStatus();
if (tries >= 6) clearInterval(id);
}, 1500);
return () => clearInterval(id);
}, [justSubscribed, loadStatus]);
const startCheckout = useCallback(async (tier: Tier) => {
setBusy(tier);
setError(null);
try {
const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', {
method: 'POST',
body: JSON.stringify({ tier }),
});
// Open the embedded checkout in-app instead of redirecting to Stripe.
setClientSecret(res.clientSecret);
} catch (e) {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
} finally {
setBusy(null);
}
}, []);
useEffect(() => {
if (!autoUpgradeTier || !status) return;
if (status.hasSubscription) return;
void startCheckout(autoUpgradeTier);
}, [autoUpgradeTier, status, startCheckout]);
async function changePlan(tier: Tier) {
if (!confirm(`Switch to ${tier.replace('_', ' ')}? Prorated charges apply immediately.`)) return;
setBusy(`change-${tier}`);
setError(null);
try {
await apiFetch('/v1/billing/change-plan', {
method: 'POST',
body: JSON.stringify({ tier }),
});
// Webhook will update plan asynchronously; poll briefly.
let tries = 0;
const id = setInterval(() => {
tries += 1;
loadStatus();
if (tries >= 6) clearInterval(id);
}, 1500);
} catch (e) {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
} finally {
setBusy(null);
}
}
async function cancelSubscription() {
if (!confirm('Cancel subscription at the end of the current billing period?')) return;
setBusy('cancel');
setError(null);
try {
await apiFetch('/v1/billing/cancel', { method: 'POST', body: '{}' });
loadStatus();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(null);
}
}
async function reactivateSubscription() {
setBusy('reactivate');
setError(null);
try {
await apiFetch('/v1/billing/reactivate', { method: 'POST', body: '{}' });
loadStatus();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(null);
}
}
if (!status && !error) {
return (
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
);
}
const sub = status?.subscription;
const hasSub = Boolean(status?.hasSubscription && sub);
const planValue = status?.plan ?? 'hobby';
const currentPlanIsPro = planValue === 'pro';
const currentPlanIsTeam = planValue === 'team';
return (
<div className="mx-auto max-w-3xl px-6 py-10">
{clientSecret && (
<CheckoutModal
clientSecret={clientSecret}
onClose={() => {
setClientSecret(null);
setBusy(null);
}}
/>
)}
<div>
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Plan, renewal, invoices and cancellation everything in-app.
</p>
</div>
{cancelledCheckout && (
<Alert tone="muted">Checkout cancelled. No charge made.</Alert>
)}
{justSubscribed && (
<Alert tone="success">
Subscription active. Plan updates within a few seconds refreshing
</Alert>
)}
{status?.suspended && (
<Alert tone="warn">
<strong>Subscription paused</strong> {status.suspendedReason ?? 'payment issue'}. New
servers + previews are blocked until payment succeeds; existing servers keep running.
</Alert>
)}
{status?._stripeError && (
<Alert tone="warn">
Live billing data temporarily unavailable showing local state only. Try again in a
minute.
</Alert>
)}
{error && <Alert tone="error">{error}</Alert>}
{status && (
<div className="panel mt-6 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
Current plan
</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-[22px] font-semibold tracking-tight">
{PLAN_LABEL[status.plan]}
</span>
{sub?.amount !== null && sub?.amount !== undefined && (
<span className="text-[13px] text-[--color-fg-muted]">
{formatMoney(sub.amount, sub.currency)} / {sub.interval}
</span>
)}
</div>
</div>
{sub && (
<div className="text-right text-[12px]">
<div className="text-[--color-fg-subtle]">
{sub.cancelAtPeriodEnd ? 'Cancels' : 'Renews'}
</div>
<div className="mt-0.5 mono text-[--color-fg]">
{new Date(sub.currentPeriodEnd * 1000).toLocaleDateString()}
</div>
</div>
)}
</div>
{hasSub && sub && (
<div className="mt-5 flex flex-wrap gap-2 border-t border-[--color-border] pt-4">
{sub.cancelAtPeriodEnd ? (
<>
<p className="w-full text-[12.5px] text-[--color-fg-muted]">
Scheduled to cancel on{' '}
<span className="mono text-[--color-fg]">
{new Date(sub.currentPeriodEnd * 1000).toLocaleDateString()}
</span>
. You keep paid features until then.
</p>
<Button
variant="primary"
size="md"
onClick={reactivateSubscription}
disabled={busy === 'reactivate'}
>
{busy === 'reactivate' ? 'Reactivating…' : 'Keep subscription'}
</Button>
</>
) : (
<Button
variant="ghost"
size="md"
onClick={cancelSubscription}
disabled={busy === 'cancel'}
>
{busy === 'cancel' ? 'Cancelling…' : 'Cancel subscription'}
</Button>
)}
</div>
)}
</div>
)}
{status && !status.hasSubscription && (
<>
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Choose a plan</h2>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<TierCard
name="Pro"
monthly={49}
yearly={490}
features={['5 MCP servers', '1M tool calls / mo', 'Priority build queue', 'Claude AI']}
busy={busy}
onSubscribe={startCheckout}
monthlyTier="pro_monthly"
yearlyTier="pro_yearly"
highlight
/>
<TierCard
name="Team"
monthly={199}
yearly={1990}
features={[
'25 MCP servers',
'10M tool calls / mo',
'Audit log',
'Claude AI',
]}
busy={busy}
onSubscribe={startCheckout}
monthlyTier="team_monthly"
yearlyTier="team_yearly"
/>
</div>
<p className="mt-4 text-[12px] text-[--color-fg-subtle]">
Annual saves 2 months. VAT calculated automatically based on your billing address.
Cancel anytime from this page service continues until end of period.
</p>
</>
)}
{status && status.hasSubscription && (
<>
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Switch plan</h2>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Prorated immediately. The new amount is added to your next invoice.
</p>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{!currentPlanIsPro && (
<PlanSwitch
label="Pro — €49 / month"
onClick={() => changePlan('pro_monthly')}
busy={busy === 'change-pro_monthly'}
/>
)}
<PlanSwitch
label={currentPlanIsPro ? 'Pro — €490 / year (2 months free)' : 'Pro — yearly'}
onClick={() => changePlan('pro_yearly')}
busy={busy === 'change-pro_yearly'}
/>
{!currentPlanIsTeam && (
<PlanSwitch
label="Team — €199 / month"
onClick={() => changePlan('team_monthly')}
busy={busy === 'change-team_monthly'}
/>
)}
<PlanSwitch
label={currentPlanIsTeam ? 'Team — €1990 / year (2 months free)' : 'Team — yearly'}
onClick={() => changePlan('team_yearly')}
busy={busy === 'change-team_yearly'}
/>
</div>
</>
)}
{status?.invoices && status.invoices.length > 0 && (
<>
<h2 className="mt-10 text-[15px] font-semibold tracking-tight">Invoices</h2>
<div className="panel mt-3 divide-y divide-[--color-border]">
{status.invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between px-4 py-3 text-[12.5px]">
<div>
<div className="mono text-[--color-fg]">{inv.number ?? inv.id}</div>
<div className="text-[11px] text-[--color-fg-subtle]">
{new Date(inv.created * 1000).toLocaleDateString()} · {inv.status ?? 'unknown'}
</div>
</div>
<div className="flex items-center gap-3">
<span className="mono text-[--color-fg]">
{formatMoney(inv.amountPaid, inv.currency)}
</span>
{inv.pdfUrl && (
<a
href={inv.pdfUrl}
target="_blank"
rel="noreferrer"
className="text-[11.5px] text-[--color-accent] hover:underline"
>
PDF
</a>
)}
{inv.hostedUrl && (
<a
href={inv.hostedUrl}
target="_blank"
rel="noreferrer"
className="text-[11.5px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
View
</a>
)}
</div>
</div>
))}
</div>
</>
)}
<p className="mt-10 text-[12px] text-[--color-fg-subtle]">
Payment-method updates and other rare actions:{' '}
<button
type="button"
onClick={async () => {
try {
const r = await apiFetch<{ url: string }>('/v1/billing/portal', {
method: 'POST',
body: '{}',
});
window.location.href = r.url;
} catch {
setError('Portal could not open');
}
}}
className="text-[--color-accent] hover:underline"
>
open Stripe billing portal
</button>{' '}
·{' '}
<Link href="/pricing" className="hover:text-[--color-fg]">
compare plans
</Link>
</p>
</div>
);
}
function CheckoutModal({
clientSecret,
onClose,
}: {
clientSecret: string;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm sm:p-8"
role="dialog"
aria-modal="true"
>
<div className="relative my-auto w-full max-w-xl rounded-lg border border-[--color-border] bg-[--color-bg] p-1 shadow-xl">
<button
type="button"
onClick={onClose}
aria-label="Close checkout"
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-[--color-fg-muted] hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
>
<X size={18} />
</button>
{stripePromise ? (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
) : (
<div className="p-6">
<Alert tone="error">
Payments arent configured (missing Stripe publishable key). Please contact support.
</Alert>
</div>
)}
</div>
</div>
);
}
function Alert({
tone,
children,
}: {
tone: 'muted' | 'success' | 'warn' | 'error';
children: React.ReactNode;
}) {
const cls =
tone === 'success'
? 'border-emerald-500/30 bg-emerald-500/10'
: tone === 'warn'
? 'border-amber-500/40 bg-amber-500/10'
: tone === 'error'
? 'border-[--color-danger]/40 bg-[--color-danger]/10'
: 'border-[--color-border] bg-[--color-bg-subtle]';
return (
<div className={`mt-4 rounded-md border px-3.5 py-2.5 text-[12.5px] ${cls}`}>
{children}
</div>
);
}
function TierCard({
name,
monthly,
yearly,
features,
busy,
onSubscribe,
monthlyTier,
yearlyTier,
highlight,
}: {
name: string;
monthly: number;
yearly: number;
features: string[];
busy: string | null;
onSubscribe: (tier: Tier) => void;
monthlyTier: Tier;
yearlyTier: Tier;
highlight?: boolean;
}) {
return (
<div
className={`panel flex h-full flex-col p-4 ${highlight ? 'border-[--color-accent]/40' : ''}`}
>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{name}</div>
<div className="mt-1 flex items-baseline gap-1">
<span className="text-[24px] font-semibold tracking-tight">{monthly}</span>
<span className="text-[12px] text-[--color-fg-subtle]">/ month</span>
</div>
<ul className="mt-3 space-y-1 text-[12.5px] text-[--color-fg-muted]">
{features.map((f) => (
<li key={f}> {f}</li>
))}
</ul>
<div className="mt-4 flex flex-col gap-2">
<Button
variant={highlight ? 'primary' : 'secondary'}
size="md"
onClick={() => onSubscribe(monthlyTier)}
disabled={Boolean(busy)}
>
{busy === monthlyTier ? 'Loading…' : `Subscribe — €${monthly}/mo`}
</Button>
<Button
variant="ghost"
size="md"
onClick={() => onSubscribe(yearlyTier)}
disabled={Boolean(busy)}
>
{busy === yearlyTier ? 'Loading…' : `Or €${yearly}/year — 2 months free`}
</Button>
</div>
</div>
);
}
function PlanSwitch({
label,
onClick,
busy,
}: {
label: string;
onClick: () => void;
busy: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={busy}
className="panel flex items-center justify-between p-3 text-left text-[12.5px] transition-colors hover:bg-[--color-bg-subtle] disabled:opacity-60"
>
<span className="text-[--color-fg]">{label}</span>
<span className="text-[--color-fg-muted]">{busy ? '…' : '→'}</span>
</button>
);
}
export default function BillingPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
}
>
<BillingInner />
</Suspense>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
import { Input, Label } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface Profile {
id: string;
email: string;
name: string | null;
phone: string | null;
isAdmin: boolean;
createdAt: string;
}
export default function ProfilePage() {
const [profile, setProfile] = useState<Profile | null>(null);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
function load() {
apiFetch<{ profile: Profile }>('/v1/account/profile')
.then((r) => {
setProfile(r.profile);
setName(r.profile.name ?? '');
})
.catch((e) => setError((e as Error).message));
}
useEffect(load, []);
async function save(e: React.FormEvent) {
e.preventDefault();
if (!profile) return;
setBusy(true);
setError(null);
setSaved(false);
try {
await apiFetch('/v1/account/profile', {
method: 'PATCH',
body: JSON.stringify({ name: name.trim() }),
});
setSaved(true);
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
if (!profile && !error) {
return (
<div className="mx-auto max-w-2xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
);
}
if (!profile) {
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<p className="text-[13px] text-[--color-danger]">{error}</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-6 py-10">
<h1 className="text-[22px] font-semibold tracking-tight">Profile</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Personal details. Email and phone can&apos;t be changed self-service yet open a{' '}
<Link href="/settings/support" className="text-[--color-accent] hover:underline">
support ticket
</Link>
{' '}
if you need it changed.
</p>
<form onSubmit={save} className="panel mt-6 space-y-4 p-5">
<div className="space-y-1.5">
<Label htmlFor="name">Display name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={128}
placeholder="How should we address you?"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<ReadField label="Email" value={profile.email} mono />
<ReadField label="Phone" value={profile.phone ?? '—'} mono />
<ReadField label="Account created" value={new Date(profile.createdAt).toLocaleString()} />
<ReadField label="Role" value={profile.isAdmin ? 'Admin' : 'Member'} />
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
{saved && <p className="text-[12.5px] text-emerald-300">Saved.</p>}
<div className="flex justify-end">
<Button
variant="primary"
size="md"
type="submit"
disabled={busy || name.trim() === (profile.name ?? '')}
>
{busy ? 'Saving…' : 'Save changes'}
</Button>
</div>
</form>
<div className="mt-8 grid gap-3 sm:grid-cols-3 text-[12px]">
<Link href="/settings/billing" className="panel p-3 text-[--color-fg-muted] transition-colors hover:text-[--color-fg]">
Billing
</Link>
<Link href="/settings/support" className="panel p-3 text-[--color-fg-muted] transition-colors hover:text-[--color-fg]">
Support
</Link>
<Link href="/settings/account" className="panel p-3 text-[--color-fg-muted] transition-colors hover:text-[--color-fg]">
Your data
</Link>
</div>
</div>
);
}
function ReadField({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{label}</div>
<div className={`mt-1 text-[13px] text-[--color-fg] ${mono ? 'mono' : ''}`}>{value}</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import { Textarea } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
interface Ticket {
id: string;
subject: string;
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
createdAt: string;
lastMessageAt: string;
}
interface Message {
id: string;
authorIsAdmin: boolean;
body: string;
createdAt: string;
}
export default function TicketDetail() {
const params = useParams<{ id: string }>();
const [data, setData] = useState<{ ticket: Ticket; messages: Message[] } | null>(null);
const [reply, setReply] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function load() {
if (!params?.id) return;
apiFetch<{ ticket: Ticket; messages: Message[] }>(`/v1/support/tickets/${params.id}`)
.then(setData)
.catch((e) => setError((e as Error).message));
}
useEffect(load, [params?.id]);
async function sendReply(e: React.FormEvent) {
e.preventDefault();
if (!params?.id || reply.trim().length === 0) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/v1/support/tickets/${params.id}/messages`, {
method: 'POST',
body: JSON.stringify({ body: reply }),
});
setReply('');
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
if (!data && !error) {
return (
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
);
}
if (error || !data) {
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<p className="text-[13px] text-[--color-danger]">{error ?? 'Ticket not found.'}</p>
<Link href="/settings/support" className="mt-3 inline-block text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
Back to support
</Link>
</div>
);
}
const { ticket, messages } = data;
const isClosed = ticket.status === 'closed';
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<Link
href="/settings/support"
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
All tickets
</Link>
<div className="mt-3 flex items-baseline justify-between gap-3">
<h1 className="text-[22px] font-semibold tracking-tight">{ticket.subject}</h1>
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
{ticket.status.replace('_', ' ')}
</span>
</div>
<div className="mt-6 space-y-3">
{messages.map((m) => (
<div
key={m.id}
className={`panel p-4 ${m.authorIsAdmin ? 'border-[--color-accent]/40' : ''}`}
>
<div className="flex items-baseline justify-between">
<span
className={`text-[11.5px] font-medium ${m.authorIsAdmin ? 'text-[--color-accent]' : 'text-[--color-fg]'}`}
>
{m.authorIsAdmin ? 'Support' : 'You'}
</span>
<span className="text-[10.5px] text-[--color-fg-subtle]">
{new Date(m.createdAt).toLocaleString()}
</span>
</div>
<p className="mt-2 whitespace-pre-wrap text-[13px] leading-relaxed text-[--color-fg-muted]">
{m.body}
</p>
</div>
))}
</div>
{!isClosed && (
<form onSubmit={sendReply} className="panel mt-6 space-y-3 p-4">
<Textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
rows={4}
maxLength={10_000}
placeholder="Your reply…"
/>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex justify-end">
<Button
variant="primary"
size="md"
type="submit"
disabled={busy || reply.trim().length === 0}
>
{busy ? 'Sending…' : 'Send reply'}
</Button>
</div>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
import { Input, Label, Textarea } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface Ticket {
id: string;
subject: string;
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
createdAt: string;
lastMessageAt: string;
}
const STATUS_LABEL: Record<Ticket['status'], string> = {
awaiting_admin: 'Open — awaiting support',
awaiting_user: 'Reply received',
closed: 'Closed',
};
const STATUS_COLOR: Record<Ticket['status'], string> = {
awaiting_admin: 'text-amber-300',
awaiting_user: 'text-emerald-300',
closed: 'text-[--color-fg-subtle]',
};
export default function SupportPage() {
const [tickets, setTickets] = useState<Ticket[] | null>(null);
const [showNew, setShowNew] = useState(false);
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function load() {
apiFetch<{ tickets: Ticket[] }>('/v1/support/tickets')
.then((r) => setTickets(r.tickets))
.catch((e) => setError((e as Error).message));
}
useEffect(load, []);
async function createTicket(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await apiFetch('/v1/support/tickets', {
method: 'POST',
body: JSON.stringify({ subject, body }),
});
setSubject('');
setBody('');
setShowNew(false);
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-[22px] font-semibold tracking-tight">Support</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Open a ticket and we&apos;ll get back to you within one business day.
</p>
</div>
{!showNew && (
<Button variant="primary" size="md" onClick={() => setShowNew(true)}>
+ New ticket
</Button>
)}
</div>
{showNew && (
<form onSubmit={createTicket} className="panel mt-6 space-y-4 p-5">
<div className="space-y-1.5">
<Label htmlFor="t-subject">Subject</Label>
<Input
id="t-subject"
required
minLength={3}
maxLength={200}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Briefly — what's up?"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="t-body" hint={`${body.length} / 10000`}>
Message
</Label>
<Textarea
id="t-body"
required
rows={6}
minLength={10}
maxLength={10_000}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="The more context the better — server slug, error messages, what you expected."
/>
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="md" type="button" onClick={() => setShowNew(false)}>
Cancel
</Button>
<Button
variant="primary"
size="md"
type="submit"
disabled={busy || subject.length < 3 || body.length < 10}
>
{busy ? 'Sending…' : 'Open ticket'}
</Button>
</div>
</form>
)}
<div className="mt-8">
{tickets === null && (
<div className="panel p-6 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={18} />
</div>
)}
{tickets && tickets.length === 0 && !showNew && (
<div className="panel p-6 text-center text-[13px] text-[--color-fg-muted]">
No tickets yet.
</div>
)}
{tickets && tickets.length > 0 && (
<div className="panel divide-y divide-[--color-border]">
{tickets.map((t) => (
<Link
key={t.id}
href={`/settings/support/${t.id}`}
className="flex items-center justify-between px-4 py-3 transition-colors hover:bg-[--color-bg-subtle]"
>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[--color-fg]">
{t.subject}
</div>
<div className={`mt-0.5 text-[11.5px] ${STATUS_COLOR[t.status]}`}>
{STATUS_LABEL[t.status]} ·{' '}
<span className="text-[--color-fg-subtle]">
{new Date(t.lastMessageAt).toLocaleString()}
</span>
</div>
</div>
<span className="ml-3 text-[--color-fg-subtle]"></span>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,129 @@
import { pageMetadata } from '@/lib/seo';
import Link from 'next/link';
export const metadata = pageMetadata({
title: 'AGB',
description:
'Allgemeine Geschäftsbedingungen für die Nutzung von BuildMyMCPServer (Schweiz).',
path: '/agb',
});
const SECTIONS: Array<{ h: string; p: string[] }> = [
{
h: '1. Geltungsbereich',
p: [
'Diese AGB regeln die Nutzung der über buildmymcpserver.com bereitgestellten Dienste durch natürliche und juristische Personen ("Kund:in"). Mit Erstellung eines Accounts oder Abschluss eines kostenpflichtigen Abonnements bestätigt die Kund:in, diese AGB gelesen, verstanden und akzeptiert zu haben.',
'Abweichende Bedingungen der Kund:in gelten nur, wenn schriftlich bestätigt.',
],
},
{
h: '2. Vertragsgegenstand',
p: [
'BuildMyMCPServer ist ein Software-as-a-Service-Angebot zur Generierung und zum Betrieb von Model-Context-Protocol-Servern (MCP-Server). Der Funktionsumfang ergibt sich aus dem jeweils gewählten Tarif gemäss Pricing-Seite.',
'Wir liefern den Service "as-is" nach bestem Bemühen. Für die Self-Service-Tarife (Hobby, Pro, Team) besteht keine zugesicherte Verfügbarkeits-SLA; eine Enterprise-Verfügbarkeit wird individuell vertraglich vereinbart.',
],
},
{
h: '3. Account und Sicherheit',
p: [
'Die Kund:in ist verpflichtet, Zugangsdaten vertraulich zu behandeln. Bei Verdacht auf unberechtigten Zugriff sind wir unverzüglich über das Support-Panel zu informieren.',
'Wir behalten uns vor, Accounts bei schwerwiegenden Verstössen gegen diese AGB oder geltendes Recht zu suspendieren.',
],
},
{
h: '4. Tarife und Bezahlung',
p: [
'Bezahlung erfolgt im Voraus über unseren Zahlungsdienstleister Stripe Payments Europe Ltd. (Irland). Akzeptierte Zahlungsmethoden umfassen Kreditkarte und SEPA-Lastschrift.',
'Monatliche Tarife werden monatlich, Jahres-Tarife jährlich abgerechnet. Bei Jahres-Tarif werden zwei Monate gratis gewährt.',
'Preise verstehen sich vorbehältlich gesetzlicher Mehrwertsteuer. Die anwendbare MwSt. wird durch Stripe Tax automatisch nach Sitz der Kund:in berechnet und ausgewiesen.',
'Nutzungs-Overage (Tool-Calls über das tarifliche Kontingent hinaus) wird zu €0.02 / 1000 Calls am Folgemonat in Rechnung gestellt.',
],
},
{
h: '5. Laufzeit, Kündigung und Rückerstattung',
p: [
'Monats-Abos verlängern sich automatisch um einen Monat, Jahres-Abos um ein Jahr. Eine Kündigung ist jederzeit über das Kundenportal (Stripe) zur nächsten Periode möglich.',
'Bereits gezahlte Beträge werden bei Kündigung nicht anteilig rückerstattet; der Service bleibt bis Periodenende aktiv.',
'Wir gewähren eine 14-tägige Geld-zurück-Garantie ab Erst-Buchung (nicht bei Verlängerungen). Anfragen über das Support-Panel.',
],
},
{
h: '6. Aussetzung bei Zahlungsverzug',
p: [
'Bei fehlgeschlagener Zahlung versucht Stripe automatisch Nachzahlungen. Nach drei erfolglosen Versuchen wird der Account in den "suspended"-Status versetzt: Bestehende MCP-Server laufen weiter, jedoch können keine neuen Server angelegt oder Builds gestartet werden.',
'Nach erfolgreicher Aktualisierung der Zahlungsmethode wird der Account automatisch reaktiviert.',
],
},
{
h: '7. Daten der Kund:in',
p: [
'Die Kund:in behält alle Rechte an ihren Inhalten (Prompts, Konfigurationen, Secrets, generierter Code). Wir nutzen diese ausschliesslich zur Erbringung des Dienstes.',
'Eine Datenexport-Funktion ist über das Einstellungsmenü verfügbar und entspricht Art. 25 Schweizer Datenschutzgesetz (DSG) sowie Art. 15 DSGVO.',
'Details zur Datenverarbeitung siehe unsere Datenschutzerklärung.',
],
},
{
h: '8. KI-Verarbeitung',
p: [
'Zur Spec-Generierung übermitteln wir Prompt-Texte an unsere KI-Anbieter: Hobby-Tarif → Zhipu AI (China); Pro/Team/Enterprise → Anthropic (USA). Vor Versand keiner sensiblen Daten gilt: Die Kund:in ist verantwortlich, welche Informationen sie in Prompts einfügt.',
'Der generierte Code wird statisch auf gefährliche Patterns (eval, child_process, Prompt-Injection-Marker) geprüft, jedoch nicht funktional verifiziert. Die Kund:in prüft den Code vor Produktivnutzung selbst.',
],
},
{
h: '9. Haftung',
p: [
'Wir haften nur für Schäden, die auf vorsätzlichem oder grob fahrlässigem Verhalten beruhen. Die Haftung für leichte Fahrlässigkeit, Mangelfolgeschäden, entgangenen Gewinn und Drittansprüche ist im gesetzlich zulässigen Umfang ausgeschlossen.',
'Wir haften nicht für Inhalte oder Verhalten von Drittanbietern (Anthropic, Zhipu, Stripe, Hetzner u.a.), an die personenbezogene Daten gemäss Datenschutzerklärung übermittelt werden.',
],
},
{
h: '10. Änderungen',
p: [
'Wir behalten uns vor, diese AGB sowie Preise mit Wirkung für die Zukunft anzupassen. Änderungen werden mindestens 30 Tage vor Inkrafttreten per E-Mail oder im Dashboard angekündigt. Bei Preisanhebung steht der Kund:in ein ausserordentliches Kündigungsrecht zum Wirkungsdatum zu.',
],
},
{
h: '11. Anwendbares Recht und Gerichtsstand',
p: [
'Es gilt schweizerisches Recht unter Ausschluss kollisionsrechtlicher Bestimmungen sowie des UN-Kaufrechts. Ausschliesslicher Gerichtsstand ist der Sitz des Anbieters; zwingende Verbraucher-Gerichtsstände bleiben vorbehalten.',
],
},
];
export default function Agb() {
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]">
Allgemeine Geschäftsbedingungen
</div>
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">AGB</h1>
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
Stand: 2026-05-25. Bei Fragen zur Auslegung erreichst du uns über das{' '}
<Link href="/contact" className="text-[--color-accent] underline">
Support-Panel
</Link>
.
</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>
<div className="mt-2 space-y-2">
{s.p.map((p) => (
<p
key={p.slice(0, 32)}
className="text-[13.5px] leading-relaxed text-[--color-fg-muted]"
>
{p}
</p>
))}
</div>
</section>
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,12 @@
import { CodeBlock } from '@/components/code-block';
import { pageMetadata } from '@/lib/seo';
export const metadata = { title: 'Changelog — BuildMyMCPServer' };
export const metadata = pageMetadata({
title: 'Changelog',
description:
'Product updates and release notes for BuildMyMCPServer — new features, fixes and improvements to the MCP server platform.',
path: '/changelog',
});
interface Release {
version: string;

View File

@@ -0,0 +1,15 @@
import { pageMetadata } from '@/lib/seo';
import type { ReactNode } from 'react';
// The contact page itself is a client component ('use client'), which cannot
// export metadata — so the canonical/description live here.
export const metadata = pageMetadata({
title: 'Contact',
description:
'Get in touch with the BuildMyMCPServer team — support, sales and security questions answered by email.',
path: '/contact',
});
export default function ContactLayout({ children }: { children: ReactNode }) {
return children;
}

View File

@@ -0,0 +1,133 @@
'use client';
import { Input, Label, Textarea } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import Link from 'next/link';
import { useState } from 'react';
export default function ContactPage() {
const [email, setEmail] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setState('sending');
setError(null);
try {
await apiFetch('/v1/contact', {
method: 'POST',
body: JSON.stringify({ email, subject, body }),
});
setState('sent');
} catch (err) {
setState('error');
const detail = (err as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (err as Error).message);
}
}
if (state === 'sent') {
return (
<div className="mx-auto max-w-2xl px-6 py-16">
<div className="panel p-6 text-center">
<h1 className="text-[20px] font-semibold tracking-tight">Message received</h1>
<p className="mt-2 text-[13.5px] text-[--color-fg-muted]">
Thank you we got your message. We&apos;ll reply to{' '}
<span className="text-[--color-fg]">{email}</span> within one business day.
</p>
<p className="mt-4 text-[12px] text-[--color-fg-subtle]">
<Link href="/" className="hover:text-[--color-fg]">
Back to home
</Link>
</p>
</div>
</div>
);
}
return (
<div className="mx-auto max-w-2xl px-6 py-14">
<header className="mb-8">
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
Contact
</div>
<h1 className="mt-2 text-[28px] font-semibold tracking-tight">Talk to us</h1>
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
We don&apos;t do public email every conversation runs through our internal support
panel so nothing gets lost. Already have an account?{' '}
<Link href="/settings/support" className="text-[--color-accent] hover:underline">
Open a ticket from inside
</Link>
.
</p>
</header>
<form onSubmit={submit} className="panel space-y-4 p-5">
<div className="space-y-1.5">
<Label htmlFor="contact-email">Your email</Label>
<Input
id="contact-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-subject">Subject</Label>
<Input
id="contact-subject"
required
minLength={3}
maxLength={200}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Briefly — what's this about?"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="contact-body" hint={`${body.length} / 10000`}>
Message
</Label>
<Textarea
id="contact-body"
required
rows={7}
minLength={10}
maxLength={10_000}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Tell us what's going on. We answer within one business day."
/>
</div>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex items-center justify-between pt-1">
<p className="text-[11px] text-[--color-fg-subtle]">
Submitting creates a support ticket see{' '}
<Link href="/privacy" className="hover:text-[--color-fg]">
privacy
</Link>
.
</p>
<Button
variant="primary"
size="md"
type="submit"
disabled={state === 'sending' || !email || subject.length < 3 || body.length < 10}
>
{state === 'sending' ? 'Sending…' : 'Send'}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
// Shared layout + typographic primitives for /guides/* SEO articles. Server
// component (no client JS) so each article page can export its own metadata.
export function ArticleShell({
title,
subtitle,
updated,
children,
}: {
title: string;
subtitle?: string;
updated?: string;
children: ReactNode;
}) {
return (
<article className="mx-auto max-w-3xl px-6 py-14">
<Link
href="/guides"
className="text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Guides
</Link>
<h1 className="mt-4 text-[30px] font-semibold leading-tight tracking-tight text-[--color-fg]">
{title}
</h1>
{subtitle && <p className="mt-3 text-[15px] leading-relaxed text-[--color-fg-muted]">{subtitle}</p>}
{updated && <p className="mt-2 text-[12px] text-[--color-fg-subtle]">Updated {updated}</p>}
<div className="mt-8">{children}</div>
<div className="mt-14 rounded-lg border border-[--color-border] bg-[--color-bg-subtle] p-5">
<p className="text-[14px] font-medium text-[--color-fg]">
Skip the boilerplate describe your tool, get a hosted MCP server.
</p>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
BuildMyMCPServer generates the TypeScript server, wraps it in OAuth 2.1 and deploys it to a
public Streamable HTTP URL for Claude, Cursor and ChatGPT. Free tier, source export, no
lock-in.
</p>
<Link
href="/login"
className="mt-3 inline-flex h-9 items-center rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#5557e8]"
>
Start building
</Link>
</div>
</article>
);
}
export function H2({ children }: { children: ReactNode }) {
return (
<h2 className="mt-10 text-[19px] font-semibold tracking-tight text-[--color-fg]">{children}</h2>
);
}
export function P({ children }: { children: ReactNode }) {
return <p className="mt-3 text-[14.5px] leading-relaxed text-[--color-fg-muted]">{children}</p>;
}
export function UL({ children }: { children: ReactNode }) {
return (
<ul className="mt-3 list-disc space-y-1.5 pl-5 text-[14.5px] leading-relaxed text-[--color-fg-muted]">
{children}
</ul>
);
}
export function Strong({ children }: { children: ReactNode }) {
return <strong className="font-semibold text-[--color-fg]">{children}</strong>;
}
export function OL({ children }: { children: ReactNode }) {
return (
<ol className="mt-3 list-decimal space-y-1.5 pl-5 text-[14.5px] leading-relaxed text-[--color-fg-muted]">
{children}
</ol>
);
}
/** Comparison / feature tables. Pass fully-formed <thead>/<tbody> children;
* the wrapper provides the horizontal-scroll container so wide tables never
* break the mobile viewport. */
export function Table({ children }: { children: ReactNode }) {
return (
<div className="mt-4 overflow-x-auto rounded-lg border border-[--color-border]">
<table className="w-full min-w-[560px] border-collapse text-left text-[13.5px] leading-relaxed [&_td]:border-t [&_td]:border-[--color-border] [&_td]:px-3.5 [&_td]:py-2.5 [&_td]:align-top [&_td]:text-[--color-fg-muted] [&_th]:bg-[--color-bg-subtle] [&_th]:px-3.5 [&_th]:py-2.5 [&_th]:text-[12px] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[--color-fg]">
{children}
</table>
</div>
);
}
/** Callout for caveats and version-sensitive facts. */
export function Note({ children }: { children: ReactNode }) {
return (
<div className="mt-4 rounded-lg border border-[--color-border] bg-[--color-bg-subtle] px-4 py-3 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
{children}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'Add a custom MCP connector to ChatGPT (2026 guide)';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'Add a custom MCP connector to ChatGPT (2026 guide)',
tag: 'Setup',
});
}

View File

@@ -0,0 +1,202 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, OL, P, Strong, Table, UL } from '../article-shell';
const PATH = '/guides/chatgpt-mcp-connector';
const TITLE = 'Add a custom MCP connector to ChatGPT (2026 guide)';
const DESCRIPTION =
'How to connect a custom MCP server to ChatGPT: setup flow, the HTTPS and OAuth requirements, and the plan limits nobody mentions — write-capable connectors need a Business, Enterprise or Edu workspace.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1300,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="ChatGPT has supported custom MCP connectors since September 2025 — but what you can actually do with one depends on your plan, and the requirements for the server side are stricter than Claude's. Here is the full picture before you build."
updated="July 2026"
>
<H2>The plan limits, first because they decide everything</H2>
<P>
Before writing a single prompt or line of code, check what your ChatGPT plan allows.
As of mid-2026, OpenAI gates custom MCP connectors by workspace type verify against
OpenAI&apos;s current help docs before committing to a plan, since these limits have
shifted before:
</P>
<Table>
<thead>
<tr>
<th>Plan</th>
<th>Custom MCP connectors</th>
<th>Practical meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>Free</td>
<td>No custom connectors</td>
<td>Only built-in connectors.</td>
</tr>
<tr>
<td>Plus / Pro (individual)</td>
<td>Read/fetch-only, via Developer Mode</td>
<td>
Your server's search and read tools work; tools that create, update or delete will
not be usable.
</td>
</tr>
<tr>
<td>Business / Enterprise / Edu</td>
<td>Full connectors, including write-capable tools</td>
<td>The complete MCP tool surface is available.</td>
</tr>
</tbody>
</Table>
<Note>
This is the most common “my connector is broken” report that is not a bug: a Plus user
adds a server with a <Strong>create_issue</Strong> tool and the tool never fires.
Read-only tools on the same server work fine. If write actions matter for your use case,
you need a Business/Enterprise/Edu workspace — or a client without this restriction, like
Claude Desktop (
<Link href="/guides/claude-desktop-mcp-setup" className="text-[--color-accent] hover:underline">
setup guide
</Link>
).
</Note>
<H2>What ChatGPT requires from the server</H2>
<UL>
<li>
<Strong>A public HTTPS URL.</Strong> Remote MCP server URLs must use HTTPS — no local
STDIO servers, no plain HTTP, no localhost tunnels for anything durable.
</li>
<li>
<Strong>Streamable HTTP transport.</Strong> The current MCP remote transport; legacy
HTTP+SSE-only servers are on borrowed time across all clients.
</li>
<li>
<Strong>OAuth for user-scoped auth.</Strong> ChatGPT walks the standard MCP OAuth flow.
One sharp edge: if the authorization server issues tokens without{' '}
<Strong>offline_access</Strong>-style refresh, ChatGPT can lose access when the token
expires and users must reauthenticate.
</li>
</UL>
<P>
If you generate and host your server on{' '}
<Link href="/" className="text-[--color-accent] hover:underline">
BuildMyMCPServer
</Link>
, all three are the default: every server deploys to a public HTTPS endpoint speaking
Streamable HTTP, behind an OAuth 2.1 authorization server with PKCE and Dynamic Client
Registration. There is nothing extra to configure for ChatGPT specifically.
</P>
<H2>Setup, step by step</H2>
<OL>
<li>
Get your server URL — from your own deployment, from{' '}
<Link href="/guides/create-mcp-server-without-code" className="text-[--color-accent] hover:underline">
a prompt-generated server
</Link>
, or by forking a{' '}
<Link href="/templates" className="text-[--color-accent] hover:underline">
template
</Link>
.
</li>
<li>
In ChatGPT: <Strong>Settings → Apps &amp; Connectors</Strong>. On individual plans,
enable <Strong>Developer Mode</Strong> under advanced settings first — the “create
connector” option is hidden without it.
</li>
<li>Add a new connector: name it, paste the MCP endpoint URL, select OAuth as the auth method.</li>
<li>
ChatGPT registers itself with the authorization server and opens the consent screen.
Approve; the connector shows as connected.
</li>
<li>
In a conversation, enable the connector (via the tools/plus menu) and ask for something
only your tool can answer. Name the tool explicitly on the first test.
</li>
</OL>
<H2>Verifying it actually works</H2>
<P>
ChatGPT is more eager than Claude to answer from its own knowledge instead of calling a
tool. To force a real call, ask for data the model cannot know — a record you created
today, a value behind your API. Then check the server's dashboard: a live tool-call log
with latency and status per call is the ground truth for whether the connector fired.
</P>
<H2>Common failure modes</H2>
<Table>
<thead>
<tr>
<th>Symptom</th>
<th>Cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Create connector option missing</td>
<td>Developer Mode off, or Free plan</td>
<td>Enable Developer Mode (Plus/Pro) or upgrade the workspace.</td>
</tr>
<tr>
<td>Write tools never execute</td>
<td>Individual plan read/fetch-only restriction</td>
<td>Business/Enterprise/Edu workspace, or use a write-capable client.</td>
</tr>
<tr>
<td>Connector disconnects after hours/days</td>
<td>No refresh token (offline_access missing)</td>
<td>Reconnect; if you control the AS, enable refresh token issuance.</td>
</tr>
<tr>
<td>Unable to reach server</td>
<td>URL is not public HTTPS, or wrong endpoint path</td>
<td>Use the full https://…/mcp endpoint; no localhost, no http.</td>
</tr>
</tbody>
</Table>
<H2>Is ChatGPT the right first client?</H2>
<P>
If your tools are read-only search, lookup, reporting ChatGPT works well on any paid
plan and the setup above takes minutes. If your tools write data, start with Claude
Desktop or Cursor where the full tool surface works on individual plans, and add ChatGPT
when a team workspace exists. The server is the same either way; only the client config
differs. Compare hosting options in{' '}
<Link href="/guides/hosted-mcp-platforms-compared" className="text-[--color-accent] hover:underline">
our platform comparison
</Link>
, or check{' '}
<Link href="/pricing" className="text-[--color-accent] hover:underline">
pricing
</Link>{' '}
the free tier is enough to test a connector end to end.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'Connect a custom MCP server to Claude Desktop (step by step)';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'Connect a custom MCP server to Claude Desktop (step by step)',
tag: 'Setup',
});
}

View File

@@ -0,0 +1,189 @@
import { JsonLd } from '@/components/json-ld';
import { StaticCodeBlock } from '@/components/static-code-block';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, OL, P, Strong, Table, UL } from '../article-shell';
const PATH = '/guides/claude-desktop-mcp-setup';
const TITLE = 'Connect a custom MCP server to Claude Desktop (step by step)';
const DESCRIPTION =
'How to add a remote MCP server to Claude Desktop: the config snippet, the OAuth flow on first use, and a troubleshooting table for 401s, missing servers and invisible tools.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
const CONFIG_SNIPPET = `{
"mcpServers": {
"my-tools": {
"url": "https://my-tools-a1.mcp.buildmymcpserver.com/mcp",
"auth": "oauth2"
}
}
}`;
const LOCAL_VS_REMOTE = `# Local (STDIO) — runs on your machine, per-machine setup
"command": "npx", "args": ["-y", "@your/mcp-server"]
# Remote (Streamable HTTP) — hosted, one URL for every machine
"url": "https://my-tools-a1.mcp.buildmymcpserver.com/mcp"`;
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1400,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="Claude Desktop supports both local STDIO servers and remote servers over Streamable HTTP. Remote is the version that survives a laptop change — here is the exact setup, including what the OAuth consent screen is doing."
updated="July 2026"
>
<H2>Local vs. remote pick remote unless you have a reason</H2>
<P>
Most MCP tutorials wire up a <Strong>local STDIO server</Strong>: Claude Desktop spawns a
process on your machine and talks to it over stdin/stdout. That works, but it is
per-machine every teammate repeats the setup, secrets live in local config files, and
nothing works from a second device. A <Strong>remote server over Streamable HTTP</Strong>{' '}
is one URL that every installation shares, with auth handled by OAuth instead of
plaintext keys in a JSON file.
</P>
<StaticCodeBlock code={LOCAL_VS_REMOTE} label="the difference in config terms" />
<H2>Step 1 get a server URL</H2>
<P>
You need a live MCP endpoint. If you already run one, use its URL. If not, you can{' '}
<Link href="/guides/create-mcp-server-without-code" className="text-[--color-accent] hover:underline">
generate one from a prompt
</Link>{' '}
or fork a working one from{' '}
<Link href="/templates" className="text-[--color-accent] hover:underline">
the template gallery
</Link>{' '}
either way you end up with an OAuth-protected URL like{' '}
<Strong>https://my-tools-a1.mcp.buildmymcpserver.com/mcp</Strong>.
</P>
<H2>Step 2 add the server to Claude Desktop</H2>
<OL>
<li>
Open Claude Desktop settings and go to the connectors/MCP section (on recent versions:
Settings Connectors Add custom connector), or edit{' '}
<Strong>claude_desktop_config.json</Strong> directly.
</li>
<li>Add the server entry:</li>
</OL>
<StaticCodeBlock code={CONFIG_SNIPPET} label="claude_desktop_config.json" />
<OL>
<li value={3}>Restart Claude Desktop. Config is read at startup, not live.</li>
</OL>
<Note>
Claude Desktop's settings UI changes between releases; the JSON config path is the stable
fallback. On macOS it lives at{' '}
<Strong>~/Library/Application Support/Claude/claude_desktop_config.json</Strong>, on
Windows at <Strong>%APPDATA%\Claude\claude_desktop_config.json</Strong>.
</Note>
<H2>Step 3 — the OAuth flow on first use</H2>
<P>
The first time Claude touches the server it will receive a <Strong>401</Strong> — that is
correct behavior, not an error. The client then discovers the authorization server,
registers itself via Dynamic Client Registration, and opens a browser window for consent.
You approve once; the client stores the token and refreshes it silently afterwards.
</P>
<P>
Under the hood this is the OAuth 2.1 handshake the MCP spec requires for remote servers:
PKCE so the code exchange cannot be intercepted, and Resource Indicators (RFC 8707) so a
token issued for this server cannot be replayed against another. Details in{' '}
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
the OAuth documentation
</Link>
.
</P>
<H2>Step 4 — verify the tools are there</H2>
<UL>
<li>Open a new conversation and check the tools/connectors icon — your server should be listed with its tools.</li>
<li>
Ask Claude directly: <em>“Use the search_pages tool to find X.”</em> Naming the tool
forces Claude to attempt the call instead of answering from memory.
</li>
<li>Watch your server's dashboard logs you should see the tool call arrive with latency and status.</li>
</UL>
<H2>Troubleshooting</H2>
<Table>
<thead>
<tr>
<th>Symptom</th>
<th>Likely cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Server never appears in the client</td>
<td>Config JSON is invalid, or Claude was not restarted</td>
<td>
Validate the JSON (trailing commas are the classic), restart Claude Desktop fully
(quit, not close window).
</td>
</tr>
<tr>
<td>Endless 401 loop, consent screen never opens</td>
<td>URL points at the server root instead of the /mcp endpoint</td>
<td>Use the full endpoint URL ending in /mcp, exactly as the install snippet shows.</td>
</tr>
<tr>
<td>Consent worked, tools still missing</td>
<td>Server is deployed but tool listing failed on connect</td>
<td>
Check the server's live logs for an initialize/list_tools error; redeploy if the
container restarted with a bad secret.
</td>
</tr>
<tr>
<td>Tool listed, every call errors</td>
<td>Upstream credential (e.g. your API key) is wrong or expired</td>
<td>
Update the secret in the dashboard — secrets are injected at runtime, so a redeploy
picks up the new value. They are never shown back, so re-enter rather than inspect.
</td>
</tr>
<tr>
<td>Worked yesterday, 401 today</td>
<td>Token expired and silent refresh failed</td>
<td>Remove and re-add the connector to force a fresh OAuth flow.</td>
</tr>
</tbody>
</Table>
<H2>One server, every client</H2>
<P>
The same URL works in Cursor, VS Code Copilot, Continue.dev and{' '}
<Link href="/guides/chatgpt-mcp-connector" className="text-[--color-accent] hover:underline">
ChatGPT custom connectors
</Link>{' '}
— the point of hosting a remote MCP server is that the client config is the only
per-client step left. If you hit a case this guide does not cover, the{' '}
<Link href="/docs/faq" className="text-[--color-accent] hover:underline">
docs FAQ
</Link>{' '}
collects the rarer ones.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'Composio alternative for bespoke MCP tools';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'Composio alternative for bespoke MCP tools',
tag: 'Alternative',
});
}

View File

@@ -0,0 +1,206 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
const PATH = '/guides/composio-alternative';
const TITLE = 'Composio alternative for bespoke MCP tools';
const DESCRIPTION =
'Composio gives agents 1,000+ pre-built SaaS integrations. The gap is everything not in a catalog: your internal API, your database, your workflow. Here is the generate-your-own route — and where Composio clearly wins.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1200,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="Composio and BuildMyMCPServer both put tools in front of your AI agent — but they answer opposite questions. Composio: 'which of these 1,000 integrations do you want?' Us: 'describe the one that doesn't exist yet.'"
updated="July 2026"
>
<H2>What Composio does well</H2>
<P>
Composio&apos;s pitch is breadth with managed auth: as of mid-2026 it exposes{' '}
<Strong>1,000+ third-party applications</Strong> Gmail, Slack, Notion, Salesforce,
HubSpot, GitHub, Linear, Stripe, Shopify and the rest of the mainstream SaaS universe as
agent-callable tools behind one MCP gateway, with the OAuth dance to each SaaS handled for
you. Pricing is per tool call: a free tier around 20k calls/month, then paid tiers from
$29/month. If the integration you need is in that catalog, rebuilding it yourself is
almost always the wrong use of your time. That is an honest, strong product.
</P>
<H2>Where the catalog model hits its ceiling</H2>
<P>
A catalog, by definition, contains what many companies share. It cannot contain what only
your company has:
</P>
<UL>
<li>
<Strong>Internal APIs</Strong> the ERP endpoint, the pricing service, the legacy SOAP
bridge your team wrapped in REST five years ago.
</li>
<li>
<Strong>Your database</Strong> a read-only reporting view with exactly the columns
the agent may see, and none it may not.
</li>
<li>
<Strong>Custom logic between systems</Strong> &quot;check inventory, then draft the
reorder in our format&quot; is a tool, not two catalog entries.
</li>
<li>
<Strong>Data-control requirements</Strong> some teams cannot route production
traffic and credentials through a third-party tool-execution layer at all.
</li>
</UL>
<P>
When you hit that ceiling with a catalog product, the fallback is suddenly steep: learn
the MCP SDK, write and test a server, stand up hosting and OAuth. That cliff is the gap.
</P>
<H2>The alternative: generate the bespoke tool</H2>
<P>
<Strong>BuildMyMCPServer</Strong> starts where the catalog ends. You describe the tool in
natural language endpoints, secrets, behavior. The platform generates a TypeScript MCP
server, runs static checks, builds an isolated container and deploys it behind a full
OAuth 2.1 authorization server (PKCE, Dynamic Client Registration, Resource Indicators),
with copy-paste install snippets for Claude Desktop, Cursor and ChatGPT. Secrets are
AES-256-GCM encrypted and injected only at runtime. You can export the full TypeScript
source at any time the generated server is yours, not a subscription artifact.
</P>
<H2>Side by side</H2>
<Table>
<thead>
<tr>
<th>&nbsp;</th>
<th>Composio</th>
<th>BuildMyMCPServer</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<Strong>Core object</Strong>
</td>
<td>Their catalog of pre-built integrations</td>
<td>A custom server generated from your prompt</td>
</tr>
<tr>
<td>
<Strong>Best for</Strong>
</td>
<td>Mainstream SaaS (Gmail, Slack, Salesforce)</td>
<td>Internal APIs, databases, bespoke workflows</td>
</tr>
<tr>
<td>
<Strong>Auth to end services</Strong>
</td>
<td>Managed OAuth to hundreds of SaaS a real moat</td>
<td>You supply credentials for your own APIs</td>
</tr>
<tr>
<td>
<Strong>Billing shape</Strong>
</td>
<td>Per tool call (free 20k/mo, then usage tiers)</td>
<td>Per server tier with call allowance (free: 1 server, 100k calls/mo)</td>
</tr>
<tr>
<td>
<Strong>Code ownership</Strong>
</td>
<td>No server code of yours exists</td>
<td>Full TypeScript source export, no lock-in</td>
</tr>
<tr>
<td>
<Strong>Maturity</Strong>
</td>
<td>Established, large developer base</td>
<td>Young product (launched 2026), EU-hosted</td>
</tr>
</tbody>
</Table>
<Note>
Composio details reflect its public pricing and catalog as of mid-2026; verify current
numbers on their site before committing budget.
</Note>
<H2>Pick by the question you are actually asking</H2>
<UL>
<li>
<Strong>&quot;Let my agent use Slack and Gmail&quot;</Strong> Composio. Do not
generate what a maintained catalog already does better.
</li>
<li>
<Strong>&quot;Let my agent use our internal order API, read-only&quot;</Strong> a
generated bespoke server. No catalog will ever have it.
</li>
<li>
<Strong>Both?</Strong> they compose. MCP clients speak to multiple servers; a catalog
gateway for SaaS plus one generated server for the internal surface is a normal setup,
not a compromise.
</li>
</UL>
<H2>Honest limits of the generator route</H2>
<P>
Generation is bounded by what a prompt can specify: tools with clear inputs, outputs and
API calls. A deeply stateful integration with complex pagination and exotic auth may
still be a hand-written job. And we repeat the maturity point on purpose: Composio has
scale and a large user base; we are new. What we offer against that is the source-export
exit and a{' '}
<Link href="/pricing" className="text-[--color-accent] hover:underline">
free tier
</Link>{' '}
that lets you test the claim in minutes start from a prompt or fork a{' '}
<Link href="/templates" className="text-[--color-accent] hover:underline">
template
</Link>
.
</P>
<P>
Related:{' '}
<Link
href="/guides/mcp-server-hosting-pricing"
className="text-[--color-accent] hover:underline"
>
MCP hosting pricing compared
</Link>{' '}
·{' '}
<Link
href="/guides/hosted-mcp-platforms-compared"
className="text-[--color-accent] hover:underline"
>
the four platform categories
</Link>{' '}
·{' '}
<Link href="/guides/mintmcp-alternative" className="text-[--color-accent] hover:underline">
MintMCP alternative
</Link>
.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'How to create an MCP server without writing code (2026)';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'How to create an MCP server without writing code (2026)',
tag: 'Guide',
});
}

View File

@@ -0,0 +1,222 @@
import { JsonLd } from '@/components/json-ld';
import { StaticCodeBlock } from '@/components/static-code-block';
import { articleJsonLd, breadcrumbJsonLd, faqJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, OL, P, Strong, UL } from '../article-shell';
const PATH = '/guides/create-mcp-server-without-code';
const TITLE = 'How to create an MCP server without writing code (2026)';
const DESCRIPTION =
'Turn a plain-language description into a hosted, OAuth-protected MCP server — no SDK, no Docker, no TypeScript. What works, what the limits are, and when you still need code.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
const ARTICLE_FAQ = [
{
q: 'Do I really write zero code to create an MCP server?',
a: 'Yes — you describe the tools in natural language. The platform generates a TypeScript MCP server, runs static checks against banned patterns, builds a Docker image and deploys it behind OAuth 2.1. You never touch the code unless you want to: the full source is exportable at any time.',
},
{
q: 'How long does generation take?',
a: 'Spec to image to live URL typically completes in 4590 seconds. You watch the build log stream live in the dashboard.',
},
{
q: 'Which AI clients can use a no-code MCP server?',
a: 'Anything that speaks the MCP spec over Streamable HTTP: Claude Desktop, Cursor, ChatGPT custom connectors, VS Code Copilot and Continue.dev. You get a copy-paste install snippet for each.',
},
{
q: 'What does it cost to try?',
a: 'The free tier includes one hosted server and 100,000 tool calls per month — no credit card. The full TypeScript source of every server you build is exportable, so there is no lock-in.',
},
];
const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace.
Tools: search_pages, get_page_content.
Auth: NOTION_API_KEY.`;
const SNIPPET_EXAMPLE = `{
"mcpServers": {
"notion": {
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
"auth": "oauth2"
}
}
}`;
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1500,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<JsonLd data={faqJsonLd(ARTICLE_FAQ)} />
<ArticleShell
title={TITLE}
subtitle="The Model Context Protocol lets AI assistants call your tools — but the official route to a server means an SDK, a transport, an auth layer and somewhere to host it. Here is the route that skips all four, and an honest list of where it stops."
updated="July 2026"
>
<H2>What no code actually has to cover</H2>
<P>
An MCP server that works outside your laptop is more than tool functions. To let Claude,
Cursor or ChatGPT call it from anywhere, you need four things: the{' '}
<Strong>server code</Strong> itself (tool definitions plus handlers), a{' '}
<Strong>remote transport</Strong> (Streamable HTTP the STDIO servers most tutorials
build only work locally), an <Strong>auth layer</Strong> (the MCP spec requires OAuth 2.1
for remote servers), and <Strong>hosting</Strong> that keeps the process alive. A no-code
claim that only generates the first item leaves you with three engineering problems. The
flow below covers all four.
</P>
<H2>Step 1 describe the tool in plain language</H2>
<P>
You write a prompt, not a spec. Name the tools you want, the credentials they need, and
what they should do. A working example:
</P>
<StaticCodeBlock code={PROMPT_EXAMPLE} label="prompt" />
<P>
Three lines is enough because the generator asks the model for a structured spec, not
prose: tool names, input schemas, the API calls behind them, and which environment
variables hold secrets. If the description is ambiguous, you see the interpreted spec
before anything builds and can edit it.
</P>
<H2>Step 2 generation and static checks</H2>
<P>
From the spec, the platform renders a TypeScript MCP server. Before anything runs, the
generated code goes through static checks against banned patterns no
<Strong> child processes, no filesystem escapes, no calls to unlisted hosts</Strong>. This
matters more in a no-code flow than in a hand-written one: you did not read the code, so
the platform has to.
</P>
<P>
The output is real, exportable TypeScript. If you cancel your account tomorrow, you can{' '}
<Link href="/docs/authoring" className="text-[--color-accent] hover:underline">
take the source
</Link>{' '}
and run it yourself there is no proprietary runtime inside.
</P>
<H2>Step 3 build and deploy</H2>
<P>
The server is packaged into a Docker image and deployed to its own isolated container
one container per server, so a bug in your Notion tool cannot see your Stripe tool's
credentials. Secrets you provide (like <Strong>NOTION_API_KEY</Strong>) are encrypted
with AES-256-GCM at rest and injected as environment variables only at runtime. They are
never logged and never echoed back into the dashboard.
</P>
<P>
The whole pipeline — spec, render, checks, image build, deploy — typically completes in{' '}
<Strong>4590 seconds</Strong>, streamed live to the dashboard so you can watch each
stage pass.
</P>
<H2>Step 4 — the OAuth-protected URL</H2>
<P>
The deployed server is a public Streamable HTTP endpoint, but every request is gated by
OAuth 2.1 before it reaches your container. The control plane acts as the authorization
server — PKCE, Dynamic Client Registration (RFC 7591) and Resource Indicators (RFC 8707)
— which is exactly the handshake modern MCP clients expect. You never configure any of
it;{' '}
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
the OAuth docs
</Link>{' '}
explain what happens under the hood.
</P>
<H2>Step 5 — install in your client</H2>
<P>The dashboard renders a copy-paste snippet per client. For Claude Desktop:</P>
<StaticCodeBlock code={SNIPPET_EXAMPLE} label="claude_desktop_config.json" />
<P>
On first use the client opens the OAuth consent flow in your browser; approve it once and
the tools appear. The same server works in Cursor, ChatGPT custom connectors, VS Code
Copilot and Continue.dev — see the client-specific walkthroughs for{' '}
<Link href="/guides/claude-desktop-mcp-setup" className="text-[--color-accent] hover:underline">
Claude Desktop
</Link>{' '}
and{' '}
<Link href="/guides/chatgpt-mcp-connector" className="text-[--color-accent] hover:underline">
ChatGPT
</Link>
.
</P>
<H2>What you cannot do without code — honest limits</H2>
<UL>
<li>
<Strong>Complex business logic.</Strong> Generation is good at “call this API, shape
the response”. Multi-step workflows with branching state, retries with compensation, or
heavy data transformation deserve hand-written code. Export the generated source and
extend it — that is the intended escape hatch.
</li>
<li>
<Strong>Unusual protocols.</Strong> Tools that need gRPC, raw TCP, or binary SDKs are
out of scope for prompt-generation; the generated servers speak HTTP to upstream APIs.
</li>
<li>
<Strong>Long-running jobs.</Strong> A tool call is a request/response. Anything that
takes minutes belongs in a queue you own, with the MCP tool submitting and polling.
</li>
<li>
<Strong>Compliance paperwork.</Strong> If procurement requires SOC 2 or HIPAA
certification today, a generated-and-hosted server will not check that box — no such
certifications are claimed. Self-hosting the exported source inside your own audited
infrastructure is the workaround.
</li>
</UL>
<Note>
Rule of thumb: if you can describe the tool in three sentences, generation gets you to
production. If your description needs a diagram, write code — or generate first, export,
then edit.
</Note>
<H2>Try it against a real API first</H2>
<OL>
<li>
Pick one API you use daily — Notion, GitHub, your own REST backend (
<Link href="/guides/rest-api-to-mcp-server" className="text-[--color-accent] hover:underline">
wrapping a REST API
</Link>{' '}
is the most common first server).
</li>
<li>Write the three-line prompt: tools, credentials, behavior.</li>
<li>Watch the build, paste the snippet, ask your assistant to use the tool.</li>
</OL>
<P>
The{' '}
<Link href="/pricing" className="text-[--color-accent] hover:underline">
free tier
</Link>{' '}
covers one server and 100,000 tool calls a month, which is more than enough to find out
whether the no-code route fits your case. If it does not, you have lost ten minutes and
gained a working reference implementation to export. Browse{' '}
<Link href="/templates" className="text-[--color-accent] hover:underline">
the template gallery
</Link>{' '}
if you would rather fork a working server than write a prompt.
</P>
<H2>FAQ</H2>
{ARTICLE_FAQ.map((f) => (
<div key={f.q} className="mt-5">
<h3 className="text-[15px] font-semibold tracking-tight text-[--color-fg]">{f.q}</h3>
<p className="mt-1.5 text-[14px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
</div>
))}
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'How to host a remote MCP server with OAuth (2026)';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'How to host a remote MCP server with OAuth (2026)',
tag: 'Guide',
});
}

View File

@@ -0,0 +1,123 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, P, Strong, UL } from '../article-shell';
const PATH = '/guides/host-mcp-server-with-oauth';
const TITLE = 'How to host a remote MCP server with OAuth (2026)';
const DESCRIPTION =
'What it actually takes to put a remote MCP server in production: Streamable HTTP transport, OAuth 2.1 with PKCE and Resource Indicators, and the shortcuts.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-05-31',
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="Local STDIO servers are easy. A remote MCP server that Claude, Cursor and ChatGPT can install over the internet — without leaving it open to the world — is where the real work is. Here's the whole picture."
updated="May 2026"
>
<H2>Local vs remote: why this is harder than it looks</H2>
<P>
A local MCP server talks to one client over STDIO on your machine no network, no auth.
The moment you want a server that lives at a URL and any MCP client can connect to, you
inherit a full web-service problem: a public transport, TLS, identity, authorization, and
isolation between callers. The MCP spec settled on <Strong>Streamable HTTP</Strong> as the
remote transport (it replaced the older HTTP+SSE pairing), and on{' '}
<Strong>OAuth 2.1</Strong> as the auth model. Both are non-negotiable if you want the
server installable from Claude Desktop or ChatGPT.
</P>
<H2>The OAuth 2.1 pieces you can't skip</H2>
<P>
MCP authorization is OAuth 2.1, and for remote servers it leans on a few RFCs that older
OAuth tutorials don't cover:
</P>
<UL>
<li>
<Strong>PKCE (RFC 7636)</Strong> on every authorization-code exchange mandatory in
OAuth 2.1, no exceptions for &quot;confidential&quot; clients.
</li>
<li>
<Strong>Dynamic Client Registration (RFC 7591)</Strong> clients like Claude Desktop
register themselves at runtime; you can't pre-provision a client_id for every user.
</li>
<li>
<Strong>Resource Indicators (RFC 8707)</Strong> — the token has to be bound to the
specific MCP server (the <code>resource</code>), so a token minted for one server can't
be replayed against another.
</li>
<li>
<Strong>Protected-resource metadata</Strong> your server returns a{' '}
<code>WWW-Authenticate</code> header pointing at the authorization server so clients can
discover where to get a token.
</li>
</UL>
<P>
Get any of these wrong and the symptom is the same: the client either can't complete the
handshake, or it silently fails to discover your auth server. This is the single most
common reason a &quot;working&quot; MCP server won't install from Claude.
</P>
<H2>Option A roll your own on generic infra</H2>
<P>
You can deploy a remote MCP server to <Strong>Cloudflare Workers</Strong> (the most common
production choice, edge-global), or to <Strong>Render, Fly, or Cloud Run</Strong> as a
normal container. Cloudflare even ships an OAuth provider library for Workers-based MCP
servers. This path gives you full control and is the right call if you have engineers and
want to own the runtime.
</P>
<P>
The cost is everything around the code: standing up the authorization server (or wiring a
third-party IdP correctly for the RFCs above), per-tenant secret storage, TLS, rate
limiting, and keeping the transport spec-current as MCP evolves. Budget days, not hours,
for the auth layer alone.
</P>
<H2>Option B a platform that wraps it for you</H2>
<P>
If you already have a server, tools like <Strong>MintMCP</Strong> take a local STDIO
server and expose it as a remote one with OAuth wrapping. If you{' '}
<Strong>don't have a server yet</Strong>, that's where BuildMyMCPServer fits: you describe
the tool in plain language, it generates the TypeScript MCP server, runs static checks,
builds a container, and deploys it behind a full OAuth 2.1 authorization server PKCE,
DCR and Resource Indicators included with copy-paste install snippets for each client.
</P>
<H2>A practical checklist before you ship</H2>
<UL>
<li>Transport is Streamable HTTP, served over TLS at a stable public URL.</li>
<li>Unauthenticated request returns 401 + a <code>WWW-Authenticate</code> pointing at your AS.</li>
<li>Authorization code flow enforces PKCE (S256), exact redirect-URI match, single-use codes.</li>
<li>Issued access tokens are audience-bound to the specific server (RFC 8707).</li>
<li>Per-caller secrets are encrypted at rest and injected only at runtime, never logged.</li>
<li>You've actually installed it from Claude Desktop end-to-end — not just curl'd it.</li>
</UL>
<P>
Whichever route you take, test the real install path in a real client early. See the{' '}
<Link href="/guides/hosted-mcp-platforms-compared" className="text-[--color-accent] hover:underline">
platform comparison
</Link>{' '}
for which option fits your situation.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'Hosted MCP platforms compared: Cloudflare, Smithery, Composio & generating your own';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'Hosted MCP platforms compared: Cloudflare, Smithery, Composio & more',
tag: 'Comparison',
});
}

View File

@@ -0,0 +1,117 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, P, Strong, UL } from '../article-shell';
const PATH = '/guides/hosted-mcp-platforms-compared';
const TITLE =
'Hosted MCP platforms compared: Cloudflare, Smithery, Composio & generating your own';
const DESCRIPTION =
'The MCP hosting landscape splits into four categories — registries, connector platforms, hosting infra, and generators. Here is which one fits which job.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-05-31',
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="There are 14,000+ MCP servers out there and a dozen platforms claiming to host them. They are not competing for the same job. Sort them into four buckets and the choice gets obvious."
updated="May 2026"
>
<H2>1. Registries &amp; directories</H2>
<P>
<Strong>Smithery</Strong>, <Strong>Glama</Strong> and <Strong>PulseMCP</Strong> are about{' '}
<em>discovery</em> finding and listing existing servers (Glama indexes thousands). Some
add light hosting on top, but the core value is the catalog and the traffic. Use them to
publish a server people can find, or to find one that already does what you need.
</P>
<H2>2. Connector platforms</H2>
<P>
<Strong>Composio</Strong>, <Strong>Nango</Strong>, <Strong>Klavis</Strong>,{' '}
<Strong>Zapier</Strong> and <Strong>Pipedream</Strong> expose <em>their</em> catalog of
hundreds of pre-built SaaS integrations as MCP, with managed auth. If your need is
&quot;let my agent touch Gmail / Slack / Salesforce,&quot; these are the fastest path
you're buying breadth of pre-built connectors, not building your own logic.
</P>
<H2>3. Hosting infrastructure</H2>
<P>
<Strong>Cloudflare Workers</Strong> is the default for hosting a remote MCP server in
production — edge-global, with an OAuth provider library. <Strong>Vercel</Strong>,{' '}
<Strong>Render</Strong> and <Strong>Cloud Run</Strong> host custom Node containers too.{' '}
<Strong>MintMCP</Strong> sits slightly higher up: one-click wrap of an existing STDIO
server into a remote one with auto-OAuth, and it leads on compliance (SOC 2 Type II,
GDPR/HIPAA-formatted audit logs). All of these assume <Strong>you bring the code.</Strong>
</P>
<H2>4. Generators (the gap most lists miss)</H2>
<P>
The first three categories all assume you already have a server, or that a pre-built
connector covers your case. Neither is true when you need a <em>bespoke</em> tool — a
wrapper around your own internal API, a niche workflow, a one-off integration nobody has
built. That's the generator category: describe the tool, get a custom MCP server hosted
for you. It's the youngest and least crowded slice, and it's where{' '}
<Strong>BuildMyMCPServer</Strong> plays.
</P>
<H2>So which do you pick?</H2>
<UL>
<li>
<Strong>Need a popular SaaS connector?</Strong> A connector platform (Composio / Klavis /
Zapier) don't rebuild what they maintain.
</li>
<li>
<Strong>Have a server and engineers?</Strong> Host it on Cloudflare Workers; wrap your
own OAuth or use MintMCP if you want the compliance posture done for you.
</li>
<li>
<Strong>Just browsing for something that exists?</Strong> Smithery or Glama.
</li>
<li>
<Strong>Need a custom tool and don't want to write or host a server?</Strong> A generator
describe it, ship it. Export the source later if you outgrow it.
</li>
</UL>
<H2>Where BuildMyMCPServer fits honestly</H2>
<P>
We're not trying to out-scale Cloudflare's edge or out-catalog Composio. The job we do is
the bespoke one: <Strong>prompt a hosted, OAuth-protected MCP server</Strong>, with
install snippets for Claude, Cursor and ChatGPT, an EU/US data-residency choice for teams
that care, full TypeScript source export, and a template marketplace to fork from. If your
tool is custom and your time is the constraint, that's the wedge. If you need a vetted
enterprise SOC 2 host for an existing server today, MintMCP or your own Cloudflare setup is
the more honest answer.
</P>
<P>
Next:{' '}
<Link
href="/guides/host-mcp-server-with-oauth"
className="text-[--color-accent] hover:underline"
>
what hosting a remote MCP server with OAuth actually involves
</Link>
.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'OAuth 2.1 for MCP servers: PKCE, DCR and RFC 8707 in plain English';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'OAuth 2.1 for MCP servers, in plain English',
tag: 'Explainer',
});
}

View File

@@ -0,0 +1,180 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, OL, P, Strong, UL } from '../article-shell';
const PATH = '/guides/mcp-oauth-plain-english';
const TITLE = 'OAuth 2.1 for MCP servers: PKCE, DCR and RFC 8707 in plain English';
const DESCRIPTION =
'The three RFCs behind MCP authorization — PKCE, Dynamic Client Registration and Resource Indicators — explained without jargon: what each one does, the full flow step by step, and what breaks when you skip one.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1400,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="MCP authorization is OAuth 2.1 plus three RFCs that most OAuth tutorials never mention. This is the concept explainer — what each piece does and why the spec requires it. For the deployment how-to, see the hosting guide."
updated="July 2026"
>
<H2>Why MCP needs more than classic OAuth</H2>
<P>
Classic OAuth assumes you know your clients in advance: you register an app in a
dashboard, get a <code>client_id</code> and <code>client_secret</code>, and ship them
inside your application. MCP breaks both assumptions. The client is someone's Claude
Desktop or Cursor install — you will never pre-register it — and it runs on a desktop
where a baked-in secret is not a secret. OAuth 2.1 plus three extension RFCs is how the
MCP spec resolves this.
</P>
<H2>The two roles: Authorization Server and Resource Server</H2>
<P>
Every OAuth setup splits into two jobs. The <Strong>Authorization Server (AS)</Strong>{' '}
authenticates users and issues tokens. The <Strong>Resource Server (RS)</Strong> — your
MCP server — accepts requests only when they carry a valid token. They can be the same
deployment or separate services; the protocol only cares that the RS can verify what the
AS signs, typically via a published JWKS (the AS's public keys).
</P>
<H2>PKCE (RFC 7636): proof that the token requester started the flow</H2>
<P>
The authorization-code flow hands the client a one-time code via a browser redirect, and
the client exchanges that code for a token. The classic attack is stealing the code
in-flight and exchanging it yourself. <Strong>PKCE</Strong> closes this: the client
invents a random secret (the verifier), sends only its hash (the challenge) when the flow
starts, and must present the original verifier at exchange time. A thief who intercepted
the code never saw the verifier, so the code is useless to them.
</P>
<P>
In OAuth 2.1, PKCE is <Strong>mandatory for every client</Strong> the old exemption for
"confidential" server-side clients is gone. If your AS treats PKCE as optional, it is not
OAuth 2.1.
</P>
<H2>Dynamic Client Registration (RFC 7591): clients register themselves</H2>
<P>
When a user adds your MCP server to Claude Desktop, the client calls your AS's
registration endpoint at runtime — "here is my name and redirect URI" — and receives a
fresh <code>client_id</code> on the spot. No dashboard, no support ticket, no shared
credentials between users. <Strong>DCR</Strong> is what makes "paste a URL, connect,
done" possible: without it, every user of every MCP client would need you to manually
provision app credentials.
</P>
<H2>Resource Indicators (RFC 8707): tokens bound to one server</H2>
<P>
A user might connect their client to ten different MCP servers. Without{' '}
<Strong>Resource Indicators</Strong>, a token minted for server A could be replayed
against server B — any server you talk to could impersonate you elsewhere. RFC 8707 has
the client name the exact server (the <code>resource</code>) when requesting the token,
and the AS bakes that audience into the token. Your MCP server then rejects any token
whose audience is not itself. One token, one server, no cross-server replay.
</P>
<H2>The full flow, step by step</H2>
<OL>
<li>
The client sends an unauthenticated request to your MCP server and gets a{' '}
<Strong>401</Strong> with a <code>WWW-Authenticate</code> header pointing at the
protected-resource metadata — this is how the client discovers your AS.
</li>
<li>The client registers itself with the AS via DCR and receives a client_id.</li>
<li>
The client generates a PKCE verifier + challenge and opens the browser to the AS's
authorize endpoint, naming your server as the <code>resource</code>.
</li>
<li>The user signs in and approves; the AS redirects back with a one-time code.</li>
<li>
The client exchanges code + PKCE verifier for an access token that is
audience-bound to your server.
</li>
<li>
Every subsequent MCP request carries the token; your server verifies signature, expiry
and audience on each call.
</li>
</OL>
<H2>What breaks when you skip a piece</H2>
<UL>
<li>
<Strong>No protected-resource metadata / wrong 401</Strong> clients can't discover
your AS. The symptom: the server "works" with curl but silently fails to install from
Claude Desktop. This is the most common failure in the wild.
</li>
<li>
<Strong>No DCR</Strong> — the connect flow dead-ends at "unknown client" for anyone but
you.
</li>
<li>
<Strong>No PKCE (or PKCE accepted but not enforced)</Strong> — the flow appears to work
but intercepted authorization codes become exchangeable. Invisible until exploited.
</li>
<li>
<Strong>No audience check (RFC 8707)</Strong> — tokens for other servers are accepted
by yours, and yours are accepted elsewhere. Also invisible until exploited.
</li>
</UL>
<Note>
The failure modes split into two families: discovery mistakes are loud (nothing
connects), security mistakes are silent (everything connects, including attackers). Test
for both — an end-to-end install from a real client proves discovery, but only negative
tests (expired token, wrong audience, missing PKCE) prove the security half.
</Note>
<H2>Common implementation mistakes</H2>
<UL>
<li>Substring-matching redirect URIs instead of exact-match comparison.</li>
<li>Allowing authorization codes to be exchanged more than once.</li>
<li>Accepting <code>plain</code> PKCE instead of requiring <code>S256</code>.</li>
<li>
Verifying token signature and expiry but forgetting the audience claim — RFC 8707 only
protects you if the RS actually checks it.
</li>
<li>
Long-lived access tokens as a substitute for refresh tokens — shorter access-token
lifetime plus refresh is the OAuth 2.1 posture.
</li>
</UL>
<H2>Build it or get it built-in</H2>
<P>
None of this is exotic, but it is a genuine authorization-server implementation — days of
work plus ongoing spec-tracking, and mistakes are security bugs rather than build
failures. If you want to own it, the{' '}
<Link
href="/guides/host-mcp-server-with-oauth"
className="text-[--color-accent] hover:underline"
>
hosting guide
</Link>{' '}
walks through the deployment options. If you'd rather not: every server generated on
BuildMyMCPServer ships behind an OAuth 2.1 AS with PKCE, DCR and Resource Indicators
already wired the flow above is what our{' '}
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
OAuth docs
</Link>{' '}
implement, and the <Link href="/pricing" className="text-[--color-accent] hover:underline">free tier</Link>{' '}
includes it.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'MCP server hosting: pricing & options compared (2026)';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'MCP server hosting: pricing & options compared (2026)',
tag: 'Comparison',
});
}

View File

@@ -0,0 +1,251 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, faqJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
const PATH = '/guides/mcp-server-hosting-pricing';
const TITLE = 'MCP server hosting: pricing & options compared (2026)';
const DESCRIPTION =
'What hosting a remote MCP server actually costs in 2026 — Cloudflare Workers, Smithery, Composio, MintMCP and prompt-to-server generation, compared on price, effort and lock-in.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
// Pricing FAQ rendered below AND emitted as FAQPage JSON-LD — single source.
const PRICING_FAQ = [
{
q: 'What is the cheapest way to host an MCP server?',
a: 'If you can write the code yourself: Cloudflare Workers — the free tier covers 100k requests/day, which is more than most personal MCP servers ever see. If you cannot or do not want to write the code, generator platforms start free (BuildMyMCPServer Hobby: 1 server, 100k tool calls/month at €0).',
},
{
q: 'Do I pay per tool call or per server?',
a: 'Both models exist. Composio bills per tool call across its catalog. Cloudflare bills per request/CPU-time. BuildMyMCPServer prices tiers by server count plus a monthly tool-call allowance. Read the overage terms — per-call platforms get expensive at agent-scale traffic.',
},
{
q: 'Is there a free way to get an OAuth-protected remote MCP server?',
a: 'Yes, two honest routes: build it yourself on Cloudflare Workers with their OAuth provider library (free tier, your time is the cost), or generate one on a free tier of a hosting generator (BuildMyMCPServer Hobby is €0 for one server with OAuth 2.1 included).',
},
{
q: 'What should enterprises check before picking an MCP host?',
a: 'Compliance posture (SOC 2 / HIPAA — MintMCP leads here as of mid-2026), data residency, audit logging, SSO, and an exit path. Ask every vendor: can I export the server code and leave?',
},
];
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1350,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<JsonLd data={faqJsonLd(PRICING_FAQ)} />
<ArticleShell
title={TITLE}
subtitle="Five ways to put an MCP server on the internet, five very different bills. This is the pricing landscape as of July 2026 — including where each option quietly gets expensive, and where the free tiers are genuinely free."
updated="July 2026"
>
<H2>The five options at a glance</H2>
<P>
&quot;MCP server hosting&quot; covers products that do very different jobs: raw compute you
deploy to, directories that also host, connector catalogs that bill per call, wrappers
that productionize a server you already wrote, and generators that write and host the
server for you. Comparing them on price only makes sense once you know which job you are
buying.
</P>
<Table>
<thead>
<tr>
<th>Platform</th>
<th>What you get</th>
<th>Entry price</th>
<th>Paid from</th>
<th>You bring</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<Strong>Cloudflare Workers</Strong>
</td>
<td>Edge compute + OAuth provider library; you build and deploy</td>
<td>Free 100k requests/day</td>
<td>$5/mo</td>
<td>All the code, OAuth wiring, upkeep</td>
</tr>
<tr>
<td>
<Strong>Smithery</Strong>
</td>
<td>Registry of 6,000+ servers, optional hosting of listed servers</td>
<td>Free to browse/publish</td>
<td>Paid tiers for hosting/usage</td>
<td>An existing server (or pick one from the catalog)</td>
</tr>
<tr>
<td>
<Strong>Composio</Strong>
</td>
<td>1,000+ pre-built SaaS integrations exposed as MCP, managed auth</td>
<td>Free 20k tool calls/mo</td>
<td>$29/mo (200k calls)</td>
<td>Nothing but only their catalog, per-call billing</td>
</tr>
<tr>
<td>
<Strong>MintMCP</Strong>
</td>
<td>Wraps your existing STDIO server into a remote OAuth deployment; SOC 2 Type II</td>
<td>Custom / enterprise pricing</td>
<td>Contact sales</td>
<td>A working server; budget for enterprise pricing</td>
</tr>
<tr>
<td>
<Strong>BuildMyMCPServer</Strong>
</td>
<td>Generates the TypeScript server from a prompt, hosts it behind OAuth 2.1</td>
<td>Free 1 server, 100k tool calls/mo</td>
<td>49/mo (5 servers, 1M calls)</td>
<td>A description of the tool. That&apos;s it.</td>
</tr>
</tbody>
</Table>
<Note>
Competitor prices are as of mid-2026 and change often treat the entry-tier shapes as the
durable signal, not the exact numbers. Sources: public pricing pages of each vendor.
</Note>
<H2>Cloudflare Workers: cheapest if your time is free</H2>
<P>
The free tier (100k requests/day, KV, D1, Durable Objects allowances) comfortably runs a
personal or small-team MCP server at <Strong>0 forever</Strong>, and the $5/mo paid plan
covers almost anything below serious production traffic. The catch is that the price tag
measures compute, not effort: you write the server, wire the OAuth provider library,
handle token refresh edge cases, and own every upgrade when the MCP spec moves. For a
team with engineers who enjoy that work, it is the best deal on this page.
</P>
<H2>Smithery: pay for distribution, not development</H2>
<P>
Smithery is primarily a registry thousands of community servers, CLI install, and
hosting for servers published there, with OAuth handled for hosted listings. Browsing and
publishing are free; hosted execution moves to paid tiers with usage. It answers
&quot;where do I find or distribute a server&quot; more than &quot;who builds mine&quot;
if the tool you need already exists in the catalog, this can be the fastest free path of
all.
</P>
<H2>Composio: per-call pricing for a pre-built catalog</H2>
<P>
Composio&apos;s model is different: you are not hosting <em>your</em> server, you are
calling <em>their</em> catalog of 1,000+ integrations with managed auth. Free covers 20k
tool calls/month; $29/mo buys 200k, $229/mo buys 2M, with per-call overage beyond. For
mainstream SaaS (Gmail, Slack, Salesforce) it is excellent value. The pricing risk is
agent-scale traffic an autonomous agent hammering tools burns per-call budgets fast
and the structural limit is the catalog: your internal API is not in it. More in our{' '}
<Link href="/guides/composio-alternative" className="text-[--color-accent] hover:underline">
Composio alternative guide
</Link>
.
</P>
<H2>MintMCP: compliance has a price tag</H2>
<P>
MintMCP wraps an existing STDIO server into a production remote deployment OAuth
brokering, SSO, SCIM, audit trails, SOC 2 Type II, HIPAA-aligned controls. Pricing is
custom/enterprise as of mid-2026. If your buyer is a compliance team, this is the honest
shortlist leader; nothing else in this table carries that certification set. If you are a
solo developer, it is not aimed at you.
</P>
<H2>BuildMyMCPServer: pay for the whole job, skip the build</H2>
<P>
Our own slot in this table, stated plainly: you describe the tool in natural language, the
platform generates the TypeScript server, runs static checks, builds a container and
deploys it behind a full OAuth 2.1 authorization server (PKCE, Dynamic Client
Registration, Resource Indicators). <Strong>Hobby is 0</Strong> one server, 100k tool
calls/month. <Strong>Pro is 49/mo</Strong> for 5 servers and 1M calls;{' '}
<Strong>Team 199/mo</Strong> adds audit logging at 25 servers/10M calls; Enterprise is
custom. Full source export on every tier, so the exit path is real. What we do not have:
SOC 2 (we are a young product), a giant connector catalog, or edge PoPs on six continents.
See{' '}
<Link href="/pricing" className="text-[--color-accent] hover:underline">
full pricing
</Link>{' '}
and the{' '}
<Link href="/templates" className="text-[--color-accent] hover:underline">
template gallery
</Link>
.
</P>
<H2>Choosing by scenario</H2>
<UL>
<li>
<Strong>Solo dev, comfortable writing TypeScript, hobby traffic:</Strong> Cloudflare
Workers free tier. Unbeatable if you enjoy the build.
</li>
<li>
<Strong>Need Gmail/Slack/Notion-class connectors this afternoon:</Strong> Composio free
tier, watch the per-call meter as agents scale.
</li>
<li>
<Strong>Have a server, need enterprise compliance sign-off:</Strong> MintMCP, budget for
enterprise pricing.
</li>
<li>
<Strong>Want to publish or discover community servers:</Strong> Smithery.
</li>
<li>
<Strong>Need a bespoke tool hosted with OAuth, no code, this hour:</Strong> a generator
that is the job{' '}
<Link href="/" className="text-[--color-accent] hover:underline">
BuildMyMCPServer
</Link>{' '}
exists for.
</li>
</UL>
<H2>Pricing FAQ</H2>
{PRICING_FAQ.map((f) => (
<div key={f.q} className="mt-5">
<h3 className="text-[15px] font-semibold tracking-tight text-[--color-fg]">{f.q}</h3>
<p className="mt-1.5 text-[14px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
</div>
))}
<P>
Related:{' '}
<Link
href="/guides/hosted-mcp-platforms-compared"
className="text-[--color-accent] hover:underline"
>
the four categories of MCP platform, explained
</Link>{' '}
·{' '}
<Link
href="/guides/host-mcp-server-with-oauth"
className="text-[--color-accent] hover:underline"
>
what hosting with OAuth actually involves
</Link>
.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'MCP Server ohne Code erstellen und hosten (2026)';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'MCP Server ohne Code erstellen und hosten',
tag: 'Anleitung',
});
}

View File

@@ -0,0 +1,176 @@
import { JsonLd } from '@/components/json-ld';
import { StaticCodeBlock } from '@/components/static-code-block';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, OL, P, Strong, UL } from '../article-shell';
const PATH = '/guides/mcp-server-ohne-code-erstellen';
const TITLE = 'MCP Server ohne Code erstellen und hosten (2026)';
const DESCRIPTION =
'Wie Sie ohne Programmierkenntnisse einen eigenen MCP Server erstellen und hosten: Tool auf Deutsch oder Englisch beschreiben, generierten TypeScript-Server mit OAuth 2.1 deployen, Install-Snippet in Claude, Cursor oder ChatGPT einfügen.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1250,
inLanguage: 'de',
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="MCP verbindet KI-Assistenten wie Claude, Cursor und ChatGPT mit Ihren eigenen Tools und Daten. Dieser Guide zeigt den Weg vom Satz in natürlicher Sprache zum gehosteten, OAuth-geschützten Server — ohne eine Zeile Code."
updated="Juli 2026"
>
<H2>Was ist MCP in zwei Absätzen</H2>
<P>
Das <Strong>Model Context Protocol (MCP)</Strong> ist ein offener Standard von Anthropic,
der KI-Assistenten mit externen Tools, Datenbanken und APIs verbindet. Statt für jeden
Assistenten eine eigene Integration zu bauen, stellen Sie einen MCP Server bereit und
jeder kompatible Client (Claude Desktop, Cursor, ChatGPT, VS Code Copilot, Continue.dev)
kann ihn nutzen.
</P>
<P>
Ein MCP Server stellt <Strong>Tools</Strong> bereit: klar definierte Funktionen wie
durchsuche unser Notion-Workspace" oder „lies Bestellungen aus der Datenbank". Die KI
entscheidet im Gespräch, wann sie ein Tool aufruft; der Server führt aus und liefert das
Ergebnis zurück in den Chat.
</P>
<H2>Warum ohne Code" bisher nicht selbstverständlich war</H2>
<P>
Einen lokalen Test-Server bekommt man mit dem MCP-SDK schnell hin — wenn man
programmieren kann. Ein <Strong>produktiver, erreichbarer</Strong> Server ist ein anderes
Kaliber: Er braucht den Streamable-HTTP-Transport, TLS, eine OAuth-2.1-Autorisierung
(damit nicht das ganze Internet Ihre Tools aufrufen kann), sichere Verwahrung Ihrer
API-Schlüssel und Hosting, das nicht nach zwei Wochen umfällt. Genau diese Schicht können
Sie heute generieren lassen, statt sie zu bauen.
</P>
<H2>Der Weg: von der Beschreibung zum laufenden Server</H2>
<OL>
<li>
<Strong>Tool beschreiben — auf Deutsch oder Englisch.</Strong> Ein präziser Satz
genügt: welche Aufgabe, welche Tools, welche Zugangsdaten. Beispiel unten.
</li>
<li>
<Strong>Spezifikation prüfen.</Strong> Die Plattform (in unserem Fall
BuildMyMCPServer) analysiert die Beschreibung und schlägt die Tool-Definitionen vor —
Namen, Parameter, benötigte Secrets. Hier korrigieren Sie, bevor etwas gebaut wird.
</li>
<li>
<Strong>Generieren und deployen lassen.</Strong> Daraus entsteht ein
TypeScript-MCP-Server, der statisch geprüft, in ein Docker-Image gebaut und auf einer
eigenen Subdomain deployt wird — hinter einem OAuth-2.1-Authorization-Server mit PKCE.
Typische Dauer: 4590 Sekunden.
</li>
<li>
<Strong>Secrets eintragen.</Strong> API-Schlüssel (z.&nbsp;B. Ihr Notion-Token) werden
AES-256-GCM-verschlüsselt gespeichert und nur zur Laufzeit als Umgebungsvariablen in
den Container injiziert — sie landen nie im Code und nie in Logs.
</li>
<li>
<Strong>Install-Snippet in den Client kopieren.</Strong> Für Claude Desktop, Cursor
und ChatGPT gibt es fertige Snippets; beim ersten Zugriff läuft der OAuth-Flow im
Browser.
</li>
</OL>
<H2>Ein konkretes Beispiel</H2>
<P>So sieht eine ausreichende Beschreibung aus — Deutsch funktioniert:</P>
<StaticCodeBlock
label="Prompt"
code={`Erstelle einen MCP Server, der unser Notion-Workspace durchsucht.
Tools: search_pages, get_page_content.
Auth: NOTION_API_KEY.`}
/>
<P>Und so das Ergebnis im Client — ein Eintrag in der Konfiguration von Claude Desktop:</P>
<StaticCodeBlock
label="claude_desktop_config.json"
code={`{
"mcpServers": {
"notion": {
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
"auth": "oauth2"
}
}
}`}
/>
<H2>Was Sie trotzdem verstehen sollten</H2>
<P>
„Ohne Code" heisst nicht „ohne Verantwortung". Drei Dinge bleiben Ihre Entscheidung:
</P>
<UL>
<li>
<Strong>Rechteumfang der Schlüssel.</Strong> Geben Sie dem Server nur die Rechte, die
die Tools brauchen — ein Read-only-Schlüssel, wo die Quelle das anbietet. Die KI wird
jedes Tool nutzen, das existiert.
</li>
<li>
<Strong>Welche Tools existieren.</Strong> Lesende Tools zuerst; schreibende Tools nur,
wenn der Anwendungsfall sie wirklich verlangt. Mehr dazu in unserer{' '}
<Link
href="/guides/mcp-server-security-checklist"
className="text-[--color-accent] hover:underline"
>
Security-Checkliste
</Link>{' '}
(Englisch).
</li>
<li>
<Strong>Wem Sie den Betrieb anvertrauen.</Strong> Prüfen Sie die Sicherheitsangaben
des Anbieters auf Konkretes: Verschlüsselung, Container-Isolation, Quellcode-Export.
Bei uns steht das auf der <Link href="/security" className="text-[--color-accent] hover:underline">Security-Seite</Link>{' '}
— und jeden generierten Server können Sie als TypeScript-Quellcode exportieren und
selbst hosten. Kein Lock-in.
</li>
</UL>
<H2>Kosten</H2>
<P>
Der Einstieg ist kostenlos: Der Hobby-Plan umfasst einen Server mit 100&nbsp;000
Tool-Aufrufen pro Monat auf einer BuildMyMCP-Subdomain. Bezahlpläne skalieren über
Server-Anzahl und Aufruf-Volumen — Details auf der{' '}
<Link href="/pricing" className="text-[--color-accent] hover:underline">
Preisseite
</Link>
.
</P>
<Note>
Für die technischen Hintergründe auf Englisch:{' '}
<Link
href="/guides/mcp-transports-explained"
className="text-[--color-accent] hover:underline"
>
MCP-Transporte erklärt
</Link>{' '}
(warum Streamable HTTP der Standard ist) und{' '}
<Link
href="/guides/mcp-oauth-plain-english"
className="text-[--color-accent] hover:underline"
>
OAuth 2.1 für MCP Server
</Link>{' '}
(was PKCE, DCR und RFC 8707 leisten).
</Note>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'MCP server security checklist: secrets, isolation, auth';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'MCP server security checklist: secrets, isolation, auth',
tag: 'Checklist',
});
}

View File

@@ -0,0 +1,192 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, P, Strong, UL } from '../article-shell';
const PATH = '/guides/mcp-server-security-checklist';
const TITLE = 'MCP server security checklist: secrets, isolation, auth';
const DESCRIPTION =
'A practical security checklist for production MCP servers: transport auth, secret storage and injection, container isolation, least-privilege tool design, safe logging, and the prompt-injection surface of tool descriptions.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1300,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="An MCP server is an API that an AI calls with credentials you gave it. That combination — machine-driven calls, real secrets, natural-language control — has its own failure modes. Here is the checklist we hold our own platform to."
updated="July 2026"
>
<H2>1. Transport and authentication</H2>
<UL>
<li>
<Strong>TLS everywhere.</Strong> A remote MCP server speaks Streamable HTTP over HTTPS
at a stable URL no plaintext fallback, no self-signed shortcuts in production.
</li>
<li>
<Strong>Every request authenticated before it reaches tool code.</Strong> OAuth 2.1
with PKCE, Dynamic Client Registration and audience-bound tokens (RFC 8707) is the MCP
standard the details are in{' '}
<Link
href="/guides/mcp-oauth-plain-english"
className="text-[--color-accent] hover:underline"
>
our plain-English OAuth explainer
</Link>
. The important checklist item: an unauthenticated request must be rejected by the
gateway, not by convention inside your tool logic.
</li>
<li>
<Strong>Verify audience, not just signature.</Strong> A token minted for someone else's
server must not work on yours. Signature + expiry + audience — all three, every call.
</li>
</UL>
<H2>2. Secret storage and injection</H2>
<UL>
<li>
<Strong>Encrypted at rest.</Strong> API keys your server needs (Notion tokens, database
DSNs, Stripe keys) belong in a store that is encrypted with a real scheme —
AES-256-GCM, not base64, not "it's an internal database".
</li>
<li>
<Strong>Injected at runtime, never baked in.</Strong> Secrets should enter the process
as environment variables at container start — never committed into the generated code,
the image, or the template you share.
</li>
<li>
<Strong>Never echoed back.</Strong> No tool response, error message, build log or
debug output should ever contain a secret value. Test this deliberately: ask the AI
client to print its configuration and confirm the secret does not appear.
</li>
<li>
<Strong>Scoped upstream credentials.</Strong> If the upstream API offers read-only or
resource-scoped keys, use them. The MCP server can only leak the power you gave it.
</li>
</UL>
<H2>3. Isolation between servers</H2>
<P>
If you run more than one MCP server — or host servers for more than one user — the
blast radius question dominates: what does a compromised or simply buggy server reach?
</P>
<UL>
<li>
<Strong>One server, one container.</Strong> Process-level separation is not enough
when different trust domains share a host. Each server should run in its own container
with its own secrets, so a bug in one cannot read another's environment.
</li>
<li>
<Strong>No host mounts, no docker socket.</Strong> A tool-serving container has no
business seeing the host filesystem or the container runtime.
</li>
<li>
<Strong>Resource limits.</Strong> Memory and CPU caps per container turn a runaway
loop into a restart instead of a host outage.
</li>
</UL>
<H2>4. Least-privilege tool design</H2>
<P>
The cheapest security control in MCP is deciding what tools exist at all. The AI will
eventually call every tool you expose, with every argument shape it can think of.
</P>
<UL>
<li>
<Strong>Read-only by default.</Strong> Ship <code>search</code> and <code>get</code>{' '}
tools first; add write tools only when the use case demands them, and separately.
</li>
<li>
<Strong>Narrow arguments.</Strong> A <code>query_orders(customer_id)</code> tool is
auditable; a <code>run_sql(sql)</code> tool is an incident report with a delay timer.
</li>
<li>
<Strong>Validate inputs inside the tool.</Strong> Tool schemas are hints to the model,
not enforcement. Your handler must validate as if the input came from the public
internet — because via the model, it did.
</li>
</UL>
<H2>5. Logging without leaking</H2>
<UL>
<li>
Log <Strong>that</Strong> a tool was called, its latency and result status — the
metrics you need for operations.
</li>
<li>
Be deliberate about logging <Strong>arguments and results</Strong>: they routinely
contain customer data and occasionally contain secrets a model pasted into a query.
Redact or hash by default.
</li>
<li>
Keep an <Strong>audit trail</Strong> of who connected which client and when — OAuth
gives you the identity for free; keep it attached to the call records.
</li>
</UL>
<H2>6. The prompt-injection surface</H2>
<P>
Two MCP-specific angles most checklists miss. First:{' '}
<Strong>tool descriptions are instructions to the model</Strong>. If you install a
third-party MCP server, its tool descriptions enter your AI's context — a malicious
description can tell the model to exfiltrate data through another tool's arguments.
Install servers the way you install browser extensions: from sources you trust, reading
what they expose.
</P>
<P>
Second: <Strong>tool results are untrusted input to the model.</Strong> If your tool
returns content from the outside world (web pages, tickets, emails), that content can
contain instructions the model may follow. You cannot fully solve this server-side, but
you can avoid amplifying it: return structured data instead of raw HTML where possible,
and never grant one server both broad read and broad write power — that pairing is what
turns an injected instruction into an actual exfiltration.
</P>
<H2>What a checklist can't fix</H2>
<Note>
Honesty section: no checklist makes an over-privileged design safe. If a server holds an
admin API key and exposes a write-anything tool, perfect transport security just means
the mistake is encrypted in flight. Prompt injection remains an open research problem —
the mitigations above reduce blast radius, they do not eliminate the class. And a hosted
platform (ours included) means trusting the platform's own isolation and encryption;
read the vendor's{' '}
<Link href="/security" className="text-[--color-accent] hover:underline">
security page
</Link>{' '}
and hold them to specifics, not adjectives.
</Note>
<H2>How BuildMyMCPServer maps to this list</H2>
<P>
For transparency about our own posture: generated servers run one-per-container behind an
OAuth 2.1 authorization server; customer secrets are AES-256-GCM encrypted at rest and
injected as environment variables at runtime, never logged or echoed back; and every
server's TypeScript source can be exported for review. Templates carry the spec and code,
never the author's credentials — you add your own on{' '}
<Link href="/templates" className="text-[--color-accent] hover:underline">
fork
</Link>
.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'MCP transports explained: stdio vs SSE vs Streamable HTTP';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'MCP transports explained: stdio vs SSE vs Streamable HTTP',
tag: 'Explainer',
});
}

View File

@@ -0,0 +1,207 @@
import { JsonLd } from '@/components/json-ld';
import { StaticCodeBlock } from '@/components/static-code-block';
import { articleJsonLd, breadcrumbJsonLd, faqJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
const PATH = '/guides/mcp-transports-explained';
const TITLE = 'MCP transports explained: stdio vs SSE vs Streamable HTTP';
const DESCRIPTION =
'What each MCP transport actually is, why the spec deprecated HTTP+SSE in favor of Streamable HTTP, and which transport to pick for local tools, remote servers and serverless deployments.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
const TRANSPORT_FAQ = [
{
q: 'Is SSE deprecated in MCP?',
a: 'Yes. The HTTP+SSE transport from protocol version 2024-11-05 was replaced by Streamable HTTP in spec revision 2025-03-26. Servers may keep SSE endpoints for backward compatibility, but client support is degrading and platforms have been removing it through 2026.',
},
{
q: 'What is the difference between Streamable HTTP and SSE in MCP?',
a: 'The old HTTP+SSE transport needed two endpoints — a long-lived SSE stream for server-to-client messages and a separate POST endpoint for client-to-server messages. Streamable HTTP collapses this into one MCP endpoint that accepts HTTP POST and can optionally upgrade a response to an SSE stream when the server needs to push multiple messages.',
},
{
q: 'When should I use stdio instead of HTTP?',
a: 'Use stdio when the server runs on the same machine as the client — local dev tools, filesystem access, anything personal. The client spawns the server as a subprocess; there is no network surface and no auth to build. The moment more than one person or machine needs the server, you need Streamable HTTP.',
},
{
q: 'Does Streamable HTTP require SSE?',
a: 'No. SSE is optional within Streamable HTTP. A server can answer every request with a plain JSON response and never open a stream. Streams are only needed when the server wants to send progress notifications or multiple messages for one request.',
},
];
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1350,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<JsonLd data={faqJsonLd(TRANSPORT_FAQ)} />
<ArticleShell
title={TITLE}
subtitle="MCP has shipped three transports in under two years. Two are current, one is on its way out. Here is what each one actually does, why the spec moved, and how to choose."
updated="July 2026"
>
<H2>The three transports at a glance</H2>
<Table>
<thead>
<tr>
<th>Transport</th>
<th>Status</th>
<th>Endpoints</th>
<th>Best for</th>
</tr>
</thead>
<tbody>
<tr>
<td>stdio</td>
<td>Current</td>
<td>None subprocess pipes</td>
<td>Local, single-user tools</td>
</tr>
<tr>
<td>HTTP+SSE</td>
<td>Deprecated (2025-03-26)</td>
<td>Two: SSE stream + POST</td>
<td>Legacy remote servers only</td>
</tr>
<tr>
<td>Streamable HTTP</td>
<td>Current standard for remote</td>
<td>One MCP endpoint (POST/GET)</td>
<td>Every remote deployment</td>
</tr>
</tbody>
</Table>
<H2>stdio: the local transport</H2>
<P>
With <Strong>stdio</Strong>, the MCP client launches your server as a subprocess and
exchanges JSON-RPC messages over stdin and stdout. There is no port, no TLS, no
authentication the security boundary is your operating system's process model. That is
exactly right for tools that live on your own machine: filesystem helpers, local database
access, developer utilities.
</P>
<P>
The limitation is structural. A stdio server is bound to one machine and one client
process. You cannot share it with a teammate, install it on a phone, or point ChatGPT's
web app at it. It also means every user has to install a runtime (Node, Python, Docker)
and keep the server updated themselves.
</P>
<H2>HTTP+SSE: the deprecated remote transport</H2>
<P>
The first remote transport (protocol version 2024-11-05) paired two endpoints: the client
opened a long-lived <Strong>Server-Sent Events</Strong> stream to receive messages, and
sent its own messages to a separate HTTP POST endpoint the server advertised over that
stream.
</P>
<P>This design turned out to be hostile to real infrastructure:</P>
<UL>
<li>
<Strong>Load balancers</Strong> had to pin the SSE stream and the POST endpoint to the
same backend instance, defeating horizontal scaling.
</li>
<li>
<Strong>Serverless platforms</Strong> bill and time out on connection duration; a
permanently open SSE stream is the pathological case.
</li>
<li>
<Strong>Proxies and firewalls</Strong> routinely buffer or kill long-lived streams,
producing connections that look healthy but deliver nothing.
</li>
</UL>
<P>
Spec revision <Strong>2025-03-26</Strong> replaced HTTP+SSE with Streamable HTTP. The old
transport still works where both sides keep supporting it, but the direction is one-way:
platforms have been announcing removal dates through 2026 Atlassian's Rovo MCP server,
for example, announced an HTTP+SSE cutoff of June 30, 2026. New servers should not ship
it.
</P>
<H2>Streamable HTTP: the current standard</H2>
<P>
<Strong>Streamable HTTP</Strong> collapses everything onto a single MCP endpoint. The
client sends JSON-RPC messages as ordinary HTTP POST requests. For a simple request, the
server answers with a plain JSON response — request in, response out, connection closed.
When the server needs to push several messages for one request (progress updates,
notifications), it can upgrade that specific response to an SSE stream. Streaming becomes
an option per response, not a mandatory architecture.
</P>
<UL>
<li>Stateless requests by default — load balancers and serverless platforms just work.</li>
<li>One URL to configure, secure and monitor instead of two coupled endpoints.</li>
<li>
Sessions are explicit (an <code>Mcp-Session-Id</code> header) instead of implied by a
held-open socket, so a server can resume or reject them deliberately.
</li>
<li>Standard HTTP auth applies — which is what makes OAuth 2.1 integration clean.</li>
</UL>
<P>A remote server entry in a client config is now just a URL:</P>
<StaticCodeBlock
label="claude_desktop_config.json"
code={`{
"mcpServers": {
"my-tool": {
"url": "https://my-tool.mcp.buildmymcpserver.com/mcp",
"auth": "oauth2"
}
}
}`}
/>
<H2>Choosing a transport</H2>
<UL>
<li>
<Strong>Only you, on your machine</Strong> → stdio. Zero infrastructure, strongest
isolation.
</li>
<li>
<Strong>Anyone else, any other machine, any hosted client</Strong> → Streamable HTTP
over TLS, with OAuth 2.1 in front of it. This is the only current answer for servers
that Claude Desktop, Cursor and ChatGPT install over the internet.
</li>
<li>
<Strong>Existing HTTP+SSE server</Strong> → migrate. The usual path is to serve the new
single MCP endpoint alongside the legacy pair during a transition window, then drop the
legacy endpoints once your clients are confirmed on the new transport.
</li>
</UL>
<Note>
Transport is only half of a production remote server — the other half is authorization.
OAuth 2.1 with PKCE, Dynamic Client Registration and Resource Indicators is what makes a
Streamable HTTP server actually installable from real clients. That's covered in{' '}
<Link
href="/guides/host-mcp-server-with-oauth"
className="text-[--color-accent] hover:underline"
>
how to host a remote MCP server with OAuth
</Link>
.
</Note>
<H2>FAQ</H2>
{TRANSPORT_FAQ.map((f) => (
<div key={f.q} className="mt-5">
<h3 className="text-[15px] font-semibold tracking-tight text-[--color-fg]">{f.q}</h3>
<p className="mt-1.5 text-[14px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
</div>
))}
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'MintMCP alternative: generate and host a custom MCP server';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'MintMCP alternative: generate and host a custom MCP server',
tag: 'Alternative',
});
}

View File

@@ -0,0 +1,103 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, P, Strong, UL } from '../article-shell';
const PATH = '/guides/mintmcp-alternative';
const TITLE = 'MintMCP alternative: generate and host a custom MCP server';
const DESCRIPTION =
'MintMCP wraps an existing STDIO server into a remote one with OAuth. If you do not have a server yet, here is the generate-from-a-prompt alternative — and where MintMCP still wins.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-05-31',
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="MintMCP and BuildMyMCPServer both get you to a hosted, OAuth-protected MCP server — but they start from opposite ends. The right pick depends entirely on whether you already have server code."
updated="May 2026"
>
<H2>What MintMCP does well</H2>
<P>
MintMCP takes a local <Strong>STDIO-based MCP server you already wrote</Strong> and turns
it into a production remote deployment one-click, with automatic OAuth wrapping. Its
headline strength is <Strong>compliance</Strong>: SOC 2 Type II, with audit logs in SOC 2,
HIPAA and GDPR-friendly formats. For an enterprise that already has a server and needs the
certifications signed off, that's a strong, honest fit.
</P>
<H2>Where it leaves a gap</H2>
<P>
The model assumes the hard part — designing and writing the server — is already done. If
you're starting from <em>&quot;I need a tool that does X&quot;</em> and there's no code
yet, a wrapper doesn't help. You still have to learn the MCP SDK, write and test the tool
logic, then bring it over.
</P>
<H2>The alternative: start from the prompt</H2>
<P>
<Strong>BuildMyMCPServer</Strong> covers the step before the wrap. You describe the tool in
plain language; it generates the TypeScript MCP server, runs static checks against banned
patterns, builds a container, and deploys it behind a full OAuth 2.1 authorization server
(PKCE, Dynamic Client Registration, Resource Indicators). You get copy-paste install
snippets for Claude Desktop, Cursor and ChatGPT, and the full source to export whenever you
want.
</P>
<H2>Pick by your starting point</H2>
<UL>
<li>
<Strong>You have a working STDIO server + need SOC 2/HIPAA today</Strong> MintMCP is
the more honest fit. We don't claim those certifications.
</li>
<li>
<Strong>You have an idea, not a server</Strong> → generate it here, ship in minutes, and
export the TypeScript if you later move it onto your own infra.
</li>
<li>
<Strong>You're an agency building one-off tools for clients repeatedly</Strong>
generation + a fork-able template marketplace removes the per-client boilerplate.
</li>
<li>
<Strong>You're in the EU/DACH and care where prompts go</Strong> → we expose the provider
and offer a data-residency choice rather than defaulting everything to one region.
</li>
</UL>
<H2>What's the same either way</H2>
<P>
Both deliver a remote, OAuth-protected MCP server at a stable URL that real clients can
install neither leaves you hand-rolling the auth handshake. The difference is purely{' '}
<Strong>where you start</Strong>: with code, or with a sentence.
</P>
<P>
More on the landscape:{' '}
<Link
href="/guides/hosted-mcp-platforms-compared"
className="text-[--color-accent] hover:underline"
>
hosted MCP platforms compared
</Link>
.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,58 @@
import { JsonLd } from '@/components/json-ld';
import { articlesNewestFirst } from '@/lib/articles';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
export const metadata = pageMetadata({
title: 'MCP guides',
description:
'Practical guides on hosting, securing and shipping Model Context Protocol (MCP) servers — OAuth 2.1, remote transport, platform comparisons.',
path: '/guides',
});
export default function GuidesIndex() {
const guides = articlesNewestFirst();
return (
<div className="mx-auto max-w-3xl px-6 py-14">
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
])}
/>
<h1 className="text-[28px] font-semibold tracking-tight text-[--color-fg]">MCP guides</h1>
<p className="mt-2 text-[14.5px] leading-relaxed text-[--color-fg-muted]">
Hosting, auth and shipping for Model Context Protocol servers written for people building
real tools, not demos.
</p>
<div className="mt-8 space-y-3">
{guides.map((g) => (
<Link
key={g.slug}
href={`/guides/${g.slug}`}
className="block rounded-lg border border-[--color-border] p-4 transition-colors hover:bg-[--color-bg-subtle]"
>
<div className="flex items-center gap-2.5">
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
{g.tag}
</span>
<span className="text-[10.5px] text-[--color-fg-subtle]">
{new Date(g.dateModified ?? g.datePublished).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
</div>
<h2 className="mt-1 text-[16px] font-semibold tracking-tight text-[--color-fg]">
{g.title}
</h2>
<p className="mt-1.5 text-[13px] leading-relaxed text-[--color-fg-muted]">
{g.description}
</p>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'Wrap any REST API as an MCP server';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'Wrap any REST API as an MCP server',
tag: 'Guide',
});
}

View File

@@ -0,0 +1,179 @@
import { JsonLd } from '@/components/json-ld';
import { StaticCodeBlock } from '@/components/static-code-block';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
const PATH = '/guides/rest-api-to-mcp-server';
const TITLE = 'Wrap any REST API as an MCP server';
const DESCRIPTION =
'How to turn a REST API into an MCP server your AI clients can use: designing the tool surface (fewer, better tools beat 1:1 endpoint mapping), handling auth headers with encrypted secrets, and respecting upstream rate limits.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
const BAD_PROMPT = `Create an MCP server for our API.
Endpoints: GET /users, GET /users/:id, POST /users, PUT /users/:id,
DELETE /users/:id, GET /orders, GET /orders/:id, POST /orders,
GET /invoices, GET /invoices/:id, POST /invoices/:id/send ...`;
const GOOD_PROMPT = `Create an MCP server for our billing API (https://api.example.com).
Tools:
- find_customer: search customers by name or email, return id, plan, status
- get_open_invoices: list unpaid invoices for a customer id, with amounts
- send_invoice_reminder: send the dunning email for one invoice id
Auth: BILLING_API_KEY sent as "Authorization: Bearer" header.
Read-heavy; send_invoice_reminder is the only write action.`;
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1400,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="Every REST API is one wrapper away from being usable by Claude, Cursor or ChatGPT. The wrapper is an MCP server — and the difference between a useful one and a frustrating one is decided before any code exists, in how you design the tool surface."
updated="July 2026"
>
<H2>The core mistake: 1:1 endpoint mapping</H2>
<P>
The obvious approach one MCP tool per REST endpoint produces a bad server. An AI
assistant choosing between 30 near-identical tools burns context on the choice, chains
three calls where one would do, and picks wrong often enough to erode trust. The
assistant is not a REST client; it does not want your resource model, it wants{' '}
<Strong>tasks</Strong>.
</P>
<StaticCodeBlock code={BAD_PROMPT} label="what not to do" />
<P>
Design the tool surface the way you would design CLI commands for a colleague:
task-shaped, few, with the joins already done.
</P>
<StaticCodeBlock code={GOOD_PROMPT} label="the same API, task-shaped" />
<UL>
<li>
<Strong>37 tools</Strong> is the sweet spot for a single-purpose server. More than ten
and selection quality drops.
</li>
<li>
<Strong>Fold lookups into the tool.</Strong> If sending a reminder needs a customer id,
let <Strong>find_customer</Strong> exist but do not expose the four intermediate
endpoints the API needs internally.
</li>
<li>
<Strong>Return shaped results, not raw payloads.</Strong> id, plan, status beats the
full 80-field customer object; the model reads every byte you return.
</li>
<li>
<Strong>Separate reads from writes</Strong> and say so in the description clients
like ChatGPT restrict write tools on individual plans (
<Link href="/guides/chatgpt-mcp-connector" className="text-[--color-accent] hover:underline">
details here
</Link>
), and a clean read/write split keeps the read tools usable everywhere.
</li>
</UL>
<H2>Auth: the API key never goes in the prompt</H2>
<P>
The wrapper needs your API's credential, and there is exactly one right place for it: an{' '}
<Strong>encrypted secret</Strong>, referenced by name. In the prompt above,{' '}
<Strong>BILLING_API_KEY</Strong> is a name, not a value. You provide the value separately
in the dashboard; it is encrypted with AES-256-GCM at rest and injected into the server's
container as an environment variable at runtime never logged, never echoed back, never
part of the generated source.
</P>
<P>
The second auth layer is between the AI client and your MCP server: every deployed server
sits behind OAuth 2.1 (PKCE, Dynamic Client Registration, Resource Indicators), so your
wrapped API is not one guessable URL away from the public internet. How that handshake
works:{' '}
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
OAuth docs
</Link>
.
</P>
<Note>
Start read-only. A read-only wrapper cannot damage anything while you learn how the
assistant actually uses the tools; add the one or two write actions after a week of
watching the call logs.
</Note>
<H2>Rate limits: yours and theirs</H2>
<Table>
<thead>
<tr>
<th>Layer</th>
<th>What limits it</th>
<th>What to do</th>
</tr>
</thead>
<tbody>
<tr>
<td>Client MCP server</td>
<td>OAuth gate before your container; plan quotas (free tier: 100k calls/mo)</td>
<td>Nothing enforced for you.</td>
</tr>
<tr>
<td>MCP server upstream API</td>
<td>The upstream's own rate limits</td>
<td>
Tell the generator: “respect a limit of N req/s; on 429, back off and surface the
error”. Shaped tools help here too — one task call instead of five endpoint calls.
</td>
</tr>
<tr>
<td>Model behavior</td>
<td>Assistants retry failed calls</td>
<td>
Return clear error messages (“rate limited, retry in 30s”) — models read them and
actually wait.
</td>
</tr>
</tbody>
</Table>
<H2>From prompt to installed tool, end to end</H2>
<P>
With the prompt written, the rest is mechanical:{' '}
<Link href="/guides/create-mcp-server-without-code" className="text-[--color-accent] hover:underline">
generation, static checks, container build and deploy
</Link>{' '}
take 4590 seconds, and the dashboard gives you install snippets for{' '}
<Link href="/guides/claude-desktop-mcp-setup" className="text-[--color-accent] hover:underline">
Claude Desktop
</Link>
, Cursor and ChatGPT. The generated TypeScript is exportable — if your wrapper outgrows
prompt-editing (complex retries, multi-step workflows), take the source and continue by
hand with the boilerplate already written.
</P>
<P>
If your API resembles something common — Notion, GitHub, Stripe, PostgreSQL — check{' '}
<Link href="/templates" className="text-[--color-accent] hover:underline">
the templates
</Link>{' '}
first: forking a working server and swapping in your credential is faster than writing
any prompt. Otherwise, the{' '}
<Link href="/pricing" className="text-[--color-accent] hover:underline">
free tier
</Link>{' '}
covers one server enough to wrap the API you use most and find out what your assistant
does with it.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,13 @@
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
export const runtime = 'edge';
export const alt = 'Smithery alternative: when you need hosting, not a directory';
export const size = OG_SIZE;
export const contentType = 'image/png';
export default function Image() {
return articleOgImage({
title: 'Smithery alternative: when you need hosting, not a directory',
tag: 'Alternative',
});
}

View File

@@ -0,0 +1,196 @@
import { JsonLd } from '@/components/json-ld';
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
import Link from 'next/link';
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
const PATH = '/guides/smithery-alternative';
const TITLE = 'Smithery alternative: when you need hosting, not a directory';
const DESCRIPTION =
'Smithery is the best-known MCP registry — thousands of servers, CLI install, hosting for listed servers. The gap: it assumes the server exists. Here is the route when it does not, and where Smithery clearly wins.';
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
export default function Page() {
return (
<>
<JsonLd
data={articleJsonLd({
title: TITLE,
description: DESCRIPTION,
path: PATH,
datePublished: '2026-07-08',
authorName: 'Marco Sadjadi',
wordCount: 1150,
})}
/>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Guides', path: '/guides' },
{ name: TITLE, path: PATH },
])}
/>
<ArticleShell
title={TITLE}
subtitle="Registry and generator solve different halves of the same problem. Smithery answers 'where do I find or publish an MCP server?' A generator answers 'who builds and hosts mine?' Picking the wrong one wastes an afternoon; here is how to tell them apart in two minutes."
updated="July 2026"
>
<H2>What Smithery does well</H2>
<P>
Smithery is the closest thing MCP has to a package index. As of mid-2026 it lists{' '}
<Strong>thousands of community-built servers</Strong> (their catalog crossed 6,000+ some
time ago), searchable by category, installable via CLI, and for servers published there
runnable as hosted remote endpoints with OAuth handled by the platform. Its Toolbox
meta-server can even route an agent dynamically across registry servers so you don&apos;t
wire each one by hand. Browsing and publishing are free; hosted execution and higher
usage sit behind paid tiers.
</P>
<P>
Two jobs it does better than anyone: <Strong>discovery</Strong> (&quot;does a server for X
already exist?&quot;) and <Strong>distribution</Strong> (&quot;let people find and run the
server I wrote&quot;). If either is your actual need, stop reading and use Smithery.
</P>
<H2>The assumption baked into a registry</H2>
<P>
Every path through Smithery starts from an existing server: one you found in the catalog,
or one you wrote and published. The moment your need is a tool that{' '}
<em>nobody has built</em> a wrapper around your internal API, a scoped read-only view of
your own database, a workflow specific to your team the registry has nothing to list.
You are back to the MCP SDK, TypeScript, container images and OAuth wiring before
Smithery can help you host or distribute anything.
</P>
<H2>The alternative: generate, then host</H2>
<P>
<Strong>BuildMyMCPServer</Strong> replaces the &quot;write it first&quot; step. Describe
the tool in natural language; the platform generates the TypeScript MCP server, runs
static checks, builds an isolated Docker container and deploys it behind a full OAuth 2.1
authorization server (PKCE, Dynamic Client Registration, Resource Indicators) at a public
Streamable HTTP URL. Claude Desktop, Cursor and ChatGPT connect with a copy-paste
snippet. The full source stays exportable if you later want to publish the server on
Smithery or self-host it, you can take the code and go.
</P>
<H2>Side by side</H2>
<Table>
<thead>
<tr>
<th>&nbsp;</th>
<th>Smithery</th>
<th>BuildMyMCPServer</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<Strong>Core job</Strong>
</td>
<td>Find, publish and run existing servers</td>
<td>Create and host a server that doesn&apos;t exist yet</td>
</tr>
<tr>
<td>
<Strong>Starting point</Strong>
</td>
<td>A server (yours or the catalog&apos;s)</td>
<td>A sentence describing the tool</td>
</tr>
<tr>
<td>
<Strong>Catalog size</Strong>
</td>
<td>Thousands of community servers</td>
<td>Small first-party template gallery</td>
</tr>
<tr>
<td>
<Strong>Hosting &amp; auth</Strong>
</td>
<td>Hosted endpoints with OAuth for listed servers</td>
<td>Every server deployed behind OAuth 2.1, isolated container</td>
</tr>
<tr>
<td>
<Strong>Pricing shape</Strong>
</td>
<td>Free registry; paid hosting/usage tiers</td>
<td>Free: 1 server, 100k calls/mo; Pro 49/mo</td>
</tr>
<tr>
<td>
<Strong>Exit path</Strong>
</td>
<td>Your code was always yours</td>
<td>Full TypeScript source export</td>
</tr>
</tbody>
</Table>
<Note>
Smithery details are as of mid-2026 from public materials; check their site for current
catalog size and tier pricing.
</Note>
<H2>Decision rule</H2>
<UL>
<li>
<Strong>The tool might already exist</Strong> search Smithery first. Genuinely five
minutes there can save the whole build.
</li>
<li>
<Strong>You wrote a server and want users</Strong> publish on Smithery; that is its
home turf.
</li>
<li>
<Strong>The tool is bespoke to your company</Strong> generate it. A registry cannot
list what only you need.
</li>
<li>
<Strong>Long-term:</Strong> the routes compose generate the bespoke server, export
the source, publish it wherever distribution helps.
</li>
</UL>
<H2>Honest caveats</H2>
<P>
We are the young product in this comparison: no SOC 2, a small template gallery next to a
registry of thousands, and a generator that is bounded by what a prompt can specify
clear inputs, outputs and API calls. What we ask you to test is the part that matters
when nothing in any catalog fits: prompt to hosted, OAuth-protected server in about a
minute, on the{' '}
<Link href="/pricing" className="text-[--color-accent] hover:underline">
free tier
</Link>
, starting from scratch or from a{' '}
<Link href="/templates" className="text-[--color-accent] hover:underline">
template
</Link>
.
</P>
<P>
Related:{' '}
<Link
href="/guides/mcp-server-hosting-pricing"
className="text-[--color-accent] hover:underline"
>
MCP hosting pricing compared
</Link>{' '}
·{' '}
<Link
href="/guides/hosted-mcp-platforms-compared"
className="text-[--color-accent] hover:underline"
>
registries vs connectors vs infra vs generators
</Link>{' '}
·{' '}
<Link href="/guides/composio-alternative" className="text-[--color-accent] hover:underline">
Composio alternative
</Link>
.
</P>
</ArticleShell>
</>
);
}

View File

@@ -0,0 +1,95 @@
import { pageMetadata } from '@/lib/seo';
import Link from 'next/link';
export const metadata = pageMetadata({
title: 'Impressum',
description: 'Legal information for BuildMyMCPServer (Switzerland).',
path: '/impressum',
});
export default function Impressum() {
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]">
Impressum
</div>
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">Impressum</h1>
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
Angaben gemäss UWG Art. 3 Abs. 1 lit. s (Schweiz).
</p>
</header>
<div className="space-y-8">
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Anbieter</h2>
<div className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
<p>BuildMyMCPServer</p>
<p>Schweiz</p>
<p className="mt-2 text-[12px] text-[--color-fg-subtle]">
Postanschrift auf Anfrage über das Support-Panel.
</p>
</div>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Kontakt</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
Sämtliche Kontaktanfragen laufen über unser integriertes Support-Panel ohne
Account erreichbar unter{' '}
<Link href="/contact" className="text-[--color-accent] underline">
/contact
</Link>
. Eingeloggte Nutzer:innen verwenden{' '}
<Link href="/settings/support" className="text-[--color-accent] underline">
/settings/support
</Link>
. Wir antworten in der Regel innerhalb von einem Werktag.
</p>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Mehrwertsteuer</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
UID-Nummer wird im ausgestellten Beleg geführt. Bei steuerrechtlichen Anfragen
kontaktiere uns über das Support-Panel.
</p>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Haftungsausschluss</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
Inhalte dieser Webseite werden mit grösstmöglicher Sorgfalt erstellt. Für Richtigkeit,
Vollständigkeit und Aktualität wird jedoch keine Gewähr übernommen. Für Inhalte
externer Links sind ausschliesslich deren Betreiber verantwortlich.
</p>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Anwendbares Recht</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
Es gilt schweizerisches Recht unter Ausschluss kollisionsrechtlicher Bestimmungen.
Gerichtsstand ist der Sitz des Anbieters.
</p>
</section>
<section>
<h2 className="text-[16px] font-semibold tracking-tight">Weiterführend</h2>
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
<Link href="/privacy" className="text-[--color-accent] underline">
Datenschutzerklärung
</Link>{' '}
·{' '}
<Link href="/agb" className="text-[--color-accent] underline">
AGB
</Link>{' '}
·{' '}
<Link href="/security" className="text-[--color-accent] underline">
Security
</Link>
</p>
</section>
</div>
</div>
);
}

View File

@@ -1,11 +1,14 @@
import Link from 'next/link';
import { CookieBanner } from '@/components/cookie-banner';
import { Logo } from '@/components/logo';
import { MarketingAuthButtons } from '@/components/marketing-auth-buttons';
import { MarketingMobileMenu } from '@/components/marketing-mobile-menu';
import Link from 'next/link';
export default function MarketingLayout({ 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]/80 backdrop-blur-md">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-6">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5 sm:px-6">
<div className="flex items-center gap-6">
<Logo />
<nav className="hidden items-center gap-5 text-[13px] text-[--color-fg-muted] md:flex">
@@ -21,51 +24,98 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
<Link href="/docs" className="transition-colors hover:text-[--color-fg]">
Docs
</Link>
<Link href="/guides" className="transition-colors hover:text-[--color-fg]">
Guides
</Link>
<Link href="/changelog" className="transition-colors hover:text-[--color-fg]">
Changelog
</Link>
</nav>
</div>
<div className="flex items-center gap-2">
<Link
href="/login"
className="rounded-md px-3 py-1.5 text-[13px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Sign in
</Link>
<Link
href="/login"
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
>
Start building
</Link>
<div className="flex items-center gap-1.5 sm:gap-2">
<MarketingAuthButtons />
<MarketingMobileMenu />
</div>
</div>
</header>
<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">
<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" />
<footer className="border-t border-[--color-border] py-12">
<div className="mx-auto max-w-6xl px-6">
<div className="grid gap-10 sm:grid-cols-2 md:grid-cols-4">
{/* Brand column — positioning line + live status. */}
<div className="sm:col-span-2 md:col-span-1">
<Logo />
<p className="mt-3 max-w-xs text-[12.5px] leading-relaxed text-[--color-fg-muted]">
From a prompt to a hosted, OAuth-protected MCP server for Claude, Cursor and
ChatGPT.
</p>
<Link
href="/status"
className="mt-4 flex items-center gap-2 text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
<span className="size-1.5 animate-pulse rounded-full bg-[--color-success]" />
<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
</Link>
<Link href="/security" className="transition-colors hover:text-[--color-fg]">
Security
</Link>
<Link href="/privacy" className="transition-colors hover:text-[--color-fg]">
Privacy
</Link>
<Link href="/terms" className="transition-colors hover:text-[--color-fg]">
Terms
</Link>
</div>
<div>&copy; {new Date().getFullYear()} BuildMyMCPServer</div>
<FooterColumn
title="Product"
links={[
{ href: '/templates', label: 'Templates' },
{ href: '/pricing', label: 'Pricing' },
{ href: '/changelog', label: 'Changelog' },
{ href: '/security', label: 'Security' },
]}
/>
<FooterColumn
title="Resources"
links={[
{ href: '/docs', label: 'Docs' },
{ href: '/guides', label: 'Guides' },
{ href: '/docs/faq', label: 'FAQ' },
{ href: '/contact', label: 'Contact' },
]}
/>
<FooterColumn
title="Legal"
links={[
{ href: '/privacy', label: 'Privacy' },
{ href: '/terms', label: 'Terms' },
{ href: '/agb', label: 'AGB' },
{ href: '/impressum', label: 'Impressum' },
]}
/>
</div>
<div className="mt-10 border-t border-[--color-border] pt-6 text-[12px] text-[--color-fg-subtle]">
&copy; {new Date().getFullYear()} BuildMyMCPServer
</div>
</div>
</footer>
<CookieBanner />
</div>
);
}
function FooterColumn({
title,
links,
}: {
title: string;
links: { href: string; label: string }[];
}) {
return (
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[--color-fg-muted]">
{title}
</h3>
<ul className="mt-3 space-y-2 text-[12.5px] text-[--color-fg-muted]">
{links.map((l) => (
<li key={l.href}>
<Link href={l.href} className="transition-colors hover:text-[--color-fg]">
{l.label}
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@@ -1,262 +1,655 @@
import {
GitHubIcon,
NotionIcon,
PostgresIcon,
RestIcon,
SalesforceCloudIcon,
StripeIcon,
} from '@/components/brand-icons';
import { HeroStepRotator } from '@/components/hero-step-rotator';
import { HeroVideo } from '@/components/hero-video';
import { JsonLd } from '@/components/json-ld';
import { ParticleHero } from '@/components/particle-hero';
import { PulseLink } from '@/components/pulse';
import { ScrollCue } from '@/components/scroll-cue';
import { TIERS } from '@/lib/pricing';
import { FAQ, faqJsonLd } from '@/lib/seo';
import { Activity, ChevronDown, Container, ShieldCheck } from 'lucide-react';
import Link from 'next/link';
import { CodeBlock } from '@/components/code-block';
import type { ComponentType } from 'react';
const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace.
Tools: search_pages, get_page_content.
Auth: NOTION_API_KEY.`;
interface ExampleEntry {
title: string;
desc: string;
/** Brand logo component rendered inside the coloured chip. */
Icon: ComponentType<{ size?: number; className?: string }>;
/** Official brand colour for the chip background. */
bg: string;
/** Foreground colour the icon paints in. */
fg: string;
}
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 }[] = [
const EXAMPLES: ExampleEntry[] = [
{
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.',
title: 'PostgreSQL',
desc: 'Read-only access to your tables with schema introspection.',
Icon: PostgresIcon,
bg: '#336791',
fg: '#ffffff',
},
{
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.',
title: 'Salesforce',
desc: 'Query opportunities, accounts and leads from Claude.',
Icon: SalesforceCloudIcon,
bg: '#00a1e0',
fg: '#ffffff',
},
{
q: 'Which clients work?',
a: 'Claude Desktop, Cursor, ChatGPT Custom Connectors, VS Code Copilot, Continue.dev — anything that speaks the MCP spec.',
title: 'Notion',
desc: 'Search pages, read content, append blocks.',
Icon: NotionIcon,
bg: '#ffffff',
fg: '#0a0a0b',
},
{
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).',
title: 'GitHub',
desc: 'List issues, search code, post comments. Scoped to one repo.',
Icon: GitHubIcon,
bg: '#181717',
fg: '#ffffff',
},
{
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.',
title: 'Stripe',
desc: 'Look up charges, customers, refunds (read-only by default).',
Icon: StripeIcon,
bg: '#635bff',
fg: '#ffffff',
},
{
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 Lets Encrypt automatically.',
title: 'Custom REST',
desc: 'Wrap any HTTP API behind one prompt-defined tool surface.',
Icon: RestIcon,
bg: '#6366f1',
fg: '#ffffff',
},
];
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'] },
// Honest marketplace preview data: template names + tool counts only. These
// mirror the first-party starter templates; no invented fork counts, no
// "verified" theatre — the frame is labelled as a preview of the card format.
const PREVIEW_TEMPLATES: { name: string; author: string; tools: number }[] = [
{ name: 'notion-search', author: 'core', tools: 2 },
{ name: 'github-issues', author: 'core', tools: 3 },
{ name: 'stripe-readonly', author: 'core', tools: 4 },
{ name: 'postgres-readonly', author: 'core', tools: 3 },
];
const MARKETPLACE_POINTS: { t: string; d: string }[] = [
{
t: 'Fork and own',
d: 'Start from a server someone already shipped. Fork it, paste your own credentials, deploy. No prompt required.',
},
{
t: 'Secrets never travel',
d: "A template carries the spec and generated code, never the author's API keys. You add your own on fork.",
},
{
t: 'Open from day one',
d: 'Publish a server you built and anyone can fork it. The marketplace is young — early templates set the standard.',
},
];
// Proof-by-specificity band: three claims a visitor can verify with one
// click instead of taking our word for it. Substitute for social proof
// until there is social proof.
const PROOF_POINTS: {
t: string;
d: string;
href: string;
linkLabel: string;
Icon: ComponentType<{ size?: number; className?: string }>;
}[] = [
{
t: 'OAuth 2.1 authorization server',
d: 'PKCE, Dynamic Client Registration and Resource Indicators (RFC 8707) in front of every server.',
href: '/docs/oauth',
linkLabel: 'Read the auth docs',
Icon: ShieldCheck,
},
{
t: 'Live system status',
d: 'Uptime and incident history, public. If a build queue slows down, you see it before we tell you.',
href: '/status',
linkLabel: 'Check status now',
Icon: Activity,
},
{
t: 'Per-server container isolation',
d: 'Every generated server runs in its own Docker container. Secrets AES-256-GCM encrypted at rest.',
href: '/security',
linkLabel: 'Security architecture',
Icon: Container,
},
];
const PIPELINE_STEPS: { n: string; t: string; d: string }[] = [
{
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.',
},
];
/** Mono shell-comment section kicker: `## how_it_works` */
function Kicker({ children }: { children: string }) {
return <p className="kicker">## {children}</p>;
}
export default function Landing() {
const teaserTiers = TIERS.filter((t) => t.name === 'Hobby' || t.name === 'Pro');
return (
<>
{/* Hero */}
<section className="relative border-b border-[--color-border]">
<div className="mx-auto grid max-w-6xl gap-12 px-6 py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:py-28">
<div>
<span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]">
v0.1 updated 2026-05-20
</span>
<h1 className="mt-6 text-balance text-[44px] font-semibold leading-[1.05] tracking-tight md:text-[56px]">
{/* Hero — left: copy + CTAs, right: cycling terminal tile. The WebGL
particle field sits behind at z-0 with pointer-events:none so the
CTAs stay interactive. */}
<section
className="relative flex items-center overflow-hidden border-b border-[--color-border]"
style={{ minHeight: 'calc(100svh - 3.5rem)' }}
>
<ParticleHero />
<div className="relative z-10 mx-auto grid w-full max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12">
<div className="min-w-0">
<h1 className="text-balance text-[36px] font-semibold leading-[1.04] tracking-[-0.03em] sm:text-[44px] md:text-[60px]">
Describe your tool.
<br />
We host the server.
<br />
<span className="text-[--color-fg-muted]">AI uses it.</span>
</h1>
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-[--color-fg-muted]">
From prompt to production MCP server in 60 seconds. OAuth 2.1, Streamable HTTP, ready
for Claude, Cursor and ChatGPT.
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-[--color-fg-muted] sm:text-[16px]">
From a prompt to a production MCP server OAuth 2.1, Streamable HTTP, ready for
Claude, Cursor and ChatGPT.
</p>
<div className="mt-7 flex flex-wrap items-center gap-3">
<Link
<div className="mt-8 flex flex-wrap items-center gap-3">
<PulseLink
href="/login"
className="inline-flex h-9 items-center justify-center rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
className="btn-brand inline-flex h-11 items-center justify-center rounded-md px-5 text-[14px] font-medium"
>
Start building free
</Link>
<Link
href="/docs"
className="inline-flex h-9 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-4 text-[13px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
Start building free
</PulseLink>
<PulseLink
href="#flow"
className="inline-flex h-11 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-5 text-[14px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Read the docs
</Link>
Watch a build
</PulseLink>
</div>
<div className="mt-10 flex flex-wrap gap-x-6 gap-y-2 text-[12px] text-[--color-fg-subtle]">
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-2 text-[12px] text-[--color-fg-muted]">
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> OAuth 2.1 + PKCE
<span className="size-1.5 rounded-full bg-[--color-success]" /> OAuth 2.1 + PKCE
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> Streamable HTTP
<span className="size-1.5 rounded-full bg-[--color-success]" /> Streamable HTTP
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> AES-256 secrets
<span className="size-1.5 rounded-full bg-[--color-success]" /> AES-256 secrets
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> Per-server isolation
<span className="size-1.5 rounded-full bg-[--color-success]" /> Per-server isolation
</span>
</div>
</div>
<div className="relative min-w-0">
<HeroStepRotator />
</div>
</div>
</section>
<ScrollCue targetId="flow" />
{/* Flow video — full-width edge-to-edge under the hero. Plays when
scrolled into view (see hero-video.tsx); preload=metadata keeps the
2.6 MB mp4 off the critical path on mobile. */}
<section
id="flow"
className="relative w-full overflow-hidden border-b border-[--color-border] bg-black"
>
<div className="relative aspect-video w-full">
<HeroVideo />
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(ellipse at center, transparent 60%, rgba(10,10,11,0.55) 100%)',
}}
/>
</div>
</section>
{/* How it works — three pipeline nodes joined by a gradient connector.
Desktop: horizontal line behind the numbered nodes. Mobile: the grid
stacks and each card keeps its own node, joined by a left rail. */}
<section id="how" className="border-b border-[--color-border] py-14 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-10 max-w-2xl">
<Kicker>how_it_works</Kicker>
<h2 className="mt-3 text-[28px] font-semibold tracking-tight sm:text-[32px]">
Three steps. No JSON to write, no Docker to manage.
</h2>
</div>
<div className="relative">
<div className="absolute -inset-px rounded-lg border border-[--color-border-strong]" />
<div className="space-y-3">
<CodeBlock label="prompt.txt" code={PROMPT_EXAMPLE} />
<CodeBlock label="build.log" code={OUTPUT_EXAMPLE} />
<CodeBlock label="claude_desktop_config.json" code={INSTALL_SNIPPET} />
</div>
</div>
</div>
</section>
{/* How it works */}
<section id="how" className="border-b border-[--color-border] py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-12 max-w-2xl">
<h2 className="text-[28px] font-semibold tracking-tight">How it works</h2>
<p className="mt-2 text-[14px] text-[--color-fg-muted]">
Three steps. No JSON to write, no Docker to manage.
</p>
</div>
<div className="grid gap-6 md:grid-cols-3">
{[
{ 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) => (
<div key={s.n} className="panel p-5">
<div className="mono text-[11px] tracking-widest text-[--color-fg-subtle]">{s.n}</div>
<h3 className="mt-4 text-[15px] font-semibold tracking-tight">{s.t}</h3>
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted]">{s.d}</p>
</div>
))}
</div>
</div>
</section>
{/* Works with */}
<section className="border-b border-[--color-border] py-16">
<div className="mx-auto max-w-6xl px-6">
<h2 className="text-center text-[13px] uppercase tracking-[0.18em] text-[--color-fg-subtle]">
Works with the clients you already use
</h2>
<div className="mt-8 flex flex-wrap items-center justify-center gap-x-12 gap-y-4 text-[14px] text-[--color-fg-muted]">
{['Claude Desktop', 'Cursor', 'ChatGPT', 'VS Code Copilot', 'Continue.dev'].map((t) => (
<span key={t} className="inline-flex items-center gap-2">
<span className="size-1.5 rounded-full bg-[--color-fg-subtle]" />
{t}
</span>
))}
</div>
</div>
</section>
{/* Examples */}
<section className="border-b border-[--color-border] py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-10 max-w-2xl">
<h2 className="text-[28px] font-semibold tracking-tight">Built for the work you actually have</h2>
<p className="mt-2 text-[14px] text-[--color-fg-muted]">
Anything with an HTTP API or a database, in minutes.
</p>
</div>
<div className="grid gap-3 md:grid-cols-3">
{EXAMPLES.map((e) => (
<div key={e.title} className="panel p-4 transition-colors hover:border-[--color-border-strong]">
<div className="text-[13px] font-semibold tracking-tight">{e.title}</div>
<p className="mt-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">{e.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="border-b border-[--color-border] py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-10 max-w-2xl">
<h2 className="text-[28px] font-semibold tracking-tight">Pricing</h2>
<p className="mt-2 text-[14px] text-[--color-fg-muted]">
Pay for tool calls, not for boilerplate.
</p>
</div>
<div className="grid gap-3 md:grid-cols-4">
{TIERS.map((t, i) => (
{/* Gradient connector — desktop only, sits behind the node row. */}
<div
key={t.name}
className={`panel p-5 ${i === 1 ? 'border-[--color-accent]/40' : ''}`}
aria-hidden
className="absolute left-0 right-0 top-[22px] hidden h-px md:block"
style={{ background: 'var(--gradient-brand)', opacity: 0.35 }}
/>
{/* Mobile left rail joining the stacked nodes. */}
<div
aria-hidden
className="absolute bottom-6 left-[22px] top-[22px] w-px md:hidden"
style={{ background: 'var(--gradient-brand)', opacity: 0.35 }}
/>
<div className="grid gap-8 md:grid-cols-3 md:gap-6">
{PIPELINE_STEPS.map((s) => (
<div key={s.n} className="relative flex gap-4 md:block">
<div
className="mono relative z-10 flex size-11 shrink-0 items-center justify-center rounded-full border border-[--color-border-strong] text-[12px] tracking-widest text-[--color-fg]"
style={{
background: 'var(--color-bg)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.05)',
}}
>
<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-[26px] font-semibold tracking-tight">{t.price}</span>
<span className="text-[12px] text-[--color-fg-subtle]">{t.tag}</span>
{s.n}
</div>
<ul className="mt-4 space-y-1.5 text-[12.5px] text-[--color-fg-muted]">
{t.features.map((f) => (
<li key={f}> {f}</li>
<div className="min-w-0 md:mt-5">
<h3 className="text-[15px] font-semibold tracking-tight">{s.t}</h3>
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted]">
{s.d}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</section>
{/* Proof by specificity — three verifiable claims, each one click from
its evidence. Replaces the old pseudo-logo row: no invented marks,
just a plain-text compatibility line. */}
<section className="py-16 sm:py-24">
<div className="mx-auto max-w-6xl px-6">
<Kicker>verify_it_yourself</Kicker>
<h2 className="mt-3 max-w-2xl text-[32px] font-semibold leading-[1.1] tracking-tight sm:text-[40px]">
Don&apos;t take our word for it.
<br />
<span className="text-[--color-fg-muted]">Every claim links to its proof.</span>
</h2>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{PROOF_POINTS.map((p) => {
const Icon = p.Icon;
return (
<Link
key={p.t}
href={p.href}
className="panel-raised group flex flex-col p-5 transition-colors hover:border-[--color-border-strong]"
>
<Icon size={20} className="text-[--color-accent]" />
<h3 className="mt-4 text-[15px] font-semibold tracking-tight">{p.t}</h3>
<p className="mt-2 flex-1 text-[13px] leading-relaxed text-[--color-fg-muted]">
{p.d}
</p>
<span className="mt-4 text-[13px] font-medium text-[--color-accent] transition-colors group-hover:text-[--color-fg]">
{p.linkLabel}
</span>
</Link>
);
})}
</div>
<p className="mt-10 text-center text-[13px] text-[--color-fg-muted]">
Works with Claude Desktop, Cursor, ChatGPT, VS Code Copilot and Continue.dev anything
that speaks MCP.
</p>
</div>
</section>
{/* Use cases — brand-coloured integration grid. No bottom hairline:
the marketplace section below brings its own border-y. */}
<section className="py-20 sm:py-28">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-12 grid gap-6 md:grid-cols-[1fr_auto] md:items-end md:gap-12">
<div>
<Kicker>use_cases</Kicker>
<h2 className="mt-3 text-[32px] font-semibold leading-[1.1] tracking-tight sm:text-[40px]">
Wrap any HTTP API.
<br />
<span className="text-[--color-fg-muted]">From one prompt.</span>
</h2>
</div>
<p className="max-w-xs text-[14px] leading-relaxed text-[--color-fg-muted]">
Each shipped from a single prompt.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{EXAMPLES.map((e) => {
const Icon = e.Icon;
return (
<div
key={e.title}
className="panel-raised group flex items-start gap-4 p-5 transition-colors hover:border-[--color-border-strong]"
>
<div
aria-hidden
className="flex size-12 shrink-0 items-center justify-center rounded-lg"
style={{
backgroundColor: e.bg,
color: e.fg,
}}
>
<Icon size={24} />
</div>
<div className="min-w-0">
<h3 className="text-[15px] font-semibold tracking-tight">{e.title}</h3>
<p className="mt-1.5 text-[13px] leading-relaxed text-[--color-fg-muted]">
{e.desc}
</p>
</div>
</div>
);
})}
</div>
</div>
</section>
{/* Marketplace — split layout: selling points left, honest preview
frame right. Elevated background (no hairline) so the page reads
in blocks instead of one continuously ruled ledger. */}
<section className="border-y border-[--color-border] bg-[--color-bg-elevated] py-20 sm:py-28">
<div className="mx-auto grid max-w-6xl gap-14 px-6 md:grid-cols-[5fr_6fr] md:items-center md:gap-16">
<div>
<Kicker>marketplace</Kicker>
<h2 className="mt-3 text-[32px] font-semibold leading-[1.1] tracking-tight sm:text-[40px]">
Skip the prompt.
<br />
<span className="text-[--color-fg-muted]">Fork what works.</span>
</h2>
<p className="mt-5 max-w-md text-[14px] leading-relaxed text-[--color-fg-muted]">
The marketplace is a library of working MCP servers. Fork one, paste your credentials,
deploy. Or publish yours and let others build on it.
</p>
<ul className="mt-8 space-y-5">
{MARKETPLACE_POINTS.map((p) => (
<li key={p.t} className="flex items-start gap-3">
<span
aria-hidden
className="mt-[7px] size-1.5 shrink-0 rounded-full bg-[--color-accent]"
/>
<div>
<h3 className="text-[14px] font-semibold tracking-tight">{p.t}</h3>
<p className="mt-1 text-[13px] leading-relaxed text-[--color-fg-muted]">
{p.d}
</p>
</div>
</li>
))}
</ul>
<PulseLink
href="/templates"
className="btn-brand mt-8 inline-flex h-11 items-center gap-2 rounded-md px-5 text-[14px] font-medium"
>
Browse the marketplace
</PulseLink>
</div>
<MarketplacePreview />
</div>
</section>
{/* Pricing teaser — Hobby + Pro only; the full matrix lives on
/pricing. Data is shared via lib/pricing.ts so it can't drift. */}
<section id="pricing" className="border-b border-[--color-border] py-20 sm:py-28">
<div className="mx-auto max-w-4xl px-6">
<div className="mb-12 text-center">
<Kicker>pricing</Kicker>
<h2 className="mt-3 text-[32px] font-semibold leading-[1.1] tracking-tight sm:text-[40px]">
Pay for tool calls.
<br />
<span className="text-[--color-fg-muted]">Not for boilerplate.</span>
</h2>
</div>
<div className="grid gap-5 sm:grid-cols-2">
{teaserTiers.map((t) => {
const featured = Boolean(t.highlight);
return (
<div
key={t.name}
className={`relative flex flex-col gap-6 rounded-xl border p-6 ${
featured
? 'border-[--color-accent] bg-[--color-bg-elevated]'
: 'border-[--color-border] bg-[--color-bg-elevated]'
}`}
style={
featured
? {
boxShadow:
'0 0 0 4px rgba(99, 102, 241, 0.12), 0 24px 50px rgba(0, 0, 0, 0.35)',
}
: { boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.04)' }
}
>
{featured && (
<span
className="absolute -top-3 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full border border-[--color-accent] px-3 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-[--color-accent]"
style={{ backgroundColor: 'var(--color-bg)' }}
>
Recommended
</span>
)}
<div>
<div className="text-[11px] font-medium uppercase tracking-[0.18em] text-[--color-fg-muted]">
{t.name}
</div>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-[40px] font-semibold leading-none tracking-tight text-[--color-fg]">
{t.price}
</span>
<span className="text-[12px] text-[--color-fg-subtle]">{t.tag}</span>
</div>
<p className="mt-3 text-[13px] leading-relaxed text-[--color-fg-muted]">
{t.description}
</p>
</div>
<ul className="flex flex-1 flex-col gap-2.5 border-t border-[--color-border] pt-5 text-[13px] text-[--color-fg-muted]">
{t.features.map((f) => (
<li key={f} className="flex items-start gap-2.5">
<span
aria-hidden
className={`mt-[7px] size-1 shrink-0 rounded-full ${
featured ? 'bg-[--color-accent]' : 'bg-[--color-fg-subtle]'
}`}
/>
<span>{f}</span>
</li>
))}
</ul>
<Link
href={t.href}
className={`inline-flex h-10 items-center justify-center rounded-md px-4 text-[13px] font-medium transition-colors ${
featured
? 'btn-brand'
: 'border border-[--color-border] bg-[--color-bg-subtle] text-[--color-fg] hover:border-[--color-border-strong]'
}`}
>
{t.cta}
</Link>
</div>
);
})}
</div>
<div className="mt-8 text-center">
<Link
href="/pricing"
className="text-[14px] font-medium text-[--color-accent] transition-colors hover:text-[--color-fg]"
>
See all plans Team, Enterprise
</Link>
</div>
</div>
</section>
{/* FAQ — collapsible accordion using native <details>. Crawlers and
screen readers see the full Q+A in the HTML. */}
<section className="border-b border-[--color-border] py-14 sm:py-20">
<JsonLd data={faqJsonLd()} />
<div className="mx-auto max-w-3xl px-6">
<Kicker>faq</Kicker>
<h2 className="mt-3 text-[28px] font-semibold tracking-tight">
Questions, answered straight.
</h2>
<div className="mt-8 border-t border-[--color-border]">
{FAQ.map((f) => (
<details
key={f.q}
className="group border-b border-[--color-border] [&_summary::-webkit-details-marker]:hidden"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-[14.5px] font-semibold tracking-tight text-[--color-fg] transition-colors hover:text-[--color-accent]">
<span>{f.q}</span>
<ChevronDown
size={16}
className="shrink-0 text-[--color-fg-subtle] transition-transform duration-200 group-open:rotate-180 group-open:text-[--color-accent]"
/>
</summary>
<p className="pb-5 pr-8 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
{f.a}
</p>
</details>
))}
</div>
</div>
</section>
{/* FAQ */}
<section className="py-20">
<div className="mx-auto max-w-6xl px-6">
<h2 className="text-[28px] font-semibold tracking-tight">FAQ</h2>
<div className="mt-8 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>
))}
{/* Final CTA — the page must not end on FAQ. Gradient top border keeps
the brand mark scarce but present at the exit point. */}
<section className="relative py-20 sm:py-28">
<div
aria-hidden
className="absolute inset-x-0 top-0 h-px"
style={{ background: 'var(--gradient-brand)', opacity: 0.6 }}
/>
<div className="mx-auto max-w-3xl px-6 text-center">
<h2 className="text-balance text-[32px] font-semibold leading-[1.1] tracking-tight sm:text-[44px]">
Your first server is <span className="text-brand-gradient">one prompt away</span>.
</h2>
<p className="mx-auto mt-4 max-w-md text-[14.5px] leading-relaxed text-[--color-fg-muted]">
Free tier, full source export, no lock-in. If it doesn't work for you, take the
TypeScript and leave.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<PulseLink
href="/login"
className="btn-brand inline-flex h-11 items-center justify-center rounded-md px-6 text-[14px] font-medium"
>
Start building free →
</PulseLink>
<PulseLink
href="/docs"
className="inline-flex h-11 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-6 text-[14px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Read the docs
</PulseLink>
</div>
</div>
</section>
</>
);
}
/**
* Honest marketplace preview.
*
* Static, server-rendered browser frame showing what a template card looks
* like. Card data mirrors the first-party starter templates — names, author
* and tool counts only. No fork counts, no "verified" badges: the frame is
* explicitly labelled a preview, not live marketplace traffic.
*/
function MarketplacePreview() {
return (
<div
className="overflow-hidden rounded-xl border border-[--color-border-strong] bg-[--color-bg-elevated]"
style={{
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 60px rgba(0, 0, 0, 0.45)',
}}
>
{/* Browser chrome */}
<div className="flex items-center gap-3 border-b border-[--color-border] bg-[--color-bg-subtle] px-4 py-3">
<div className="flex gap-1.5">
<span className="size-2.5 rounded-full bg-[#ff5f57]/80" />
<span className="size-2.5 rounded-full bg-[#febc2e]/80" />
<span className="size-2.5 rounded-full bg-[#28c840]/80" />
</div>
<div className="mono flex-1 truncate rounded-md border border-[--color-border] bg-[--color-bg] px-3 py-1 text-[11px] text-[--color-fg-subtle]">
buildmymcpserver.com/templates
</div>
</div>
{/* Toolbar inside the page chrome */}
<div className="flex items-center justify-between gap-4 border-b border-[--color-border] px-5 py-3">
<span className="text-[13px] font-semibold tracking-tight text-[--color-fg]">
Templates
</span>
<span className="mono text-[10px] uppercase tracking-wider text-[--color-fg-subtle]">
preview
</span>
</div>
{/* Template cards grid */}
<div className="grid gap-3 p-4 sm:grid-cols-2">
{PREVIEW_TEMPLATES.map((t) => (
<div
key={t.name}
className="rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-4 transition-colors hover:border-[--color-accent]/40"
>
<div className="min-w-0">
<div className="mono truncate text-[13px] font-semibold tracking-tight text-[--color-fg]">
{t.name}
</div>
<div className="mt-1 flex items-center gap-1.5">
<span className="mono text-[10px] uppercase tracking-wider text-[--color-fg-subtle]">
{t.author}
</span>
<span className="inline-flex items-center rounded-full border border-[--color-border-strong] px-1.5 py-px text-[9.5px] font-medium uppercase tracking-wider text-[--color-fg-subtle]">
template
</span>
</div>
</div>
<div className="mt-4 text-[11px] text-[--color-fg-subtle]">
<span className="mono">{t.tools} tools</span>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,69 +1,13 @@
import { TIERS } from '@/lib/pricing';
import { pageMetadata } from '@/lib/seo';
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',
},
];
export const metadata = pageMetadata({
title: 'Pricing',
description:
'BuildMyMCPServer pricing — start free with one hosted MCP server, scale to Pro, Team and Enterprise. Pay for tool calls, not boilerplate.',
path: '/pricing',
});
const FAQ = [
{
@@ -72,11 +16,11 @@ const FAQ = [
},
{
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.',
a: 'Daily build and analysis limits return a 429 with a clear upgrade hint. Monthly tool-call volumes are generous soft limits — we reach out before anything is capped.',
},
{
q: 'Annual billing?',
a: 'Yes — save 20% on Pro and Team paying annually. Enterprise is annual by default.',
a: 'Annual plans are coming — contact us for annual invoicing today. Enterprise is annual by default.',
},
{
q: 'Plan changes?',
@@ -113,7 +57,16 @@ export default function Pricing() {
<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>
<p className="mt-2 text-[12px] leading-relaxed text-[--color-fg-muted]">
{t.description}
</p>
<div className="mt-3 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 py-1.5">
<div className="text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
AI model
</div>
<div className="mt-0.5 text-[12.5px] font-medium text-[--color-fg]">{t.model}</div>
<div className="text-[10.5px] text-[--color-fg-subtle]">{t.modelDetail}</div>
</div>
<ul className="mt-4 space-y-1.5 text-[12.5px] text-[--color-fg-muted]">
{t.features.map((f) => (
<li key={f}> {f}</li>

View File

@@ -1,4 +1,11 @@
export const metadata = { title: 'Privacy — BuildMyMCPServer' };
import { pageMetadata } from '@/lib/seo';
export const metadata = pageMetadata({
title: 'Privacy',
description:
'BuildMyMCPServer privacy policy — what data we collect, how it is used, and the rights you have over it.',
path: '/privacy',
});
const SECTIONS = [
{
@@ -29,11 +36,21 @@ const SECTIONS = [
{
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).',
"Anthropic, USA (Claude AI — used for prompt analysis and code generation on Pro / Team / Enterprise tiers). Only the prompt text and resulting spec are sent. Anthropic's data-retention policy applies.",
'Zhipu AI, China (GLM model — used for prompt analysis on the free Hobby tier only). Only the prompt text and resulting spec are sent. Upgrade to a paid tier to keep all AI processing within Anthropic (US).',
'Stripe Payments Europe Ltd., Ireland (billing, invoicing, payment processing, automatic VAT). Stripe receives: email, billing address, payment method details. Card numbers are tokenised by Stripe and never reach our servers. Stripe is GDPR-compliant and Swiss-DSG-aligned via the EU-Swiss adequacy decision.',
'Hetzner, Germany (compute, Postgres, Redis, runner containers).',
'Backblaze, EU (encrypted backups).',
'Cloudflare (DNS + DDoS protection + TLS termination).',
],
},
{
h: 'AI processing per tier',
p: [
'Hobby (free): prompts are sent to Zhipu AI (GLM, China) for analysis. Choose a paid tier if your prompts contain data that must not leave the EU/US.',
'Pro: prompts are sent to Anthropic (Claude Haiku 4.5, USA).',
'Team: prompts are sent to Anthropic (Claude Sonnet 4.6, USA).',
'Enterprise: Anthropic (Claude Sonnet + Opus, USA) with EU-data-residency opt-in available on request.',
],
},
{
@@ -91,7 +108,10 @@ export default function Privacy() {
<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">
<a
className="text-[--color-accent] underline"
href="mailto:privacy@buildmymcpserver.com"
>
privacy@buildmymcpserver.com
</a>{' '}
for any of the above.

View File

@@ -1,7 +1,13 @@
import Link from 'next/link';
import { CodeBlock } from '@/components/code-block';
import { pageMetadata } from '@/lib/seo';
import Link from 'next/link';
export const metadata = { title: 'Security — BuildMyMCPServer' };
export const metadata = pageMetadata({
title: 'Security',
description:
'How BuildMyMCPServer secures your MCP servers — per-server Docker isolation, AES-256-GCM encrypted secrets, OAuth 2.1 and a hardened control plane.',
path: '/security',
});
const PILLARS = [
{
@@ -18,7 +24,7 @@ const PILLARS = [
},
{
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.',
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',
@@ -34,7 +40,11 @@ const PILLARS = [
},
{
title: 'Rate limiting',
body: 'Default 100 requests/min/IP per tool, enforced at the Traefik layer before traffic ever reaches your container.',
body: 'Default 100 requests/min/IP per tool, enforced at the Traefik layer before traffic ever reaches your container. Daily preview + build caps per tier protect against runaway LLM spend.',
},
{
title: 'AI provider by tier — transparent',
body: "Hobby (free) tier uses Zhipu's GLM model (servers in China) for prompt analysis — chosen for cost so we can offer a real free tier. Pro, Team and Enterprise use Anthropic Claude (US). Enterprise can request EU-only data residency. The provider is shown live in the wizard so you always know where your prompt is going.",
},
];
@@ -49,8 +59,8 @@ export default function Security() {
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.
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>
@@ -67,7 +77,10 @@ export default function Security() {
<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">
<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

View File

@@ -0,0 +1,14 @@
import { pageMetadata } from '@/lib/seo';
// status/page.tsx is a client component and cannot export metadata itself —
// this layout carries it.
export const metadata = pageMetadata({
title: 'Status',
description:
'Live operational status of BuildMyMCPServer — control plane, build pipeline and hosted MCP servers.',
path: '/status',
});
export default function StatusLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -1,4 +1,11 @@
export const metadata = { title: 'Terms — BuildMyMCPServer' };
import { pageMetadata } from '@/lib/seo';
export const metadata = pageMetadata({
title: 'Terms',
description:
'BuildMyMCPServer terms of service — the agreement that governs use of the platform.',
path: '/terms',
});
const SECTIONS = [
{
@@ -19,7 +26,7 @@ const SECTIONS = [
},
{
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.',
p: 'Free, Pro and Team plans are provided on a best-effort basis with no guaranteed uptime SLA. Enterprise availability commitments are negotiated separately by contract.',
},
{
h: '6. Billing',

View File

@@ -0,0 +1,178 @@
'use client';
import { useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
interface KeyRow {
version: number;
active: boolean;
createdAt: string;
retiredAt: string | null;
}
interface Status {
activeVersion: number | null;
keyCount: number;
secretCount: number;
legacySecretCount: number;
keys: KeyRow[];
}
export default function AdminEncryptionPage() {
const [status, setStatus] = useState<Status | null>(null);
const [rotating, setRotating] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function reload() {
setStatus(await apiFetch<Status>('/v1/admin/encryption'));
}
useEffect(() => {
reload();
}, []);
async function rotate() {
if (
!confirm(
'Rotate the encryption key?\n\nA fresh Data Encryption Key is generated and EVERY stored secret is re-encrypted under it in one transaction. The environment KEK is untouched. This is safe to run any time you suspect key compromise.',
)
) {
return;
}
setRotating(true);
setMessage(null);
try {
const r = await apiFetch<{ newVersion: number; reEncrypted: number }>(
'/v1/admin/encryption/rotate',
{ method: 'POST', body: '{}' },
);
setMessage(`Rotated to key v${r.newVersion}${r.reEncrypted} secret(s) re-encrypted.`);
await reload();
} catch (e) {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setMessage(`Rotation failed: ${detail?.detail ?? detail?.error ?? (e as Error).message}`);
} finally {
setRotating(false);
}
}
return (
<div className="px-8 py-8">
<header className="mb-6">
<h1 className="text-[22px] font-semibold tracking-tight">Encryption</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Envelope encryption for customer secrets. The KEK lives only in the environment;
Data Encryption Keys are stored wrapped and rotated here.
</p>
</header>
{!status && <div className="mono text-[12px] text-[--color-fg-muted]">Loading</div>}
{status && (
<>
<div className="grid gap-3 md:grid-cols-3">
<Card label="Active key" value={status.activeVersion ? `v${status.activeVersion}` : '—'} />
<Card label="Secrets encrypted" value={status.secretCount.toLocaleString()} />
<Card
label="Legacy (pre-envelope)"
value={status.legacySecretCount.toLocaleString()}
sub={
status.legacySecretCount > 0
? 'rotate once to migrate them onto a DEK'
: 'all on a managed DEK'
}
/>
</div>
<div className="panel mt-6 p-4">
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-[14px] font-semibold tracking-tight">Rotate encryption key</h2>
<p className="mt-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
Generates a new DEK and re-encrypts all {status.secretCount} secret(s) under it
atomically. The environment KEK is never exposed or changed.
</p>
</div>
<Button variant="primary" size="md" onClick={rotate} disabled={rotating}>
{rotating ? 'Rotating…' : 'Rotate key'}
</Button>
</div>
{message && (
<p
className={cn(
'mt-3 text-[12.5px]',
message.startsWith('Rotation failed')
? 'text-[--color-danger]'
: 'text-emerald-300',
)}
>
{message}
</p>
)}
</div>
<div className="mt-6">
<h2 className="text-[14px] font-semibold tracking-tight">Key history</h2>
<div className="panel mt-3">
<table className="w-full text-[12.5px]">
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
<tr>
<th className="px-4 py-2 text-left font-medium">Version</th>
<th className="px-4 py-2 text-left font-medium">Status</th>
<th className="px-4 py-2 text-left font-medium">Created</th>
<th className="px-4 py-2 text-left font-medium">Retired</th>
</tr>
</thead>
<tbody>
{status.keys.map((k) => (
<tr key={k.version} className="border-b border-[--color-border] last:border-0">
<td className="px-4 py-2.5 mono">v{k.version}</td>
<td className="px-4 py-2.5">
<span
className={cn(
'mono rounded-full border px-2 py-0.5 text-[11px]',
k.active
? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300'
: 'border-[--color-border] bg-[--color-bg-subtle] text-[--color-fg-subtle]',
)}
>
{k.active ? 'active' : 'retired'}
</span>
</td>
<td className="px-4 py-2.5 mono text-[--color-fg-muted]">
{new Date(k.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2.5 mono text-[--color-fg-muted]">
{k.retiredAt ? new Date(k.retiredAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<p className="mt-6 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
How it works: a 32-byte Data Encryption Key (DEK) is generated, AES-256-GCM encrypted
with the environment Key Encryption Key (KEK = SECRETS_ENCRYPTION_KEY), and stored
wrapped. Secrets are encrypted with the DEK. Rotation mints a fresh DEK, re-encrypts
every secret, and retires the old one recoverable from a suspected DEK compromise
without ever touching the KEK.
</p>
</>
)}
</div>
);
}
function Card({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{label}</div>
<div className="mt-1.5 text-[24px] font-semibold tabular-nums tracking-tight">{value}</div>
{sub && <div className="mt-1 text-[12px] text-[--color-fg-muted]">{sub}</div>}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import {
LayoutGrid,
LifeBuoy,
Users,
Building2,
Server,
@@ -15,6 +16,7 @@ import {
LogOut,
ShieldAlert,
Package,
KeyRound,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
@@ -28,6 +30,7 @@ interface MeUser {
const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
{ href: '/admin', label: 'Overview', icon: LayoutGrid },
{ href: '/admin/support', label: 'Support', icon: LifeBuoy },
{ href: '/admin/users', label: 'Users', icon: Users },
{ href: '/admin/orgs', label: 'Organizations', icon: Building2 },
{ href: '/admin/servers', label: 'MCP servers', icon: Server },
@@ -35,6 +38,7 @@ const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: num
{ href: '/admin/builds', label: 'Builds', icon: Hammer },
{ href: '/admin/audit', label: 'Audit log', icon: FileClock },
{ href: '/admin/system', label: 'System health', icon: Activity },
{ href: '/admin/encryption', label: 'Encryption', icon: KeyRound },
{ href: '/admin/prompt', label: 'AI prompt', icon: Wand2 },
];
@@ -43,6 +47,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter();
const [user, setUser] = useState<MeUser | null>(null);
const [authState, setAuthState] = useState<'checking' | 'ok' | 'forbidden'>('checking');
const [supportPending, setSupportPending] = useState(0);
useEffect(() => {
if (pathname === '/admin/login') {
@@ -61,6 +66,26 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
.catch(() => setAuthState('forbidden'));
}, [pathname]);
// Poll support count every 30s so the sidebar badge stays fresh while admin
// is doing other work in the panel.
useEffect(() => {
if (authState !== 'ok' || pathname === '/admin/login') return;
let cancelled = false;
const load = () => {
apiFetch<{ awaitingAdmin: number }>('/v1/admin/support/counts')
.then((r) => {
if (!cancelled) setSupportPending(r.awaitingAdmin);
})
.catch(() => undefined);
};
load();
const t = setInterval(load, 30_000);
return () => {
cancelled = true;
clearInterval(t);
};
}, [authState, pathname]);
useEffect(() => {
if (authState === 'forbidden' && pathname !== '/admin/login') {
router.replace('/admin/login');
@@ -124,7 +149,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
)}
>
<Icon size={13} />
{item.label}
<span className="flex-1">{item.label}</span>
{item.href === '/admin/support' && supportPending > 0 && (
<span className="mono inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500/30 px-1 text-[10px] font-semibold text-amber-300">
{supportPending}
</span>
)}
</Link>
</li>
);

View File

@@ -0,0 +1,197 @@
'use client';
import { Textarea } from '@/components/input';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
interface Ticket {
id: string;
subject: string;
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
guestEmail: string | null;
createdAt: string;
lastMessageAt: string;
}
interface Message {
id: string;
authorIsAdmin: boolean;
body: string;
createdAt: string;
}
interface Detail {
ticket: Ticket;
userEmail: string | null;
userName: string | null;
messages: Message[];
}
export default function AdminTicketDetail() {
const params = useParams<{ id: string }>();
const [data, setData] = useState<Detail | null>(null);
const [reply, setReply] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function load() {
if (!params?.id) return;
apiFetch<Detail>(`/v1/admin/support/tickets/${params.id}`)
.then(setData)
.catch((e) => setError((e as Error).message));
}
useEffect(load, [params?.id]);
async function sendReply(e: React.FormEvent) {
e.preventDefault();
if (!params?.id || reply.trim().length === 0) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/v1/admin/support/tickets/${params.id}/messages`, {
method: 'POST',
body: JSON.stringify({ body: reply }),
});
setReply('');
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
async function setStatus(status: Ticket['status']) {
if (!params?.id) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/v1/admin/support/tickets/${params.id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
});
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
if (!data && !error) {
return (
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
</div>
);
}
if (error || !data) {
return (
<div className="mx-auto max-w-3xl px-6 py-12">
<p className="text-[13px] text-[--color-danger]">{error ?? 'Ticket not found.'}</p>
<Link href="/admin/support" className="mt-3 inline-block text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
Back to tickets
</Link>
</div>
);
}
const { ticket, messages, userEmail, userName } = data;
const fromLabel = userEmail
? `${userName ? `${userName} · ` : ''}${userEmail}`
: ticket.guestEmail
? `${ticket.guestEmail} (guest)`
: 'unknown';
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<Link
href="/admin/support"
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
All tickets
</Link>
<div className="mt-3 flex items-baseline justify-between gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-[22px] font-semibold tracking-tight">{ticket.subject}</h1>
<p className="mt-1 text-[12px] text-[--color-fg-muted]">From: {fromLabel}</p>
</div>
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
{ticket.status.replace('_', ' ')}
</span>
</div>
<div className="mt-6 space-y-3">
{messages.map((m) => (
<div
key={m.id}
className={`panel p-4 ${m.authorIsAdmin ? 'border-[--color-accent]/40' : ''}`}
>
<div className="flex items-baseline justify-between">
<span
className={`text-[11.5px] font-medium ${m.authorIsAdmin ? 'text-[--color-accent]' : 'text-[--color-fg]'}`}
>
{m.authorIsAdmin ? 'Admin' : 'User'}
</span>
<span className="text-[10.5px] text-[--color-fg-subtle]">
{new Date(m.createdAt).toLocaleString()}
</span>
</div>
<p className="mt-2 whitespace-pre-wrap text-[13px] leading-relaxed text-[--color-fg-muted]">
{m.body}
</p>
</div>
))}
</div>
<form onSubmit={sendReply} className="panel mt-6 space-y-3 p-4">
<Textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
rows={4}
maxLength={10_000}
placeholder="Reply to user…"
/>
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="flex items-center justify-between gap-2">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setStatus('closed')}
disabled={busy || ticket.status === 'closed'}
>
Mark closed
</Button>
{ticket.status === 'closed' && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setStatus('awaiting_admin')}
disabled={busy}
>
Reopen
</Button>
)}
</div>
<Button
variant="primary"
size="md"
type="submit"
disabled={busy || reply.trim().length === 0}
>
{busy ? 'Sending…' : 'Send reply'}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface AdminTicketRow {
ticket: {
id: string;
subject: string;
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
guestEmail: string | null;
createdAt: string;
lastMessageAt: string;
};
userEmail: string | null;
userName: string | null;
}
const STATUS_BADGE: Record<AdminTicketRow['ticket']['status'], string> = {
awaiting_admin: 'bg-amber-500/20 text-amber-300',
awaiting_user: 'bg-emerald-500/20 text-emerald-300',
closed: 'bg-[--color-bg-subtle] text-[--color-fg-subtle]',
};
export default function AdminSupport() {
const [rows, setRows] = useState<AdminTicketRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<'all' | 'awaiting_admin' | 'awaiting_user' | 'closed'>(
'awaiting_admin',
);
useEffect(() => {
apiFetch<{ tickets: AdminTicketRow[] }>('/v1/admin/support/tickets')
.then((r) => setRows(r.tickets))
.catch((e) => setError((e as Error).message));
}, []);
const filtered = (rows ?? []).filter(
(r) => filter === 'all' || r.ticket.status === filter,
);
return (
<div className="mx-auto max-w-6xl px-6 py-8">
<div className="flex items-baseline justify-between">
<div>
<Link
href="/admin"
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
>
Admin
</Link>
<h1 className="mt-1 text-[22px] font-semibold tracking-tight">Support tickets</h1>
</div>
<div className="flex gap-1">
{(['awaiting_admin', 'awaiting_user', 'closed', 'all'] as const).map((s) => (
<button
type="button"
key={s}
onClick={() => setFilter(s)}
className={`rounded-md px-2.5 py-1 text-[11.5px] transition-colors ${
filter === s
? 'bg-[--color-bg-subtle] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
}`}
>
{s.replace('_', ' ')}
</button>
))}
</div>
</div>
{error && <p className="mt-4 text-[12.5px] text-[--color-danger]">{error}</p>}
<div className="panel mt-6 overflow-hidden">
{rows === null && (
<div className="p-6 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={18} />
</div>
)}
{rows && filtered.length === 0 && (
<div className="p-6 text-center text-[13px] text-[--color-fg-muted]">
No tickets in this view.
</div>
)}
{rows && filtered.length > 0 && (
<ul className="divide-y divide-[--color-border]">
{filtered.map((r) => {
const from = r.userEmail
? `${r.userName ? `${r.userName} · ` : ''}${r.userEmail}`
: r.ticket.guestEmail
? `${r.ticket.guestEmail} (guest)`
: 'unknown';
return (
<li key={r.ticket.id}>
{/* Whole row is the link — table-style layout via flex
so any pixel inside is clickable, not just the subject. */}
<Link
href={`/admin/support/${r.ticket.id}`}
className="grid grid-cols-[1fr_auto_auto] items-center gap-3 px-4 py-3 transition-colors hover:bg-[--color-bg-subtle]"
>
<div className="min-w-0">
<div className="truncate text-[13px] font-medium text-[--color-fg]">
{r.ticket.subject}
</div>
<div className="mono mt-0.5 truncate text-[11.5px] text-[--color-fg-subtle]">
{from}
</div>
</div>
<span
className={`mono shrink-0 rounded-full px-2 py-0.5 text-[10.5px] ${STATUS_BADGE[r.ticket.status]}`}
>
{r.ticket.status.replace('_', ' ')}
</span>
<span className="shrink-0 whitespace-nowrap text-[11px] text-[--color-fg-muted]">
{new Date(r.ticket.lastMessageAt).toLocaleString()}
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}

View File

@@ -1,11 +1,14 @@
import { ImageResponse } from 'next/og';
// Edge runtime — see opengraph-image.tsx: avoids the next/og fileURLToPath
// crash during a Node-runtime prerender (notably on Windows builds).
export const runtime = 'edge';
export const size = { width: 180, height: 180 };
export const contentType = 'image/png';
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
@@ -17,12 +20,7 @@ export default function AppleIcon() {
justifyContent: 'center',
}}
>
<svg
width="120"
height="120"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<svg width="120" height="120" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<title>BuildMyMCPServer</title>
<path
d="M8.5 22.5V9.5L16 16L23.5 9.5V22.5"
@@ -33,8 +31,7 @@ export default function AppleIcon() {
fill="none"
/>
</svg>
</div>
),
</div>,
{ ...size },
);
}

View File

@@ -6,12 +6,26 @@ import {
DocsCode,
Mono,
} from '@/components/docs-page';
import { JsonLd } from '@/components/json-ld';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
export const metadata = { title: 'API reference — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'API reference',
description:
'REST API reference for the BuildMyMCPServer control plane — auth, server CRUD, build streaming, templates and the OAuth 2.1 endpoints.',
path: '/docs/api-reference',
});
export default function ApiReference() {
return (
<>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Docs', path: '/docs' },
{ name: 'API reference', path: '/docs/api-reference' },
])}
/>
<DocsTitle kicker="Reference">API reference</DocsTitle>
<DocsLead>
Every endpoint on the control plane. Authenticated routes use the session cookie set by
@@ -60,7 +74,7 @@ export default function ApiReference() {
<DocsH2 id="oauth">OAuth (clients of generated servers, not dashboard)</DocsH2>
<DocsP>
<Mono>GET /oauth/.well-known/oauth-authorization-server</Mono> RFC 8414 metadata.
<Mono>GET /.well-known/oauth-authorization-server/oauth</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>

View File

@@ -8,12 +8,26 @@ import {
DocsCode,
Mono,
} from '@/components/docs-page';
import { JsonLd } from '@/components/json-ld';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
export const metadata = { title: 'Authoring tools — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'Authoring tools',
description:
'How to write prompts that generate good MCP tools — naming, input schemas, credentials, and how the generated TypeScript is checked before it ships.',
path: '/docs/authoring',
});
export default function Authoring() {
return (
<>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Docs', path: '/docs' },
{ name: 'Authoring tools', path: '/docs/authoring' },
])}
/>
<DocsTitle kicker="Build">Authoring tools</DocsTitle>
<DocsLead>
What you write in the prompt is what Claude turns into TypeScript. Better prompts mean

View File

@@ -8,12 +8,26 @@ import {
DocsCode,
Mono,
} from '@/components/docs-page';
import { JsonLd } from '@/components/json-ld';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
export const metadata = { title: 'MCP concepts — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'MCP concepts',
description:
'What Model Context Protocol is: tools, resources and prompts, the Streamable HTTP transport, and how a client discovers and calls a server.',
path: '/docs/concepts',
});
export default function Concepts() {
return (
<>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Docs', path: '/docs' },
{ name: 'MCP concepts', path: '/docs/concepts' },
])}
/>
<DocsTitle kicker="Get started">MCP concepts</DocsTitle>
<DocsLead>
Model Context Protocol is an open standard from Anthropic for connecting AI assistants to

View File

@@ -1,6 +1,13 @@
import { DocsTitle, DocsLead, DocsH2, DocsP, Mono } from '@/components/docs-page';
import { JsonLd } from '@/components/json-ld';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
export const metadata = { title: 'FAQ — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'Docs FAQ',
description:
'Answers on generated-code safety, secrets handling, build failures, quotas and self-hosting for BuildMyMCPServer.',
path: '/docs/faq',
});
const ITEMS: { q: string; a: React.ReactNode }[] = [
{
@@ -56,6 +63,13 @@ const ITEMS: { q: string; a: React.ReactNode }[] = [
export default function Faq() {
return (
<>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Docs', path: '/docs' },
{ name: 'FAQ', path: '/docs/faq' },
])}
/>
<DocsTitle kicker="Reference">FAQ</DocsTitle>
<DocsLead>Common questions, direct answers.</DocsLead>
<div className="space-y-7">

View File

@@ -1,5 +1,13 @@
import Link from 'next/link';
import { Logo } from '@/components/logo';
import { pageMetadata } from '@/lib/seo';
import Link from 'next/link';
export const metadata = pageMetadata({
title: 'Docs',
description:
'BuildMyMCPServer documentation — quickstart, MCP concepts, the OAuth 2.1 flow, authoring tools, self-hosting and the API reference.',
path: '/docs',
});
const SECTIONS: { heading: string; items: { href: string; label: string }[] }[] = [
{
@@ -54,8 +62,8 @@ export default function DocsLayout({ children }: { children: React.ReactNode })
</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">
<div className="mx-auto flex w-full max-w-6xl flex-1 gap-8 px-5 py-8 sm:px-6 sm:py-10 lg:gap-12">
<aside className="hidden w-[240px] shrink-0 lg:block">
<nav className="sticky top-20 space-y-5">
{SECTIONS.map((section) => (
<div key={section.heading}>
@@ -78,7 +86,7 @@ export default function DocsLayout({ children }: { children: React.ReactNode })
))}
</nav>
</aside>
<article className="prose prose-invert max-w-2xl flex-1">{children}</article>
<article className="prose prose-invert min-w-0 max-w-2xl flex-1">{children}</article>
</div>
</div>
);

View File

@@ -8,12 +8,26 @@ import {
DocsCode,
Mono,
} from '@/components/docs-page';
import { JsonLd } from '@/components/json-ld';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
export const metadata = { title: 'OAuth 2.1 flow — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'OAuth 2.1 flow',
description:
'How every generated MCP server is protected: OAuth 2.1 with PKCE, Dynamic Client Registration (RFC 7591) and Resource Indicators (RFC 8707), walked through request by request.',
path: '/docs/oauth',
});
export default function OAuthDocs() {
return (
<>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Docs', path: '/docs' },
{ name: 'OAuth 2.1 flow', path: '/docs/oauth' },
])}
/>
<DocsTitle kicker="Auth">OAuth 2.1 flow</DocsTitle>
<DocsLead>
Every generated server is an OAuth 2.1 Resource Server. The control plane is the
@@ -24,8 +38,8 @@ export default function OAuthDocs() {
<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 8414 Authorization Server Metadata at <Mono>/.well-known/oauth-authorization-server/oauth</Mono></DocsLi>
<DocsLi>RFC 9728 Protected Resource Metadata at <Mono>/.well-known/oauth-protected-resource/&lt;server-path&gt;</Mono></DocsLi>
<DocsLi>RFC 8707 Resource Indicators (audience binding)</DocsLi>
<DocsLi>RFC 7591 Dynamic Client Registration</DocsLi>
</DocsList>
@@ -41,7 +55,7 @@ export default function OAuthDocs() {
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"
www-authenticate: Bearer resource_metadata="http://localhost:4103/.well-known/oauth-protected-resource/mcp"
content-type: application/json
{"error":"unauthorized"}`}
@@ -53,10 +67,10 @@ content-type: application/json
</DocsP>
<DocsCode
label="step 2 — resource metadata"
code={`$ curl http://localhost:4103/.well-known/oauth-protected-resource
code={`$ curl http://localhost:4103/.well-known/oauth-protected-resource/mcp
{
"resource": "http://localhost:4103",
"resource": "http://localhost:4103/mcp",
"authorization_servers": ["http://localhost:4000/oauth"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["mcp:read"]
@@ -74,7 +88,7 @@ content-type: application/json
"client_name": "Claude Desktop",
"redirect_uris": ["claude://oauth/callback"],
"token_endpoint_auth_method": "none",
"resource": "http://localhost:4103"
"resource": "http://localhost:4103/mcp"
}
201 Created
@@ -94,7 +108,7 @@ content-type: application/json
"code_verifier": "riSU-w1DT…",
"client_id": "bmm_8aee2fe0…",
"redirect_uri": "claude://oauth/callback",
"resource": "http://localhost:4103"
"resource": "http://localhost:4103/mcp"
}
200 OK
@@ -109,7 +123,7 @@ content-type: application/json
<DocsP>
Subsequent <Mono>/mcp</Mono> calls carry the JWT. The runner verifies the signature
against the AS&apos;s JWKS, checks the <Mono>iss</Mono>, the <Mono>aud</Mono>
(RFC 8707 must match the runner&apos;s own public URL), and the expiry. No token
(RFC 8707 must match the runner&apos;s MCP resource URL), and the expiry. No token
passthrough; the runner never forwards the client&apos;s token to a downstream API.
</DocsP>

View File

@@ -9,8 +9,14 @@ import {
DocsCode,
Mono,
} from '@/components/docs-page';
import { pageMetadata } from '@/lib/seo';
export const metadata = { title: 'Quickstart — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'Quickstart',
description:
'From first prompt to a live OAuth-protected MCP server in five minutes — sign in, describe your tool, confirm the plan, watch the build stream, install in your client.',
path: '/docs',
});
export default function Quickstart() {
return (

View File

@@ -8,12 +8,26 @@ import {
DocsCode,
Mono,
} from '@/components/docs-page';
import { JsonLd } from '@/components/json-ld';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
export const metadata = { title: 'Self-hosting — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'Self-hosting',
description:
'Run the BuildMyMCPServer control plane yourself — bring your own Postgres, Redis and Docker host, plus the production sandboxing flags for generated containers.',
path: '/docs/self-hosting',
});
export default function SelfHosting() {
return (
<>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Docs', path: '/docs' },
{ name: 'Self-hosting', path: '/docs/self-hosting' },
])}
/>
<DocsTitle kicker="Build">Self-hosting</DocsTitle>
<DocsLead>
The control plane and generator are open. Bring your own Postgres, Redis, Docker host and

View File

@@ -0,0 +1,54 @@
import { articlesNewestFirst } from '@/lib/articles';
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from '@/lib/seo';
export const dynamic = 'force-static';
function escapeXml(s: string): string {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
export function GET(): Response {
const articles = articlesNewestFirst();
const lastBuildDate = new Date(
articles[0]?.dateModified ?? articles[0]?.datePublished ?? '2026-05-31',
).toUTCString();
const items = articles
.map((a) => {
const url = `${SITE_URL}/guides/${a.slug}`;
return ` <item>
<title>${escapeXml(a.title)}</title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<description>${escapeXml(a.description)}</description>
<pubDate>${new Date(a.datePublished).toUTCString()}</pubDate>
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(`${SITE_NAME} — MCP guides`)}</title>
<link>${SITE_URL}/guides</link>
<atom:link href="${SITE_URL}/feed.xml" rel="self" type="application/rss+xml"/>
<description>${escapeXml(SITE_DESCRIPTION)}</description>
<language>en</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
${items}
</channel>
</rss>
`;
return new Response(xml, {
headers: {
'content-type': 'application/rss+xml; charset=utf-8',
'cache-control': 'public, max-age=3600',
},
});
}

Some files were not shown because too many files have changed in this diff Show More