Compare commits
94 Commits
9cce4a94c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4f0db5719 | ||
|
|
a08f5f05b1 | ||
|
|
089074d104 | ||
|
|
17056d0b30 | ||
|
|
cba45402ce | ||
|
|
3dc65e4f4d | ||
|
|
2a12ea18cd | ||
|
|
be02600759 | ||
|
|
ee4713f82c | ||
|
|
bd82a67fba | ||
|
|
7eb323e8f8 | ||
|
|
74ca59b8b7 | ||
|
|
4d717d877f | ||
|
|
4687c8be52 | ||
|
|
1349dc1dc0 | ||
|
|
21a5cf5762 | ||
|
|
cf423de3d5 | ||
|
|
9d5386ccba | ||
|
|
092290bb38 | ||
|
|
29e699dc74 | ||
|
|
31bfeed9dd | ||
|
|
ec819082a6 | ||
|
|
147ba69968 | ||
|
|
b421457010 | ||
|
|
44cebc9fd8 | ||
|
|
0c6d738a6b | ||
|
|
b930a454e8 | ||
|
|
4d136c4fb2 | ||
|
|
1d845abf92 | ||
|
|
86cf89ef42 | ||
|
|
d2b19a5439 | ||
|
|
979d1abfca | ||
|
|
5a8e736113 | ||
|
|
1093dc40a7 | ||
|
|
3a05766f88 | ||
|
|
e75f9ad4fe | ||
|
|
7a32385e2b | ||
|
|
05746e13e6 | ||
|
|
b464b5640f | ||
|
|
438ce3cfbc | ||
|
|
6197ee7f5e | ||
|
|
035e55f00c | ||
|
|
6f8b8da151 | ||
|
|
0cf9c66b6b | ||
|
|
e4e437c44c | ||
|
|
22ba23f353 | ||
|
|
fd147f9998 | ||
|
|
591a1cb575 | ||
|
|
2267daadd4 | ||
|
|
9f1135325c | ||
|
|
00c6692c7a | ||
|
|
f80bd8afbe | ||
|
|
a8e6f4fabd | ||
|
|
c656bd3189 | ||
|
|
d0f3c202eb | ||
|
|
8c6f04f034 | ||
|
|
e9827b1f77 | ||
|
|
1cccdbdff1 | ||
|
|
091454d273 | ||
|
|
b248adf5c0 | ||
|
|
aa79a71357 | ||
|
|
f8af3fc0fd | ||
|
|
1c58977596 | ||
|
|
1b8f61df5f | ||
|
|
20910f5466 | ||
|
|
ef30baf52a | ||
|
|
c2a21fc3cd | ||
|
|
defb4186b4 | ||
|
|
bc174c1302 | ||
|
|
66128c73d8 | ||
|
|
389446ea16 | ||
|
|
dc5bbaa0ae | ||
|
|
083b6e5d41 | ||
|
|
e198d44e1e | ||
|
|
5d0d5668d8 | ||
|
|
88c7262a08 | ||
|
|
2e5bf5b44b | ||
|
|
cc3c5ad444 | ||
|
|
f5107922a0 | ||
|
|
36a1adf4d7 | ||
|
|
b843394d0f | ||
|
|
617886352c | ||
|
|
cd428d5ba3 | ||
|
|
390cf5e8a1 | ||
|
|
c016bf237b | ||
|
|
a288179954 | ||
|
|
c7e6537c64 | ||
|
|
a54f6218a7 | ||
|
|
e46a9a1cf8 | ||
|
|
8a7ffe673d | ||
|
|
2b098c5d33 | ||
|
|
38aa5875d3 | ||
|
|
a68e882092 | ||
|
|
8d47b20ae5 |
34
.dockerignore
Normal file
34
.dockerignore
Normal 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
|
||||||
27
.env.example
27
.env.example
@@ -1,5 +1,8 @@
|
|||||||
# ---- Core ----
|
# ---- Core ----
|
||||||
NODE_ENV=development
|
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 ----
|
||||||
DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm
|
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
|
BETTER_AUTH_URL=http://localhost:3001
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
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_ID=
|
||||||
GITHUB_OAUTH_SECRET=
|
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 ----
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
|||||||
97
.env.production.example
Normal file
97
.env.production.example
Normal 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=
|
||||||
36
.gitea/workflows/deploy.yml
Normal file
36
.gitea/workflows/deploy.yml
Normal 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
236
DEPLOY.md
Normal 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 | `4400–4900` | 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 4400–4900. 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
35
apps/api/Dockerfile
Normal 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"]
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "tsx src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"fastify": "5.2.0",
|
"fastify": "5.2.0",
|
||||||
"ioredis": "5.4.1",
|
"ioredis": "5.4.1",
|
||||||
"jose": "5.9.6",
|
"jose": "5.9.6",
|
||||||
|
"stripe": "^22.1.1",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const Env = z.object({
|
|||||||
NEXT_PUBLIC_APP_URL: z.string().default('http://localhost:3001'),
|
NEXT_PUBLIC_APP_URL: z.string().default('http://localhost:3001'),
|
||||||
OAUTH_KEY_DIR: z.string().default('./keys'),
|
OAUTH_KEY_DIR: z.string().default('./keys'),
|
||||||
ANTHROPIC_API_KEY: z.string().optional(),
|
ANTHROPIC_API_KEY: z.string().optional(),
|
||||||
|
GLM_API_KEY: z.string().optional(),
|
||||||
SECRETS_ENCRYPTION_KEY: z
|
SECRETS_ENCRYPTION_KEY: z
|
||||||
.string()
|
.string()
|
||||||
.min(64, '32 bytes hex required')
|
.min(64, '32 bytes hex required')
|
||||||
@@ -16,6 +17,27 @@ const Env = z.object({
|
|||||||
ADMIN_EMAIL: z.string().email().optional(),
|
ADMIN_EMAIL: z.string().email().optional(),
|
||||||
ADMIN_PASSWORD: z.string().min(8).optional(),
|
ADMIN_PASSWORD: z.string().min(8).optional(),
|
||||||
ADMIN_NAME: z.string().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({
|
export const config = Env.parse({
|
||||||
@@ -26,11 +48,27 @@ export const config = Env.parse({
|
|||||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||||
OAUTH_KEY_DIR: process.env.OAUTH_KEY_DIR,
|
OAUTH_KEY_DIR: process.env.OAUTH_KEY_DIR,
|
||||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||||
|
GLM_API_KEY: process.env.GLM_API_KEY,
|
||||||
SECRETS_ENCRYPTION_KEY: process.env.SECRETS_ENCRYPTION_KEY,
|
SECRETS_ENCRYPTION_KEY: process.env.SECRETS_ENCRYPTION_KEY,
|
||||||
CONTROL_PLANE_PUBLIC_URL: process.env.CONTROL_PLANE_PUBLIC_URL,
|
CONTROL_PLANE_PUBLIC_URL: process.env.CONTROL_PLANE_PUBLIC_URL,
|
||||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
|
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
|
||||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
|
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
|
||||||
ADMIN_NAME: process.env.ADMIN_NAME,
|
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.
|
// INFRA-001: refuse to boot in production with the placeholder encryption key.
|
||||||
|
|||||||
@@ -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 { 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 { config } from './config.js';
|
||||||
import { authRoutes } from './routes/auth.js';
|
import { ensureActiveKey } from './lib/crypto.js';
|
||||||
import { serverRoutes } from './routes/servers.js';
|
import { validateStripePriceConfig } from './lib/stripe.js';
|
||||||
import { oauthRoutes } from './routes/oauth.js';
|
import { accountRoutes } from './routes/account.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
|
||||||
import { adminRoutes } from './routes/admin.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';
|
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({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
level: config.NODE_ENV === 'production' ? 'info' : 'debug',
|
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, {
|
await app.register(cors, {
|
||||||
origin: [config.NEXT_PUBLIC_APP_URL],
|
origin: [config.NEXT_PUBLIC_APP_URL],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -26,12 +85,30 @@ await app.register(websocket, { options: { maxPayload: 1024 * 1024 } });
|
|||||||
|
|
||||||
app.get('/health', async () => ({ ok: true, ts: Date.now() }));
|
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(authRoutes);
|
||||||
await app.register(serverRoutes);
|
await app.register(serverRoutes);
|
||||||
await app.register(oauthRoutes);
|
await app.register(oauthRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
await app.register(templateRoutes);
|
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)
|
// Bootstrap admin user from env (idempotent)
|
||||||
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {
|
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {
|
||||||
|
|||||||
@@ -1,30 +1,195 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { count, createDb, desc, eq, encryptionKeys, secrets, sql } from '@bmm/db';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
const ALGO = 'aes-256-gcm';
|
const ALGO = 'aes-256-gcm';
|
||||||
|
const db = createDb();
|
||||||
|
|
||||||
function getKey(): Buffer {
|
/**
|
||||||
const hex = config.SECRETS_ENCRYPTION_KEY;
|
* Envelope encryption.
|
||||||
const buf = Buffer.from(hex, 'hex');
|
*
|
||||||
|
* 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) {
|
if (buf.length !== 32) {
|
||||||
throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
|
throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
|
||||||
}
|
}
|
||||||
return buf;
|
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 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 enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||||
const tag = cipher.getAuthTag();
|
const tag = cipher.getAuthTag();
|
||||||
return `${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
|
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('.');
|
const [ivB64, tagB64, encB64] = payload.split('.');
|
||||||
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_secret_payload');
|
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_ciphertext');
|
||||||
const decipher = crypto.createDecipheriv(ALGO, getKey(), Buffer.from(ivB64, 'base64'));
|
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB64, 'base64'));
|
||||||
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
||||||
const dec = Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]);
|
return Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]).toString(
|
||||||
return dec.toString('utf8');
|
'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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
import { spawn } from 'node:child_process';
|
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.
|
* Stop and remove a generated MCP container by container id.
|
||||||
* Resolves regardless of outcome — failures are logged but never blocking.
|
* 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) {
|
if (!containerId || containerId.length < 4) {
|
||||||
return { ok: false, detail: 'invalid_container_id' };
|
return { ok: false, detail: 'invalid_container_id' };
|
||||||
}
|
}
|
||||||
|
if (slug) await removeRunnerMapEntry(slug);
|
||||||
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
|
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
|
||||||
const child = spawn('docker', ['rm', '-f', containerId], {
|
const child = spawn('docker', ['rm', '-f', containerId], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
|||||||
48
apps/api/src/lib/plan.ts
Normal file
48
apps/api/src/lib/plan.ts
Normal 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,
|
||||||
|
};
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { getRedis } from './redis.js';
|
|
||||||
import type { GeneratorSpec } from '@bmm/types';
|
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 {
|
function key(previewId: string): string {
|
||||||
return `preview:${previewId}`;
|
return `preview:${previewId}`;
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import type { Plan } from '@bmm/llm';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { getRedis } from './redis.js';
|
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 {
|
export interface BuildJobData {
|
||||||
buildId: string;
|
buildId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -17,7 +26,14 @@ let queue: Queue<BuildJobData> | null = null;
|
|||||||
|
|
||||||
export function getBuildQueue(): Queue<BuildJobData> {
|
export function getBuildQueue(): Queue<BuildJobData> {
|
||||||
if (!queue) {
|
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;
|
return queue;
|
||||||
}
|
}
|
||||||
|
|||||||
62
apps/api/src/lib/rate-limit.ts
Normal file
62
apps/api/src/lib/rate-limit.ts
Normal 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
30
apps/api/src/lib/sms.ts
Normal 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
109
apps/api/src/lib/stripe.ts
Normal 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_…).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
269
apps/api/src/routes/account.ts
Normal file
269
apps/api/src/routes/account.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
import {
|
||||||
adminSettings,
|
adminSettings,
|
||||||
auditLog,
|
auditLog,
|
||||||
@@ -20,10 +18,13 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from '@bmm/db';
|
} from '@bmm/db';
|
||||||
import { SYSTEM_PROMPT } from '@bmm/llm';
|
import { SYSTEM_PROMPT } from '@bmm/llm';
|
||||||
import { requireAdmin } from '../plugins/session.js';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { getRedis } from '../lib/redis.js';
|
import { z } from 'zod';
|
||||||
import { getBuildQueue } from '../lib/queue.js';
|
|
||||||
import { audit } from '../lib/audit.js';
|
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();
|
const db = createDb();
|
||||||
|
|
||||||
@@ -46,11 +47,26 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
newUsersLast7d,
|
newUsersLast7d,
|
||||||
newServersLast7d,
|
newServersLast7d,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)),
|
db
|
||||||
db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)),
|
.select({ c: count() })
|
||||||
db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)),
|
.from(users)
|
||||||
db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)),
|
.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(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
|
db
|
||||||
.select({ c: count() })
|
.select({ c: count() })
|
||||||
.from(mcpServers)
|
.from(mcpServers)
|
||||||
@@ -80,11 +96,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.groupBy(mcpServers.status);
|
.groupBy(mcpServers.status);
|
||||||
|
|
||||||
// Recent activity from audit log
|
// Recent activity from audit log
|
||||||
const recent = await db
|
const recent = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(15);
|
||||||
.select()
|
|
||||||
.from(auditLog)
|
|
||||||
.orderBy(desc(auditLog.createdAt))
|
|
||||||
.limit(15);
|
|
||||||
|
|
||||||
// Builds in last 24h with status
|
// Builds in last 24h with status
|
||||||
const recentBuilds = await db
|
const recentBuilds = await db
|
||||||
@@ -122,12 +134,20 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const parsed = Query.safeParse(req.query);
|
const parsed = Query.safeParse(req.query);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_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
|
const filtered = parsed.data.search
|
||||||
? rows.filter((u) =>
|
? rows.filter((u) => {
|
||||||
u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) ||
|
const q = parsed.data.search!.toLowerCase();
|
||||||
(u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false),
|
return (
|
||||||
)
|
(u.email?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(u.phone?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(u.name?.toLowerCase().includes(q) ?? false)
|
||||||
|
);
|
||||||
|
})
|
||||||
: rows;
|
: rows;
|
||||||
|
|
||||||
// attach org + server count
|
// attach org + server count
|
||||||
@@ -279,7 +299,12 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
server: mcpServers,
|
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)
|
.from(mcpServers)
|
||||||
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
|
.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);
|
const p = Params.safeParse(req.params);
|
||||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
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' });
|
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
// Get last build's prompt
|
// Get last build's prompt
|
||||||
@@ -356,7 +385,11 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const p = Params.safeParse(req.params);
|
const p = Params.safeParse(req.params);
|
||||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
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' });
|
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||||
|
|
||||||
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
|
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';
|
redisOk = pong === 'PONG';
|
||||||
const q = getBuildQueue();
|
const q = getBuildQueue();
|
||||||
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
|
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
|
||||||
queueDepth =
|
queueDepth = (counts.waiting ?? 0) + (counts.active ?? 0) + (counts.delayed ?? 0);
|
||||||
(counts.waiting ?? 0) +
|
|
||||||
(counts.active ?? 0) +
|
|
||||||
(counts.delayed ?? 0);
|
|
||||||
} catch {
|
} catch {
|
||||||
// remains false
|
// remains false
|
||||||
}
|
}
|
||||||
@@ -565,5 +595,28 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.send({ ok: true });
|
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
|
void inArray; // referenced for future bulk operations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,154 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import crypto from 'node:crypto';
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
import {
|
||||||
consumeMagicLink,
|
consumeMagicLink,
|
||||||
|
consumeSmsCode,
|
||||||
destroySession,
|
destroySession,
|
||||||
getSession,
|
getSession,
|
||||||
issueMagicLink,
|
issueMagicLink,
|
||||||
|
issueSmsCode,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
|
upsertOAuthLogin,
|
||||||
} from '@bmm/auth';
|
} from '@bmm/auth';
|
||||||
import { audit } from '../lib/audit.js';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import { config } from '../config.js';
|
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 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> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.post('/v1/auth/magic-link', async (req, reply) => {
|
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 Body = z.object({ email: z.string().email() });
|
||||||
const parsed = Body.safeParse(req.body);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' });
|
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 {
|
try {
|
||||||
const { token, expiresAt } = await issueMagicLink(parsed.data.email);
|
const { token, expiresAt } = await issueMagicLink(parsed.data.email);
|
||||||
const callbackUrl = `${config.NEXT_PUBLIC_APP_URL}/login/callback?token=${token}`;
|
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}`);
|
app.log.info({ to: parsed.data.email, expiresAt }, `[magic-link] -> ${callbackUrl}`);
|
||||||
console.log(`\n[magic-link] ${parsed.data.email} ->\n ${callbackUrl}\n`);
|
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 });
|
return reply.send({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
app.log.error(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) => {
|
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 Body = z.object({ token: z.string().min(10) });
|
||||||
const parsed = Body.safeParse(req.body);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_token' });
|
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 token = req.cookies[SESSION_COOKIE];
|
||||||
const session = await getSession(token);
|
const session = await getSession(token);
|
||||||
if (!session) return reply.code(401).send({ error: 'unauthorized' });
|
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) => {
|
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 token = req.cookies[SESSION_COOKIE];
|
||||||
const session = token ? await getSession(token) : null;
|
const session = token ? await getSession(token) : null;
|
||||||
if (token) await destroySession(token);
|
if (token) await destroySession(token);
|
||||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
reply.clearCookie(SESSION_COOKIE, sessionCookieOpts());
|
||||||
if (session) {
|
if (session) {
|
||||||
await audit({
|
await audit({
|
||||||
orgId: session.orgId,
|
orgId: session.orgId,
|
||||||
@@ -132,4 +264,294 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
return reply.send({ ok: true });
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
557
apps/api/src/routes/billing.ts
Normal file
557
apps/api/src/routes/billing.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,11 +13,18 @@ import {
|
|||||||
oauthTokens,
|
oauthTokens,
|
||||||
} from '@bmm/db';
|
} from '@bmm/db';
|
||||||
import { getJWKS, signAccessToken } from '../lib/jwks.js';
|
import { getJWKS, signAccessToken } from '../lib/jwks.js';
|
||||||
|
import { checkDailyLimit } from '../lib/rate-limit.js';
|
||||||
import { requireAuth } from '../plugins/session.js';
|
import { requireAuth } from '../plugins/session.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
const db = createDb();
|
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 {
|
function sha256(input: string): string {
|
||||||
return crypto.createHash('sha256').update(input).digest('hex');
|
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) {
|
async function resolveServerByResource(resource: string) {
|
||||||
const url = new URL(resource);
|
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;
|
const port = url.port ? Number(url.port) : null;
|
||||||
if (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;
|
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];
|
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);
|
const [s] = await db.select().from(mcpServers).where(eq(mcpServers.slug, slug)).limit(1);
|
||||||
if (s) return s;
|
if (s) return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// Authorization Server Metadata (RFC 8414) — control-plane wide
|
// Authorization Server Metadata (RFC 8414).
|
||||||
app.get('/oauth/.well-known/oauth-authorization-server', async (_req, reply) => {
|
//
|
||||||
const base = `${config.CONTROL_PLANE_PUBLIC_URL}`;
|
// Our issuer is `${CONTROL_PLANE_PUBLIC_URL}/oauth`. RFC 8414 §3 says the
|
||||||
return reply.send({
|
// 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`,
|
issuer: `${base}/oauth`,
|
||||||
authorization_endpoint: `${base}/oauth/authorize`,
|
authorization_endpoint: `${base}/oauth/authorize`,
|
||||||
token_endpoint: `${base}/oauth/token`,
|
token_endpoint: `${base}/oauth/token`,
|
||||||
@@ -65,16 +120,34 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
'none',
|
'none',
|
||||||
],
|
],
|
||||||
scopes_supported: ['mcp:read', 'mcp:write'],
|
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) => {
|
app.get('/oauth/jwks', async (_req, reply) => {
|
||||||
reply.header('cache-control', 'public, max-age=300');
|
reply.header('cache-control', 'public, max-age=300');
|
||||||
return reply.send(await getJWKS());
|
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) => {
|
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({
|
const Body = z.object({
|
||||||
client_name: z.string().min(1).max(128).optional(),
|
client_name: z.string().min(1).max(128).optional(),
|
||||||
redirect_uris: z.array(z.string().url()).min(1).max(10),
|
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);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
|
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;
|
let serverId: string | null = null;
|
||||||
if (parsed.data.resource) {
|
if (parsed.data.resource) {
|
||||||
const server = await resolveServerByResource(parsed.data.resource);
|
const server = await resolveServerByResource(parsed.data.resource);
|
||||||
if (!server) return reply.code(400).send({ error: 'invalid_resource' });
|
if (!server) return reply.code(400).send({ error: 'invalid_resource' });
|
||||||
serverId = server.id;
|
serverId = server.id;
|
||||||
} else {
|
|
||||||
return reply.code(400).send({ error: 'resource_required' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = `bmm_${crypto.randomBytes(12).toString('hex')}`;
|
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' });
|
if (!redirectOk) return reply.code(400).send({ error: 'invalid_redirect_uri' });
|
||||||
|
|
||||||
const server = await resolveServerByResource(parsed.data.resource);
|
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' });
|
return reply.code(400).send({ error: 'invalid_resource' });
|
||||||
}
|
}
|
||||||
if (server.orgId !== user.orgId) {
|
if (server.orgId !== user.orgId) {
|
||||||
@@ -190,6 +279,33 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const parsed = Body.safeParse(body);
|
const parsed = Body.safeParse(body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
|
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') {
|
if (parsed.data.grant_type === 'authorization_code') {
|
||||||
const { code, code_verifier, client_id, client_secret, redirect_uri, resource } = parsed.data;
|
const { code, code_verifier, client_id, client_secret, redirect_uri, resource } = parsed.data;
|
||||||
if (!code || !code_verifier || !client_id || !redirect_uri || !resource) {
|
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' });
|
return reply.code(400).send({ error: 'invalid_grant' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subject = row.code.userId ?? row.client.clientId;
|
||||||
const accessToken = await signAccessToken({
|
const accessToken = await signAccessToken({
|
||||||
subject: row.code.userId ?? row.client.clientId,
|
subject,
|
||||||
audience: resource,
|
audience: resource,
|
||||||
issuer: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
|
issuer: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
|
||||||
scope: row.code.scope ?? '',
|
scope: row.code.scope ?? '',
|
||||||
ttlSeconds: 3600,
|
ttlSeconds: ACCESS_TOKEN_TTL_S,
|
||||||
});
|
});
|
||||||
const refreshToken = crypto.randomBytes(32).toString('base64url');
|
const refreshToken = crypto.randomBytes(32).toString('base64url');
|
||||||
await db.insert(oauthTokens).values({
|
await db.insert(oauthTokens).values({
|
||||||
@@ -239,18 +356,98 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
refreshTokenHash: sha256(refreshToken),
|
refreshTokenHash: sha256(refreshToken),
|
||||||
scope: row.code.scope ?? null,
|
scope: row.code.scope ?? null,
|
||||||
resource,
|
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({
|
return reply.send({
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
expires_in: 3600,
|
expires_in: ACCESS_TOKEN_TTL_S,
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
scope: row.code.scope ?? '',
|
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' });
|
return reply.code(400).send({ error: 'unsupported_grant_type' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,4 +468,3 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
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 { 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();
|
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) => {
|
app.post('/v1/servers/preview', { preHandler: requireAuth }, async (req, reply) => {
|
||||||
|
const user = req.user!;
|
||||||
const parsed = PreviewInput.safeParse(req.body);
|
const parsed = PreviewInput.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
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 {
|
try {
|
||||||
const { spec, source } = await generateSpec(parsed.data.prompt, {
|
const { spec, source } = await generateSpec(parsed.data.prompt, {
|
||||||
|
provider: choice.provider,
|
||||||
apiKey: config.ANTHROPIC_API_KEY,
|
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);
|
const previewId = await cacheSpec(spec);
|
||||||
return reply.send({
|
return reply.send({
|
||||||
previewId,
|
previewId,
|
||||||
source,
|
source,
|
||||||
|
plan,
|
||||||
|
modelDisplayName: choice.displayName,
|
||||||
|
modelBadge: choice.displayBadge,
|
||||||
|
upgradeHint: plan === 'hobby',
|
||||||
spec: {
|
spec: {
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
description: spec.description,
|
description: spec.description,
|
||||||
@@ -62,23 +134,318 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SpecValidationError) {
|
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 });
|
return reply.code(422).send({ error: 'spec_invalid', detail: err.message });
|
||||||
}
|
}
|
||||||
if (err instanceof BannedPatternError) {
|
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 });
|
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);
|
app.log.error(err);
|
||||||
return reply.code(500).send({ error: 'preview_failed', detail: (err as Error).message });
|
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) => {
|
app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
|
||||||
const user = req.user!;
|
const user = req.user!;
|
||||||
const parsed = CreateServerInput.safeParse(req.body);
|
const parsed = CreateServerInput.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
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 ----
|
// ---- Template-fork validation ----
|
||||||
// templateId is user-controlled. To prevent fork_count manipulation + garbage
|
// 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)) {
|
for (const [key, value] of Object.entries(secretValues)) {
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
|
const enc = encryptSecret(value);
|
||||||
await db.insert(secrets).values({
|
await db.insert(secrets).values({
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
key,
|
key,
|
||||||
encryptedValue: encryptSecret(value),
|
encryptedValue: enc.value,
|
||||||
|
keyId: enc.keyId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +546,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.returning();
|
.returning();
|
||||||
if (!build) return reply.code(500).send({ error: 'build_create_failed' });
|
if (!build) return reply.code(500).send({ error: 'build_create_failed' });
|
||||||
|
|
||||||
await getBuildQueue().add('generate', {
|
await getBuildQueue().add(
|
||||||
|
'generate',
|
||||||
|
{
|
||||||
buildId: build.id,
|
buildId: build.id,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
orgId: user.orgId,
|
orgId: user.orgId,
|
||||||
@@ -187,7 +558,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
serverName: name,
|
serverName: name,
|
||||||
secrets: secretValues,
|
secrets: secretValues,
|
||||||
previewId,
|
previewId,
|
||||||
});
|
},
|
||||||
|
{ priority: buildPriority(plan) },
|
||||||
|
);
|
||||||
|
|
||||||
await audit({
|
await audit({
|
||||||
orgId: user.orgId,
|
orgId: user.orgId,
|
||||||
@@ -240,6 +613,32 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
if (!server) return reply.code(404).send({ error: 'not_found' });
|
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 nextVersion = server.currentVersion + 1;
|
||||||
const [build] = await db
|
const [build] = await db
|
||||||
.insert(builds)
|
.insert(builds)
|
||||||
@@ -257,7 +656,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.set({ status: 'queued', updatedAt: new Date() })
|
.set({ status: 'queued', updatedAt: new Date() })
|
||||||
.where(eq(mcpServers.id, server.id));
|
.where(eq(mcpServers.id, server.id));
|
||||||
|
|
||||||
await getBuildQueue().add('generate', {
|
await getBuildQueue().add(
|
||||||
|
'generate',
|
||||||
|
{
|
||||||
buildId: build.id,
|
buildId: build.id,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
orgId: user.orgId,
|
orgId: user.orgId,
|
||||||
@@ -266,7 +667,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
slug: server.slug,
|
slug: server.slug,
|
||||||
serverName: server.name,
|
serverName: server.name,
|
||||||
secrets: parsed.data.secrets,
|
secrets: parsed.data.secrets,
|
||||||
});
|
},
|
||||||
|
{ priority: buildPriority(billing.plan) },
|
||||||
|
);
|
||||||
|
|
||||||
await audit({
|
await audit({
|
||||||
orgId: user.orgId,
|
orgId: user.orgId,
|
||||||
@@ -312,6 +715,14 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
socket.close();
|
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 Params = z.object({ id: z.string().uuid() });
|
||||||
const parsed = Params.safeParse(req.params);
|
const parsed = Params.safeParse(req.params);
|
||||||
if (!parsed.success) return fail('invalid_id');
|
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.
|
// otherwise it keeps serving traffic with the user's secrets baked in.
|
||||||
let containerStopped = false;
|
let containerStopped = false;
|
||||||
if (server.containerId) {
|
if (server.containerId) {
|
||||||
const result = await stopContainer(server.containerId);
|
const result = await stopContainer(server.containerId, server.slug);
|
||||||
containerStopped = result.ok;
|
containerStopped = result.ok;
|
||||||
if (!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));
|
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 ----
|
// ---- Spec-edit merge helpers ----
|
||||||
|
|
||||||
const BANNED_PATTERNS = [
|
/** Delegates to @bmm/llm's scanForInjection so the banned-pattern set is
|
||||||
/\beval\s*\(/,
|
* defined exactly once. Wraps the thrown BannedPatternError into a plain
|
||||||
/\bnew\s+Function\s*\(/,
|
* Error for the existing catch path in /v1/servers POST. */
|
||||||
/\brequire\s*\(\s*['"]child_process['"]/,
|
|
||||||
/\bchild_process\b/,
|
|
||||||
/ignore\s+previous\s+instructions/i,
|
|
||||||
/disregard\s+(the\s+)?(above|previous)/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
function rescanInjection(spec: GeneratorSpec): void {
|
function rescanInjection(spec: GeneratorSpec): void {
|
||||||
for (const tool of spec.tools) {
|
try {
|
||||||
for (const pattern of BANNED_PATTERNS) {
|
scanForInjection(spec);
|
||||||
if (pattern.test(tool.implementation) || pattern.test(tool.description)) {
|
} catch (err) {
|
||||||
throw new Error(`banned_pattern_detected: ${pattern.source}`);
|
if (err instanceof BannedPatternError) {
|
||||||
}
|
throw new Error(err.message);
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
333
apps/api/src/routes/support.ts
Normal file
333
apps/api/src/routes/support.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import { getSession } from '@bmm/auth';
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
builds,
|
builds,
|
||||||
@@ -15,30 +14,35 @@ import {
|
|||||||
templates,
|
templates,
|
||||||
users,
|
users,
|
||||||
} from '@bmm/db';
|
} from '@bmm/db';
|
||||||
|
import { SHARED_BANNED_PATTERNS } from '@bmm/llm';
|
||||||
import { GeneratorSpec } from '@bmm/types';
|
import { GeneratorSpec } from '@bmm/types';
|
||||||
import { getSession } from '@bmm/auth';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { requireAuth, requireAdmin } from '../plugins/session.js';
|
import { z } from 'zod';
|
||||||
import { audit } from '../lib/audit.js';
|
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 { 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 db = createDb();
|
||||||
|
|
||||||
const BANNED_PATTERNS = [
|
// Code-level extras on top of SHARED_BANNED_PATTERNS — these are concerns
|
||||||
/\beval\s*\(/,
|
// that only make sense scanning a fully-rendered server.ts (not a spec).
|
||||||
/\bnew\s+Function\s*\(/,
|
// Keeping them additive means @bmm/llm stays the single source of truth for
|
||||||
/\bFunction\s*\(\s*['"`]/, // Function('code')() — no `new` needed
|
// "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)
|
/\bimport\s*\(/, // dynamic import (escape from bundle scope)
|
||||||
/\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form
|
/\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form
|
||||||
/\bsetInterval\s*\(\s*['"`]/,
|
/\bsetInterval\s*\(\s*['"`]/,
|
||||||
/\bchild_process\b/,
|
|
||||||
/\bfs\s*\.\s*(unlink|rmdir|rm)\b/,
|
/\bfs\s*\.\s*(unlink|rmdir|rm)\b/,
|
||||||
/\bprocess\s*\.\s*kill\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,
|
/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
|
// Hardcoded-credential patterns. If Claude embedded a literal API key into the
|
||||||
// generated code (publisher pasted it into the prompt), block the publish.
|
// generated code (publisher pasted it into the prompt), block the publish.
|
||||||
@@ -54,7 +58,7 @@ const SECRET_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function scanForInjection(code: string): void {
|
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}`);
|
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 slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
while (true) {
|
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;
|
if (existing.length === 0) break;
|
||||||
attempt++;
|
attempt++;
|
||||||
slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`;
|
slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`;
|
||||||
@@ -186,10 +194,15 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
toolsSchema: server.toolsSchema,
|
toolsSchema: server.toolsSchema,
|
||||||
generatedCode: build.generatedCode,
|
generatedCode: build.generatedCode,
|
||||||
requiredSecrets: parsed.data.secretHints,
|
requiredSecrets: parsed.data.secretHints,
|
||||||
scopes: (server.toolsSchema as Array<{ scopes?: string[] }>).reduce<string[]>(
|
// Aggregate the distinct scopes actually declared by the server's tools
|
||||||
() => ['mcp:read'],
|
// (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,
|
allowedDomains: parsed.data.allowedDomains ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -302,29 +315,41 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.from(templates)
|
.from(templates)
|
||||||
.leftJoin(users, eq(users.id, templates.ownerUserId))
|
.leftJoin(users, eq(users.id, templates.ownerUserId))
|
||||||
.leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
|
.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))
|
.orderBy(desc(templates.createdAt))
|
||||||
.limit(parsed.data.limit);
|
.limit(parsed.data.limit);
|
||||||
|
|
||||||
const filtered = parsed.data.category
|
const filtered = rows;
|
||||||
? rows.filter((r) => r.template.category === parsed.data.category)
|
|
||||||
: rows;
|
|
||||||
|
|
||||||
// Augment with active deployment counts
|
// Single grouped query — was N+1 (one COUNT per template). On a 100-row
|
||||||
const enriched = await Promise.all(
|
// listing that's 101 round-trips → p95 latency cliff once the marketplace
|
||||||
filtered.map(async (r) => {
|
// grows. (Zc-002.)
|
||||||
const [active] = await db
|
const templateIds = filtered.map((r) => r.template.id);
|
||||||
.select({ c: count() })
|
const activeCounts = new Map<string, number>();
|
||||||
|
if (templateIds.length > 0) {
|
||||||
|
const grouped = await db
|
||||||
|
.select({ id: mcpServers.templateId, c: count() })
|
||||||
.from(mcpServers)
|
.from(mcpServers)
|
||||||
.where(and(eq(mcpServers.templateId, r.template.id), eq(mcpServers.status, 'live')));
|
.where(and(eq(mcpServers.status, 'live'), sql`${mcpServers.templateId} = ANY(${templateIds})`))
|
||||||
return {
|
.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,
|
...r.template,
|
||||||
ownerName: r.ownerName ?? r.ownerEmail?.split('@')[0] ?? null,
|
ownerName: r.ownerName ?? r.ownerEmail?.split('@')[0] ?? null,
|
||||||
ownerOrgName: r.ownerOrgName,
|
ownerOrgName: r.ownerOrgName,
|
||||||
activeDeployments: Number(active?.c ?? 0),
|
activeDeployments: activeCounts.get(r.template.id) ?? 0,
|
||||||
};
|
}));
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
const now = Date.now();
|
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')));
|
.where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
|
||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
ownerName: user.email.split('@')[0],
|
ownerName: user.email?.split('@')[0] ?? user.phone ?? 'you',
|
||||||
ownerOrgName: null,
|
ownerOrgName: null,
|
||||||
activeDeployments: Number(active?.c ?? 0),
|
activeDeployments: Number(active?.c ?? 0),
|
||||||
};
|
};
|
||||||
@@ -468,7 +493,9 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const validation = GeneratorSpec.safeParse(fullSpec);
|
const validation = GeneratorSpec.safeParse(fullSpec);
|
||||||
if (!validation.success) {
|
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);
|
const previewId = await cacheSpec(validation.data);
|
||||||
// Persist the pre-rendered code under the same previewId so the worker uses it
|
// 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;
|
let stoppedContainers = 0;
|
||||||
if (b.data.status === 'takedown') {
|
if (b.data.status === 'takedown') {
|
||||||
const forkedServers = await db
|
const forkedServers = await db
|
||||||
.select({ id: mcpServers.id, containerId: mcpServers.containerId })
|
.select({ id: mcpServers.id, containerId: mcpServers.containerId, slug: mcpServers.slug })
|
||||||
.from(mcpServers)
|
.from(mcpServers)
|
||||||
.where(eq(mcpServers.templateId, p.data.id));
|
.where(eq(mcpServers.templateId, p.data.id));
|
||||||
for (const fork of forkedServers) {
|
for (const fork of forkedServers) {
|
||||||
if (fork.containerId) {
|
if (fork.containerId) {
|
||||||
const result = await stopContainer(fork.containerId);
|
const result = await stopContainer(fork.containerId, fork.slug);
|
||||||
if (result.ok) stoppedContainers++;
|
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
|
await db
|
||||||
|
|||||||
34
apps/generator/Dockerfile
Normal file
34
apps/generator/Dockerfile
Normal 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"]
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"start": "node dist/index.js",
|
"start": "tsx src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,14 +4,24 @@ const Env = z.object({
|
|||||||
DATABASE_URL: z.string(),
|
DATABASE_URL: z.string(),
|
||||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||||
ANTHROPIC_API_KEY: z.string().optional(),
|
ANTHROPIC_API_KEY: z.string().optional(),
|
||||||
|
GLM_API_KEY: z.string().optional(),
|
||||||
RUNNER_HOST: z.string().default('localhost'),
|
RUNNER_HOST: z.string().default('localhost'),
|
||||||
RUNNER_PORT_RANGE_START: z.coerce.number().default(4100),
|
RUNNER_PORT_RANGE_START: z.coerce.number().default(4100),
|
||||||
RUNNER_PORT_RANGE_END: z.coerce.number().default(4999),
|
RUNNER_PORT_RANGE_END: z.coerce.number().default(4999),
|
||||||
CONTROL_PLANE_URL: z.string().default('http://host.docker.internal:4000'),
|
CONTROL_PLANE_URL: z.string().default('http://host.docker.internal:4000'),
|
||||||
CONTROL_PLANE_PUBLIC_URL: z.string().default('http://localhost:4000'),
|
CONTROL_PLANE_PUBLIC_URL: z.string().default('http://localhost:4000'),
|
||||||
OAUTH_ISSUER: z.string().optional(),
|
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'),
|
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);
|
export const config = Env.parse(process.env);
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ export async function prepareBuildContext(
|
|||||||
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
|
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
|
||||||
await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
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 };
|
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> {
|
export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn('docker', ['build', '-t', imageTag, '.'], {
|
const child = spawn('docker', ['build', '-t', imageTag, '.'], {
|
||||||
cwd: contextDir,
|
cwd: contextDir,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
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) => {
|
child.stdout.on('data', (d) => {
|
||||||
for (const line of d.toString().split(/\r?\n/)) {
|
for (const line of d.toString().split(/\r?\n/)) {
|
||||||
if (line.trim()) onLog(line.trim());
|
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());
|
if (line.trim()) onLog(line.trim());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
child.on('error', (e) => reject(e));
|
child.on('error', (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
if (code === 0) resolve();
|
if (code === 0) resolve();
|
||||||
else reject(new Error(`docker_build_failed (exit ${code})`));
|
else reject(new Error(`docker_build_failed (exit ${code})`));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
import { config } from '../config.js';
|
||||||
|
|
||||||
export type { GenerationResult };
|
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> {
|
export async function generateSpec(prompt: string): Promise<GenerationResult> {
|
||||||
return sharedGenerate(prompt, {
|
if (config.GLM_API_KEY) {
|
||||||
apiKey: config.ANTHROPIC_API_KEY,
|
try {
|
||||||
|
return await sharedGenerate(prompt, {
|
||||||
|
provider: 'glm',
|
||||||
|
glmApiKey: config.GLM_API_KEY,
|
||||||
model: config.MODEL_GENERATE,
|
model: config.MODEL_GENERATE,
|
||||||
maxTokens: 8192,
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,106 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
import { createDb, eq, isNotNull, mcpServers } from '@bmm/db';
|
import { createDb, eq, isNotNull, mcpServers } from '@bmm/db';
|
||||||
import { config } from '../config.js';
|
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();
|
const db = createDb();
|
||||||
|
|
||||||
async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> {
|
async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> {
|
||||||
@@ -46,16 +145,9 @@ export interface DeployInput {
|
|||||||
envVars: Record<string, string>;
|
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> {
|
export async function deployContainer(input: DeployInput): Promise<DeployHandle> {
|
||||||
// In a future iteration this calls docker engine API directly via UNIX socket / named pipe.
|
// Docker CLI is portable across linux/mac/win — sufficient for now; future
|
||||||
// For Sprint 1-3 we shell out via the bound docker CLI which is portable on win/mac/linux.
|
// iteration will switch to the engine API via UNIX socket.
|
||||||
const { spawn } = await import('node:child_process');
|
const { spawn } = await import('node:child_process');
|
||||||
const containerName = `bmm-mcp-${input.slug}-${Date.now().toString(36)}`;
|
const containerName = `bmm-mcp-${input.slug}-${Date.now().toString(36)}`;
|
||||||
const args = [
|
const args = [
|
||||||
@@ -66,6 +158,9 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
|
|||||||
'-p',
|
'-p',
|
||||||
`${input.hostPort}:3000`,
|
`${input.hostPort}:3000`,
|
||||||
];
|
];
|
||||||
|
if (shouldHarden()) {
|
||||||
|
args.push(...HARDENING_FLAGS);
|
||||||
|
}
|
||||||
for (const [k, v] of Object.entries(input.envVars)) {
|
for (const [k, v] of Object.entries(input.envVars)) {
|
||||||
args.push('-e', `${k}=${v}`);
|
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'] });
|
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
let out = '';
|
let out = '';
|
||||||
let err = '';
|
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) => {
|
child.stdout.on('data', (d) => {
|
||||||
out += d.toString();
|
out += d.toString();
|
||||||
});
|
});
|
||||||
child.stderr.on('data', (d) => {
|
child.stderr.on('data', (d) => {
|
||||||
err += d.toString();
|
err += d.toString();
|
||||||
});
|
});
|
||||||
child.on('error', (e) => reject(e));
|
child.on('error', (e) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
child.on('close', async (code) => {
|
child.on('close', async (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
|
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const containerId = out.trim().slice(0, 64);
|
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
|
await db
|
||||||
.update(mcpServers)
|
.update(mcpServers)
|
||||||
.set({
|
.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');
|
const { spawn } = await import('node:child_process');
|
||||||
await new Promise<void>((resolve) => {
|
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
|
||||||
const child = spawn('docker', ['rm', '-f', containerId], { stdio: 'ignore' });
|
const child = spawn('docker', ['rm', '-f', containerId], {
|
||||||
child.on('close', () => resolve());
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
child.on('error', () => resolve());
|
});
|
||||||
|
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}` });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ function renderTool(tool: ToolSpec): string {
|
|||||||
inputSchema: ${schemaShape},
|
inputSchema: ${schemaShape},
|
||||||
},
|
},
|
||||||
async (args) => {
|
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 {
|
try {
|
||||||
${tool.implementation}
|
${tool.implementation}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -59,9 +68,24 @@ import Fastify from 'fastify';
|
|||||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
const PUBLIC_URL = process.env.PUBLIC_URL ?? 'http://localhost:3000';
|
function stripTrailingSlash(value) {
|
||||||
const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL ?? 'http://host.docker.internal:4000';
|
return value.replace(/\\/$/, '');
|
||||||
const OAUTH_ISSUER = process.env.OAUTH_ISSUER ?? CONTROL_PLANE_URL + '/oauth';
|
}
|
||||||
|
|
||||||
|
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 PORT = Number.parseInt(process.env.PORT ?? '3000', 10);
|
||||||
|
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
@@ -75,15 +99,18 @@ const app = Fastify({ logger: { level: 'info' } });
|
|||||||
|
|
||||||
app.get('/health', async () => ({ ok: true }));
|
app.get('/health', async () => ({ ok: true }));
|
||||||
|
|
||||||
app.get('/.well-known/oauth-protected-resource', async () => ({
|
const protectedResourceMetadata = async () => ({
|
||||||
resource: PUBLIC_URL,
|
resource: MCP_RESOURCE_URL,
|
||||||
authorization_servers: [OAUTH_ISSUER],
|
authorization_servers: [OAUTH_ISSUER],
|
||||||
bearer_methods_supported: ['header'],
|
bearer_methods_supported: ['header'],
|
||||||
scopes_supported: ${JSON.stringify(spec.scopes)},
|
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 () => {
|
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();
|
return await r.json();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,16 +123,17 @@ app.all('/mcp', async (request, reply) => {
|
|||||||
if (!auth || !auth.startsWith('Bearer ')) {
|
if (!auth || !auth.startsWith('Bearer ')) {
|
||||||
return reply
|
return reply
|
||||||
.code(401)
|
.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' });
|
.send({ error: 'unauthorized' });
|
||||||
}
|
}
|
||||||
const token = auth.slice(7);
|
const token = auth.slice(7);
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, JWKS, {
|
const { payload } = await jwtVerify(token, JWKS, {
|
||||||
issuer: OAUTH_ISSUER,
|
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' });
|
return reply.code(403).send({ error: 'invalid_audience' });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import { builds, createDb, eq, mcpServers } from '@bmm/db';
|
||||||
|
import { GeneratorSpec } from '@bmm/types';
|
||||||
import { Worker } from 'bullmq';
|
import { Worker } from 'bullmq';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { GeneratorSpec } from '@bmm/types';
|
|
||||||
import { builds, createDb, eq, mcpServers } from '@bmm/db';
|
|
||||||
import { config } from './config.js';
|
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 { 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 { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
|
||||||
|
import { renderServerCode } from './lib/render.js';
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
|
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 { buildId, serverId, prompt, version, slug, secrets, previewId } = job.data;
|
||||||
const log = (level: 'info' | 'warn' | 'error', msg: string) => emitLog(buildId, level, msg);
|
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 {
|
try {
|
||||||
await db.update(builds).set({ status: 'generating', startedAt: new Date() }).where(eq(builds.id, buildId));
|
await db
|
||||||
await db.update(mcpServers).set({ status: 'generating', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
|
.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');
|
await emitStatus(buildId, 'generating');
|
||||||
|
|
||||||
let spec: GeneratorSpec | null = null;
|
let spec: GeneratorSpec | null = null;
|
||||||
let source: 'claude' | 'mock' | 'cached' = 'mock';
|
let source: 'claude' | 'glm' | 'mock' | 'cached' = 'mock';
|
||||||
|
|
||||||
if (previewId) {
|
if (previewId) {
|
||||||
spec = await loadCachedSpec(previewId);
|
spec = await loadCachedSpec(previewId);
|
||||||
@@ -77,7 +99,10 @@ export const worker = new Worker<JobData>(
|
|||||||
let generatedCode: string;
|
let generatedCode: string;
|
||||||
const prebuilt = previewId ? await loadPrebuiltCode(previewId) : null;
|
const prebuilt = previewId ? await loadPrebuiltCode(previewId) : null;
|
||||||
if (prebuilt) {
|
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;
|
generatedCode = prebuilt;
|
||||||
} else {
|
} else {
|
||||||
generatedCode = renderServerCode(spec);
|
generatedCode = renderServerCode(spec);
|
||||||
@@ -88,11 +113,20 @@ export const worker = new Worker<JobData>(
|
|||||||
.where(eq(builds.id, buildId));
|
.where(eq(builds.id, buildId));
|
||||||
|
|
||||||
await db.update(builds).set({ status: 'building' }).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 emitStatus(buildId, 'building');
|
||||||
await log('info', 'Preparing build context...');
|
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', `Build context at ${contextDir}`);
|
||||||
|
|
||||||
await log('info', 'Running static checks...');
|
await log('info', 'Running static checks...');
|
||||||
@@ -102,8 +136,14 @@ export const worker = new Worker<JobData>(
|
|||||||
const hasDocker = await dockerAvailable();
|
const hasDocker = await dockerAvailable();
|
||||||
if (!hasDocker) {
|
if (!hasDocker) {
|
||||||
await log('warn', 'Docker not available — skipping build/deploy. Server marked draft.');
|
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
|
||||||
await db.update(mcpServers).set({ status: 'failed', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
|
.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);
|
await emitDone(buildId, 'failed', serverId, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -115,11 +155,20 @@ export const worker = new Worker<JobData>(
|
|||||||
await log('info', 'Image built.');
|
await log('info', 'Image built.');
|
||||||
|
|
||||||
await db.update(builds).set({ status: 'deploying' }).where(eq(builds.id, buildId));
|
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');
|
await emitStatus(buildId, 'deploying');
|
||||||
|
|
||||||
const port = await allocatePort();
|
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> = {
|
const envVars: Record<string, string> = {
|
||||||
...secrets,
|
...secrets,
|
||||||
PUBLIC_URL: publicUrl,
|
PUBLIC_URL: publicUrl,
|
||||||
@@ -130,16 +179,40 @@ export const worker = new Worker<JobData>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handle = await deployContainer({ serverId, slug, hostPort: port, imageTag, envVars });
|
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
|
await db
|
||||||
.update(builds)
|
.update(builds)
|
||||||
.set({ status: 'success', finishedAt: new Date() })
|
.set({ status: 'success', finishedAt: new Date() })
|
||||||
.where(eq(builds.id, buildId));
|
.where(eq(builds.id, buildId));
|
||||||
await db
|
await db
|
||||||
.update(mcpServers)
|
.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));
|
.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 emitStatus(buildId, 'success');
|
||||||
await emitDone(buildId, 'success', serverId, handle.publicUrl);
|
await emitDone(buildId, 'success', serverId, handle.publicUrl);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
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
|
FROM node:20-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -10,6 +14,8 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY package.json tsconfig.json ./
|
COPY package.json tsconfig.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
EXPOSE 3000
|
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 \
|
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"]
|
CMD ["npx", "tsx", "src/server.ts"]
|
||||||
|
|||||||
47
apps/web/Dockerfile
Normal file
47
apps/web/Dockerfile
Normal 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"]
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { apiFetch } from '@/lib/api';
|
|
||||||
import { StatusPill } from '@/components/status-pill';
|
import { StatusPill } from '@/components/status-pill';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
interface ServerRow {
|
interface ServerRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,12 +17,17 @@ interface ServerRow {
|
|||||||
|
|
||||||
export default function Overview() {
|
export default function Overview() {
|
||||||
const [servers, setServers] = useState<ServerRow[] | null>(null);
|
const [servers, setServers] = useState<ServerRow[] | null>(null);
|
||||||
|
const [plan, setPlan] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<{ servers: ServerRow[] }>('/v1/servers')
|
apiFetch<{ servers: ServerRow[] }>('/v1/servers')
|
||||||
.then((r) => setServers(r.servers))
|
.then((r) => setServers(r.servers))
|
||||||
.catch((e) => setErr((e as Error).message));
|
.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')) {
|
if (err?.includes('401')) {
|
||||||
@@ -37,31 +42,34 @@ export default function Overview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[22px] font-semibold tracking-tight">Overview</h1>
|
<h1 className="text-[22px] font-semibold tracking-tight">Overview</h1>
|
||||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||||
Your MCP servers, calls and recent builds.
|
Your MCP servers, calls and recent builds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||||
<Card label="Servers" value={total.toString()} sub={`${live} live`} />
|
<Card label="Servers" value={total.toString()} sub={`${live} live`} />
|
||||||
<Card label="Calls this period" value="0" sub="of 100,000" />
|
<Card
|
||||||
<Card label="Plan" value="Hobby" sub="Upgrade in Settings" />
|
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>
|
||||||
|
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-[14px] font-semibold tracking-tight">Recent servers</h2>
|
<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 →
|
View all →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,9 +102,15 @@ export default function Overview() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{servers.slice(0, 5).map((s) => (
|
{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">
|
<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}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import Link from 'next/link';
|
import { CookieBanner } from '@/components/cookie-banner';
|
||||||
import { Logo } from '@/components/logo';
|
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 }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<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">
|
<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="mx-auto flex h-12 max-w-7xl items-center justify-between gap-2 px-4 sm:px-6">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex min-w-0 items-center gap-2 sm:gap-6">
|
||||||
<Logo />
|
<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} />}>
|
<NavLink href="/dashboard" icon={<LayoutGrid size={13} />}>
|
||||||
Overview
|
Overview
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -27,15 +33,20 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
<PulseLink
|
||||||
href="/servers/new"
|
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
|
+ New server
|
||||||
</Link>
|
</PulseLink>
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
</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]"
|
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}
|
{icon}
|
||||||
{children}
|
<span className="hidden sm:inline">{children}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { StatusPill } from '@/components/status-pill';
|
import { StatusPill } from '@/components/status-pill';
|
||||||
import { CodeBlock } from '@/components/code-block';
|
import { CodeBlock } from '@/components/code-block';
|
||||||
@@ -38,11 +38,14 @@ type Tab = 'overview' | 'tools' | 'logs' | 'metrics' | 'secrets' | 'iterate' | '
|
|||||||
|
|
||||||
export default function ServerDetailPage() {
|
export default function ServerDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
const [server, setServer] = useState<ServerDetail | null>(null);
|
const [server, setServer] = useState<ServerDetail | null>(null);
|
||||||
const [builds, setBuilds] = useState<BuildSummary[]>([]);
|
const [builds, setBuilds] = useState<BuildSummary[]>([]);
|
||||||
const [tab, setTab] = useState<Tab>('overview');
|
const [tab, setTab] = useState<Tab>('overview');
|
||||||
const [iteratePrompt, setIteratePrompt] = useState('');
|
const [iteratePrompt, setIteratePrompt] = useState('');
|
||||||
const [latestBuildId, setLatestBuildId] = useState<string | null>(null);
|
const [latestBuildId, setLatestBuildId] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const r = await apiFetch<{ server: ServerDetail; builds: BuildSummary[] }>(
|
const r = await apiFetch<{ server: ServerDetail; builds: BuildSummary[] }>(
|
||||||
@@ -72,6 +75,27 @@ export default function ServerDetailPage() {
|
|||||||
setTab('logs');
|
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) {
|
if (!server) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-6 py-8 text-[12.5px] text-[--color-fg-muted]">Loading…</div>
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
'use client';
|
'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 { 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 { Loader2, RotateCcw, X } from 'lucide-react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
const EXAMPLE_PROMPTS = [
|
const EXAMPLE_PROMPTS = [
|
||||||
{
|
{
|
||||||
@@ -41,9 +42,15 @@ interface PreviewTool {
|
|||||||
inputSchema: Record<string, unknown>;
|
inputSchema: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
|
||||||
|
|
||||||
interface PreviewResponse {
|
interface PreviewResponse {
|
||||||
previewId: string;
|
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: {
|
spec: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: 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: '30–60 seconds' },
|
||||||
|
pro: { name: 'Claude Haiku 4.5', estimate: '10–20 seconds' },
|
||||||
|
team: { name: 'Claude Sonnet 4.6', estimate: '15–40 seconds' },
|
||||||
|
enterprise: { name: 'Claude Sonnet 4.6', estimate: '15–40 seconds' },
|
||||||
|
};
|
||||||
|
|
||||||
interface EditableTool {
|
interface EditableTool {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -82,9 +96,11 @@ function specToEditable(spec: PreviewResponse['spec']): EditableSpec {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewServerPage() {
|
function NewServerPageInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [step, setStep] = useState<Step>('prompt');
|
const [step, setStep] = useState<Step>('prompt');
|
||||||
|
const [elapsedSec, setElapsedSec] = useState(0);
|
||||||
|
const [userPlan, setUserPlan] = useState<Plan | null>(null);
|
||||||
|
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -105,7 +121,11 @@ export default function NewServerPage() {
|
|||||||
const templateSlug = searchParams.get('template');
|
const templateSlug = searchParams.get('template');
|
||||||
|
|
||||||
const trySlug = (n: string) =>
|
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
|
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -189,6 +209,27 @@ export default function NewServerPage() {
|
|||||||
}
|
}
|
||||||
}, [preview, editable]);
|
}, [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() {
|
async function analyze() {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (prompt.trim().length < 10) {
|
if (prompt.trim().length < 10) {
|
||||||
@@ -199,22 +240,81 @@ export default function NewServerPage() {
|
|||||||
setError('Name and slug are required.');
|
setError('Name and slug are required.');
|
||||||
return;
|
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');
|
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 {
|
try {
|
||||||
const res = await apiFetch<PreviewResponse>('/v1/servers/preview', {
|
const res = await apiFetch<PreviewResponse>('/v1/servers/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ prompt }),
|
body: JSON.stringify({ prompt }),
|
||||||
});
|
});
|
||||||
setPreview(res);
|
setPreview(res);
|
||||||
setEditable(null); // will re-init via useEffect
|
setEditable(null);
|
||||||
setStep('confirm');
|
setStep('confirm');
|
||||||
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
|
setError(humanizeError(e));
|
||||||
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
|
|
||||||
setStep('prompt');
|
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>) {
|
function updateTool(i: number, patch: Partial<EditableTool>) {
|
||||||
setEditable((prev) => {
|
setEditable((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
@@ -323,9 +423,7 @@ export default function NewServerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
|
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>('/v1/servers', {
|
||||||
'/v1/servers',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name,
|
name,
|
||||||
@@ -337,19 +435,28 @@ export default function NewServerPage() {
|
|||||||
// are already in the Redis cache. Edits would invalidate the impls.
|
// are already in the Redis cache. Edits would invalidate the impls.
|
||||||
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
|
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
|
||||||
}),
|
}),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
setBuildId(res.build.id);
|
setBuildId(res.build.id);
|
||||||
setServerId(res.server.id);
|
setServerId(res.server.id);
|
||||||
setStep('building');
|
setStep('building');
|
||||||
} catch (e) {
|
} 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;
|
const code = detail?.error;
|
||||||
|
if (code === 'slug_taken') {
|
||||||
setError(
|
setError(
|
||||||
code === 'slug_taken'
|
`The slug "${slug}" is already used by one of your servers — change the Slug field above.`,
|
||||||
? `The slug "${slug}" is already used by one of your servers — change the Slug field above.`
|
|
||||||
: (code ?? (e as Error).message),
|
|
||||||
);
|
);
|
||||||
|
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]">
|
<p className="text-[12px] leading-relaxed text-[--color-fg-subtle]">
|
||||||
Next step we'll show you exactly which tools we'll expose and let you tweak
|
Next step we'll show you exactly which tools we'll expose and let you tweak
|
||||||
the spec before we build.
|
the spec before we build.{' '}
|
||||||
|
<span className="text-[--color-fg-muted]">
|
||||||
|
Don't paste API keys or access tokens here — you'll add each one in its own
|
||||||
|
encrypted field in the next step.
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
{EXAMPLE_PROMPTS.map((p) => (
|
{EXAMPLE_PROMPTS.map((p) => (
|
||||||
@@ -412,7 +523,9 @@ export default function NewServerPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
id="slug"
|
id="slug"
|
||||||
value={slug}
|
value={slug}
|
||||||
@@ -437,10 +550,15 @@ export default function NewServerPage() {
|
|||||||
|
|
||||||
{step === 'analyzing' && (
|
{step === 'analyzing' && (
|
||||||
<div className="mt-10 panel p-8 text-center">
|
<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-4 text-[13px]">Analyzing your prompt…</p>
|
||||||
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
|
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
|
||||||
Claude Opus 4.7 is parsing the spec. Usually 20–40 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -488,9 +606,36 @@ export default function NewServerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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="panel p-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we'll build</h2>
|
<h2 className="text-[14px] font-semibold tracking-tight">
|
||||||
|
Confirm what we'll build
|
||||||
|
</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{editsDirty && (
|
{editsDirty && (
|
||||||
<button
|
<button
|
||||||
@@ -502,15 +647,15 @@ export default function NewServerPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
|
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
|
||||||
spec via {preview.source}
|
drafted with {preview.modelDisplayName ?? preview.source}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
|
<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]">
|
<p className="mt-3 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
|
||||||
Edit tool names, descriptions or input schemas inline. Renaming parameters may
|
Edit tool names, descriptions or input schemas inline. Renaming parameters may require
|
||||||
require an <span className="mono">Iterate</span> after build to update the
|
an <span className="mono">Iterate</span> after build to update the implementation —
|
||||||
implementation — the existing impl references the original names.
|
the existing impl references the original names.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -560,17 +705,18 @@ export default function NewServerPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[13px] font-semibold tracking-tight">
|
<h3 className="text-[13px] font-semibold tracking-tight">API keys & credentials</h3>
|
||||||
Credentials we need
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
<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
|
One field per key or access token — entered here, separately from your prompt.
|
||||||
implementation doesn't actually use one.
|
AES-256-GCM encrypted at rest, injected as env vars at runtime only. Remove any your
|
||||||
|
implementation doesn't use; add any we missed.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{editable.requiredSecrets.length === 0 && (
|
{editable.requiredSecrets.length === 0 && (
|
||||||
<p className="text-[12.5px] text-[--color-fg-muted]">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{editable.requiredSecrets.map((key, idx) => (
|
{editable.requiredSecrets.map((key, idx) => (
|
||||||
@@ -625,12 +771,7 @@ export default function NewServerPage() {
|
|||||||
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
|
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
|
||||||
← Back
|
← Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="primary" size="md" onClick={build} disabled={Boolean(hasSchemaErrors)}>
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
onClick={build}
|
|
||||||
disabled={Boolean(hasSchemaErrors)}
|
|
||||||
>
|
|
||||||
Build server →
|
Build server →
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 = [
|
const SHARE_CATEGORIES = [
|
||||||
'productivity',
|
'productivity',
|
||||||
'developer-tools',
|
'developer-tools',
|
||||||
@@ -734,9 +895,7 @@ function SharePanel({
|
|||||||
}) {
|
}) {
|
||||||
const [share, setShare] = useState(true);
|
const [share, setShare] = useState(true);
|
||||||
const [category, setCategory] = useState('other');
|
const [category, setCategory] = useState('other');
|
||||||
const [shortDescription, setShortDescription] = useState(
|
const [shortDescription, setShortDescription] = useState(defaultShortDescription.slice(0, 280));
|
||||||
defaultShortDescription.slice(0, 280),
|
|
||||||
);
|
|
||||||
const [hints, setHints] = useState<Record<string, string>>(() =>
|
const [hints, setHints] = useState<Record<string, string>>(() =>
|
||||||
Object.fromEntries(secretKeys.map((k) => [k, ''])),
|
Object.fromEntries(secretKeys.map((k) => [k, ''])),
|
||||||
);
|
);
|
||||||
@@ -812,8 +971,8 @@ function SharePanel({
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
<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{' '}
|
Your secrets stay private — they are never copied into a template. But your{' '}
|
||||||
<span className="text-[--color-fg]">generated code becomes publicly viewable</span>{' '}
|
<span className="text-[--color-fg]">generated code becomes publicly viewable</span> so
|
||||||
so others can audit it before forking. Unshare anytime.
|
others can audit it before forking. Unshare anytime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -848,9 +1007,7 @@ function SharePanel({
|
|||||||
|
|
||||||
{secretKeys.length > 0 && (
|
{secretKeys.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label hint="optional — helps forkers know what to paste">
|
<Label hint="optional — helps forkers know what to paste">Credential hints</Label>
|
||||||
Credential hints
|
|
||||||
</Label>
|
|
||||||
{secretKeys.map((k) => (
|
{secretKeys.map((k) => (
|
||||||
<div key={k} className="grid grid-cols-[180px_1fr] gap-2">
|
<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]">
|
<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]">
|
<p className="text-[11px] text-[--color-fg-subtle]">
|
||||||
Published code is re-scanned for banned patterns and hardcoded secrets.
|
Published code is re-scanned for banned patterns and hardcoded secrets.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button variant="primary" size="md" onClick={publish} disabled={state === 'submitting'}>
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
onClick={publish}
|
|
||||||
disabled={state === 'submitting'}
|
|
||||||
>
|
|
||||||
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
|
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
116
apps/web/app/(dashboard)/settings/account/page.tsx
Normal file
116
apps/web/app/(dashboard)/settings/account/page.tsx
Normal 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' 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
596
apps/web/app/(dashboard)/settings/billing/page.tsx
Normal file
596
apps/web/app/(dashboard)/settings/billing/page.tsx
Normal 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 aren’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
apps/web/app/(dashboard)/settings/profile/page.tsx
Normal file
140
apps/web/app/(dashboard)/settings/profile/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/web/app/(dashboard)/settings/support/[id]/page.tsx
Normal file
145
apps/web/app/(dashboard)/settings/support/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/app/(dashboard)/settings/support/page.tsx
Normal file
166
apps/web/app/(dashboard)/settings/support/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/web/app/(marketing)/agb/page.tsx
Normal file
129
apps/web/app/(marketing)/agb/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { CodeBlock } from '@/components/code-block';
|
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 {
|
interface Release {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
15
apps/web/app/(marketing)/contact/layout.tsx
Normal file
15
apps/web/app/(marketing)/contact/layout.tsx
Normal 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;
|
||||||
|
}
|
||||||
133
apps/web/app/(marketing)/contact/page.tsx
Normal file
133
apps/web/app/(marketing)/contact/page.tsx
Normal 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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
apps/web/app/(marketing)/guides/article-shell.tsx
Normal file
103
apps/web/app/(marketing)/guides/article-shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
202
apps/web/app/(marketing)/guides/chatgpt-mcp-connector/page.tsx
Normal file
202
apps/web/app/(marketing)/guides/chatgpt-mcp-connector/page.tsx
Normal 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'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 & 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
206
apps/web/app/(marketing)/guides/composio-alternative/page.tsx
Normal file
206
apps/web/app/(marketing)/guides/composio-alternative/page.tsx
Normal 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'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> — "check inventory, then draft the
|
||||||
|
reorder in our format" 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> </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>"Let my agent use Slack and Gmail"</Strong> → Composio. Do not
|
||||||
|
generate what a maintained catalog already does better.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Strong>"Let my agent use our internal order API, read-only"</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 45–90 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>45–90 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 "confidential" 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 "working" 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 & 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
|
||||||
|
"let my agent touch Gmail / Slack / Salesforce," 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
180
apps/web/app/(marketing)/guides/mcp-oauth-plain-english/page.tsx
Normal file
180
apps/web/app/(marketing)/guides/mcp-oauth-plain-english/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
"MCP server hosting" 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'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
|
||||||
|
"where do I find or distribute a server" more than "who builds mine" —
|
||||||
|
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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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: 45–90 Sekunden.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Strong>Secrets eintragen.</Strong> API-Schlüssel (z. 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 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
103
apps/web/app/(marketing)/guides/mintmcp-alternative/page.tsx
Normal file
103
apps/web/app/(marketing)/guides/mintmcp-alternative/page.tsx
Normal 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>"I need a tool that does X"</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/web/app/(marketing)/guides/page.tsx
Normal file
58
apps/web/app/(marketing)/guides/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
179
apps/web/app/(marketing)/guides/rest-api-to-mcp-server/page.tsx
Normal file
179
apps/web/app/(marketing)/guides/rest-api-to-mcp-server/page.tsx
Normal 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>3–7 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 45–90 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
196
apps/web/app/(marketing)/guides/smithery-alternative/page.tsx
Normal file
196
apps/web/app/(marketing)/guides/smithery-alternative/page.tsx
Normal 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'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> ("does a server for X
|
||||||
|
already exist?") and <Strong>distribution</Strong> ("let people find and run the
|
||||||
|
server I wrote"). 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 "write it first" 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> </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't exist yet</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<Strong>Starting point</Strong>
|
||||||
|
</td>
|
||||||
|
<td>A server (yours or the catalog'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 & 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/web/app/(marketing)/impressum/page.tsx
Normal file
95
apps/web/app/(marketing)/impressum/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import Link from 'next/link';
|
import { CookieBanner } from '@/components/cookie-banner';
|
||||||
import { Logo } from '@/components/logo';
|
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 }) {
|
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<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">
|
<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">
|
<div className="flex items-center gap-6">
|
||||||
<Logo />
|
<Logo />
|
||||||
<nav className="hidden items-center gap-5 text-[13px] text-[--color-fg-muted] md:flex">
|
<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]">
|
<Link href="/docs" className="transition-colors hover:text-[--color-fg]">
|
||||||
Docs
|
Docs
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/guides" className="transition-colors hover:text-[--color-fg]">
|
||||||
|
Guides
|
||||||
|
</Link>
|
||||||
<Link href="/changelog" className="transition-colors hover:text-[--color-fg]">
|
<Link href="/changelog" className="transition-colors hover:text-[--color-fg]">
|
||||||
Changelog
|
Changelog
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
<Link
|
<MarketingAuthButtons />
|
||||||
href="/login"
|
<MarketingMobileMenu />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
<footer className="border-t border-[--color-border] py-8">
|
<footer className="border-t border-[--color-border] py-12">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-4 px-6 text-[12px] text-[--color-fg-subtle] md:flex-row md:items-center md:justify-between">
|
<div className="mx-auto max-w-6xl px-6">
|
||||||
<Link href="/status" className="flex items-center gap-2 transition-colors hover:text-[--color-fg]">
|
<div className="grid gap-10 sm:grid-cols-2 md:grid-cols-4">
|
||||||
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
|
{/* 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>
|
<span>System status</span>
|
||||||
</Link>
|
</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>
|
||||||
<div>© {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]">
|
||||||
|
© {new Date().getFullYear()} BuildMyMCPServer
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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.
|
interface ExampleEntry {
|
||||||
Tools: search_pages, get_page_content.
|
title: string;
|
||||||
Auth: NOTION_API_KEY.`;
|
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)
|
const EXAMPLES: ExampleEntry[] = [
|
||||||
> Static checks OK
|
|
||||||
> Building image bmm-mcp-notion OK 17.2s
|
|
||||||
> Deploying container OK
|
|
||||||
> Live at https://notion-x9.mcp.buildmymcpserver.com
|
|
||||||
> First request: 401 → token → 200 OK`;
|
|
||||||
|
|
||||||
const INSTALL_SNIPPET = `{
|
|
||||||
"mcpServers": {
|
|
||||||
"notion": {
|
|
||||||
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
|
|
||||||
"auth": "oauth2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const EXAMPLES: { title: string; desc: string }[] = [
|
|
||||||
{ title: 'Postgres reader', desc: 'Read-only access to your tables with schema introspection.' },
|
|
||||||
{ title: 'Salesforce', desc: 'Query opportunities, accounts and leads from Claude.' },
|
|
||||||
{ title: 'Notion', desc: 'Search pages, read content, append blocks.' },
|
|
||||||
{ title: 'GitHub', desc: 'List issues, search code, post comments — scoped to one repo.' },
|
|
||||||
{ title: 'Stripe', desc: 'Look up charges, customers, refunds (read-only by default).' },
|
|
||||||
{ title: 'Custom REST', desc: 'Wrap any HTTP API behind one prompt-defined tool surface.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const FAQ: { q: string; a: string }[] = [
|
|
||||||
{
|
{
|
||||||
q: 'What is MCP?',
|
title: 'PostgreSQL',
|
||||||
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.',
|
desc: 'Read-only access to your tables with schema introspection.',
|
||||||
|
Icon: PostgresIcon,
|
||||||
|
bg: '#336791',
|
||||||
|
fg: '#ffffff',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Do I need to write code?',
|
title: 'Salesforce',
|
||||||
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.',
|
desc: 'Query opportunities, accounts and leads from Claude.',
|
||||||
|
Icon: SalesforceCloudIcon,
|
||||||
|
bg: '#00a1e0',
|
||||||
|
fg: '#ffffff',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Which clients work?',
|
title: 'Notion',
|
||||||
a: 'Claude Desktop, Cursor, ChatGPT Custom Connectors, VS Code Copilot, Continue.dev — anything that speaks the MCP spec.',
|
desc: 'Search pages, read content, append blocks.',
|
||||||
|
Icon: NotionIcon,
|
||||||
|
bg: '#ffffff',
|
||||||
|
fg: '#0a0a0b',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'How is auth handled?',
|
title: 'GitHub',
|
||||||
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).',
|
desc: 'List issues, search code, post comments. Scoped to one repo.',
|
||||||
|
Icon: GitHubIcon,
|
||||||
|
bg: '#181717',
|
||||||
|
fg: '#ffffff',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Can I self-host?',
|
title: 'Stripe',
|
||||||
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.',
|
desc: 'Look up charges, customers, refunds (read-only by default).',
|
||||||
|
Icon: StripeIcon,
|
||||||
|
bg: '#635bff',
|
||||||
|
fg: '#ffffff',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'What about secrets?',
|
title: 'Custom REST',
|
||||||
a: 'AES-256-GCM at rest in Postgres, injected as environment variables into the runtime container. Never logged, never echoed back.',
|
desc: 'Wrap any HTTP API behind one prompt-defined tool surface.',
|
||||||
},
|
Icon: RestIcon,
|
||||||
{
|
bg: '#6366f1',
|
||||||
q: 'Cold starts?',
|
fg: '#ffffff',
|
||||||
a: 'No cold starts. Containers stay warm. Sub-50ms tool-call overhead on average for in-region requests.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Rate limits?',
|
|
||||||
a: 'Default 100 requests/min/IP per tool. Configurable per server. Quota enforced at the Traefik layer before hitting your container.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'How fast is generation?',
|
|
||||||
a: 'Spec → image → live URL typically completes in 45-90 seconds.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Logs and metrics?',
|
|
||||||
a: 'Live log streaming to the dashboard, structured tool-call metrics (P50/P95/P99 latency, error rate, per-tool throughput) — all retained for 30 days.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'What if I cancel?',
|
|
||||||
a: 'You can export the full TypeScript source of every server you built. No vendor lock-in.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Custom domain?',
|
|
||||||
a: 'Pro plan and above. Add a CNAME, we provision Let’s Encrypt automatically.',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const TIERS = [
|
// Honest marketplace preview data: template names + tool counts only. These
|
||||||
{ name: 'Hobby', price: '€0', tag: 'Forever free', features: ['1 server', '100k calls/mo', 'BMM subdomain', 'Community support'] },
|
// mirror the first-party starter templates; no invented fork counts, no
|
||||||
{ name: 'Pro', price: '€49', tag: '/ month', features: ['5 servers', '1M calls/mo', 'Custom domain', 'Priority build queue', 'Email support'] },
|
// "verified" theatre — the frame is labelled as a preview of the card format.
|
||||||
{ name: 'Team', price: '€149', tag: '/ month', features: ['25 servers', '10M calls/mo', 'RBAC + audit log', 'SLA 99.9%', 'Slack support'] },
|
const PREVIEW_TEMPLATES: { name: string; author: string; tools: number }[] = [
|
||||||
{ name: 'Enterprise', price: '€499+', tag: '/ month', features: ['Unlimited', 'BYOC', 'SSO / SAML', 'Dedicated cluster', 'Customer success'] },
|
{ 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() {
|
export default function Landing() {
|
||||||
|
const teaserTiers = TIERS.filter((t) => t.name === 'Hobby' || t.name === 'Pro');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero */}
|
{/* Hero — left: copy + CTAs, right: cycling terminal tile. The WebGL
|
||||||
<section className="relative border-b border-[--color-border]">
|
particle field sits behind at z-0 with pointer-events:none so the
|
||||||
<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">
|
CTAs stay interactive. */}
|
||||||
<div>
|
<section
|
||||||
<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]">
|
className="relative flex items-center overflow-hidden border-b border-[--color-border]"
|
||||||
v0.1 — updated 2026-05-20
|
style={{ minHeight: 'calc(100svh - 3.5rem)' }}
|
||||||
</span>
|
>
|
||||||
<h1 className="mt-6 text-balance text-[44px] font-semibold leading-[1.05] tracking-tight md:text-[56px]">
|
<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.
|
Describe your tool.
|
||||||
<br />
|
<br />
|
||||||
We host the server.
|
We host the server.
|
||||||
<br />
|
<br />
|
||||||
<span className="text-[--color-fg-muted]">AI uses it.</span>
|
<span className="text-[--color-fg-muted]">AI uses it.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-[--color-fg-muted]">
|
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-[--color-fg-muted] sm:text-[16px]">
|
||||||
From prompt to production MCP server in 60 seconds. OAuth 2.1, Streamable HTTP, ready
|
From a prompt to a production MCP server — OAuth 2.1, Streamable HTTP, ready for
|
||||||
for Claude, Cursor and ChatGPT.
|
Claude, Cursor and ChatGPT.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-7 flex flex-wrap items-center gap-3">
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||||
<Link
|
<PulseLink
|
||||||
href="/login"
|
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
|
Start building free →
|
||||||
</Link>
|
</PulseLink>
|
||||||
<Link
|
<PulseLink
|
||||||
href="/docs"
|
href="#flow"
|
||||||
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]"
|
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
|
Watch a build
|
||||||
</Link>
|
</PulseLink>
|
||||||
</div>
|
</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="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>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<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>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<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>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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="relative">
|
||||||
<div className="absolute -inset-px rounded-lg border border-[--color-border-strong]" />
|
{/* Gradient connector — desktop only, sits behind the node row. */}
|
||||||
<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) => (
|
|
||||||
<div
|
<div
|
||||||
key={t.name}
|
aria-hidden
|
||||||
className={`panel p-5 ${i === 1 ? 'border-[--color-accent]/40' : ''}`}
|
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>
|
{s.n}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-4 space-y-1.5 text-[12.5px] text-[--color-fg-muted]">
|
<div className="min-w-0 md:mt-5">
|
||||||
{t.features.map((f) => (
|
<h3 className="text-[15px] font-semibold tracking-tight">{s.t}</h3>
|
||||||
<li key={f}>— {f}</li>
|
<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'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>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* Final CTA — the page must not end on FAQ. Gradient top border keeps
|
||||||
<section className="py-20">
|
the brand mark scarce but present at the exit point. */}
|
||||||
<div className="mx-auto max-w-6xl px-6">
|
<section className="relative py-20 sm:py-28">
|
||||||
<h2 className="text-[28px] font-semibold tracking-tight">FAQ</h2>
|
<div
|
||||||
<div className="mt-8 grid gap-x-12 gap-y-6 md:grid-cols-2">
|
aria-hidden
|
||||||
{FAQ.map((f) => (
|
className="absolute inset-x-0 top-0 h-px"
|
||||||
<div key={f.q}>
|
style={{ background: 'var(--gradient-brand)', opacity: 0.6 }}
|
||||||
<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 className="mx-auto max-w-3xl px-6 text-center">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,69 +1,13 @@
|
|||||||
|
import { TIERS } from '@/lib/pricing';
|
||||||
|
import { pageMetadata } from '@/lib/seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export const metadata = { title: 'Pricing — BuildMyMCPServer' };
|
export const metadata = pageMetadata({
|
||||||
|
title: 'Pricing',
|
||||||
const TIERS = [
|
description:
|
||||||
{
|
'BuildMyMCPServer pricing — start free with one hosted MCP server, scale to Pro, Team and Enterprise. Pay for tool calls, not boilerplate.',
|
||||||
name: 'Hobby',
|
path: '/pricing',
|
||||||
price: '€0',
|
});
|
||||||
tag: 'Forever free',
|
|
||||||
description: 'For trying things out and shipping single-user tools.',
|
|
||||||
features: [
|
|
||||||
'1 MCP server',
|
|
||||||
'100,000 tool calls / month',
|
|
||||||
'BuildMyMCP subdomain',
|
|
||||||
'Community support',
|
|
||||||
],
|
|
||||||
cta: 'Start free',
|
|
||||||
href: '/login',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Pro',
|
|
||||||
price: '€49',
|
|
||||||
tag: '/ month',
|
|
||||||
description: 'For solo founders and small teams shipping production tools.',
|
|
||||||
features: [
|
|
||||||
'5 MCP servers',
|
|
||||||
'1M tool calls / month',
|
|
||||||
'Custom domain',
|
|
||||||
'Priority build queue',
|
|
||||||
'Email support, 1 business-day SLA',
|
|
||||||
],
|
|
||||||
cta: 'Start Pro',
|
|
||||||
href: '/login',
|
|
||||||
highlight: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Team',
|
|
||||||
price: '€149',
|
|
||||||
tag: '/ month',
|
|
||||||
description: 'For teams with RBAC, audit, and 99.9% SLA needs.',
|
|
||||||
features: [
|
|
||||||
'25 MCP servers',
|
|
||||||
'10M tool calls / month',
|
|
||||||
'RBAC + extended audit log',
|
|
||||||
'99.9% uptime SLA',
|
|
||||||
'Shared Slack channel support',
|
|
||||||
],
|
|
||||||
cta: 'Start Team',
|
|
||||||
href: '/login',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Enterprise',
|
|
||||||
price: '€499+',
|
|
||||||
tag: '/ month',
|
|
||||||
description: 'For organizations bringing their own cloud, SSO and dedicated infra.',
|
|
||||||
features: [
|
|
||||||
'Unlimited servers',
|
|
||||||
'BYOC (AWS, GCP, Azure, Hetzner)',
|
|
||||||
'SSO / SAML',
|
|
||||||
'Dedicated cluster',
|
|
||||||
'Customer success manager',
|
|
||||||
],
|
|
||||||
cta: 'Contact sales',
|
|
||||||
href: 'mailto:sales@buildmymcpserver.com',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const FAQ = [
|
const FAQ = [
|
||||||
{
|
{
|
||||||
@@ -72,11 +16,11 @@ const FAQ = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'What happens if I exceed my quota?',
|
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?',
|
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?',
|
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-[28px] font-semibold tracking-tight">{t.price}</span>
|
||||||
<span className="text-[12px] text-[--color-fg-subtle]">{t.tag}</span>
|
<span className="text-[12px] text-[--color-fg-subtle]">{t.tag}</span>
|
||||||
</div>
|
</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]">
|
<ul className="mt-4 space-y-1.5 text-[12.5px] text-[--color-fg-muted]">
|
||||||
{t.features.map((f) => (
|
{t.features.map((f) => (
|
||||||
<li key={f}>— {f}</li>
|
<li key={f}>— {f}</li>
|
||||||
|
|||||||
@@ -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 = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -29,11 +36,21 @@ const SECTIONS = [
|
|||||||
{
|
{
|
||||||
h: 'Subprocessors',
|
h: 'Subprocessors',
|
||||||
p: [
|
p: [
|
||||||
'Anthropic (generation) — only the prompt text you send. Anthropic\'s data-retention policy applies.',
|
"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.",
|
||||||
'Hetzner (compute).',
|
'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).',
|
||||||
'Backblaze (encrypted backups).',
|
'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.',
|
||||||
'Stripe (billing).',
|
'Hetzner, Germany (compute, Postgres, Redis, runner containers).',
|
||||||
'Cloudflare (DNS + DDoS).',
|
'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>
|
<h2 className="text-[16px] font-semibold tracking-tight">Contact</h2>
|
||||||
<p className="mt-3 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
<p className="mt-3 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||||
Data controller: BuildMyMCPServer. Email{' '}
|
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
|
privacy@buildmymcpserver.com
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
for any of the above.
|
for any of the above.
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { CodeBlock } from '@/components/code-block';
|
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 = [
|
const PILLARS = [
|
||||||
{
|
{
|
||||||
@@ -18,7 +24,7 @@ const PILLARS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'No token passthrough',
|
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',
|
title: 'Static security checks',
|
||||||
@@ -34,7 +40,11 @@ const PILLARS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Rate limiting',
|
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.
|
Built like infrastructure.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
<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
|
We host code generated by an LLM, on behalf of customers, that exposes their internal APIs
|
||||||
APIs to AI clients. The threat model is real. Here is what we do about it.
|
to AI clients. The threat model is real. Here is what we do about it.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -67,7 +77,10 @@ export default function Security() {
|
|||||||
<h2 className="text-[18px] font-semibold tracking-tight">Disclosure</h2>
|
<h2 className="text-[18px] font-semibold tracking-tight">Disclosure</h2>
|
||||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||||
Found a vulnerability? Email{' '}
|
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
|
security@buildmymcpserver.com
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
with a clear reproduction. We respond within 48h. We do not run a paid bounty yet, but we
|
with a clear reproduction. We respond within 48h. We do not run a paid bounty yet, but we
|
||||||
|
|||||||
14
apps/web/app/(marketing)/status/layout.tsx
Normal file
14
apps/web/app/(marketing)/status/layout.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 = [
|
const SECTIONS = [
|
||||||
{
|
{
|
||||||
@@ -19,7 +26,7 @@ const SECTIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
h: '5. Service availability',
|
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',
|
h: '6. Billing',
|
||||||
|
|||||||
178
apps/web/app/admin/encryption/page.tsx
Normal file
178
apps/web/app/admin/encryption/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
LifeBuoy,
|
||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
Server,
|
Server,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Package,
|
Package,
|
||||||
|
KeyRound,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
@@ -28,6 +30,7 @@ interface MeUser {
|
|||||||
|
|
||||||
const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
|
const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: number }> }[] = [
|
||||||
{ href: '/admin', label: 'Overview', icon: LayoutGrid },
|
{ href: '/admin', label: 'Overview', icon: LayoutGrid },
|
||||||
|
{ href: '/admin/support', label: 'Support', icon: LifeBuoy },
|
||||||
{ href: '/admin/users', label: 'Users', icon: Users },
|
{ href: '/admin/users', label: 'Users', icon: Users },
|
||||||
{ href: '/admin/orgs', label: 'Organizations', icon: Building2 },
|
{ href: '/admin/orgs', label: 'Organizations', icon: Building2 },
|
||||||
{ href: '/admin/servers', label: 'MCP servers', icon: Server },
|
{ 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/builds', label: 'Builds', icon: Hammer },
|
||||||
{ href: '/admin/audit', label: 'Audit log', icon: FileClock },
|
{ href: '/admin/audit', label: 'Audit log', icon: FileClock },
|
||||||
{ href: '/admin/system', label: 'System health', icon: Activity },
|
{ href: '/admin/system', label: 'System health', icon: Activity },
|
||||||
|
{ href: '/admin/encryption', label: 'Encryption', icon: KeyRound },
|
||||||
{ href: '/admin/prompt', label: 'AI prompt', icon: Wand2 },
|
{ href: '/admin/prompt', label: 'AI prompt', icon: Wand2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -43,6 +47,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [user, setUser] = useState<MeUser | null>(null);
|
const [user, setUser] = useState<MeUser | null>(null);
|
||||||
const [authState, setAuthState] = useState<'checking' | 'ok' | 'forbidden'>('checking');
|
const [authState, setAuthState] = useState<'checking' | 'ok' | 'forbidden'>('checking');
|
||||||
|
const [supportPending, setSupportPending] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname === '/admin/login') {
|
if (pathname === '/admin/login') {
|
||||||
@@ -61,6 +66,26 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
.catch(() => setAuthState('forbidden'));
|
.catch(() => setAuthState('forbidden'));
|
||||||
}, [pathname]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (authState === 'forbidden' && pathname !== '/admin/login') {
|
if (authState === 'forbidden' && pathname !== '/admin/login') {
|
||||||
router.replace('/admin/login');
|
router.replace('/admin/login');
|
||||||
@@ -124,7 +149,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={13} />
|
<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>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
197
apps/web/app/admin/support/[id]/page.tsx
Normal file
197
apps/web/app/admin/support/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/web/app/admin/support/page.tsx
Normal file
128
apps/web/app/admin/support/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { ImageResponse } from 'next/og';
|
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 size = { width: 180, height: 180 };
|
||||||
export const contentType = 'image/png';
|
export const contentType = 'image/png';
|
||||||
|
|
||||||
export default function AppleIcon() {
|
export default function AppleIcon() {
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -17,12 +20,7 @@ export default function AppleIcon() {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<svg width="120" height="120" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
width="120"
|
|
||||||
height="120"
|
|
||||||
viewBox="0 0 32 32"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<title>BuildMyMCPServer</title>
|
<title>BuildMyMCPServer</title>
|
||||||
<path
|
<path
|
||||||
d="M8.5 22.5V9.5L16 16L23.5 9.5V22.5"
|
d="M8.5 22.5V9.5L16 16L23.5 9.5V22.5"
|
||||||
@@ -33,8 +31,7 @@ export default function AppleIcon() {
|
|||||||
fill="none"
|
fill="none"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>,
|
||||||
),
|
|
||||||
{ ...size },
|
{ ...size },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,26 @@ import {
|
|||||||
DocsCode,
|
DocsCode,
|
||||||
Mono,
|
Mono,
|
||||||
} from '@/components/docs-page';
|
} 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() {
|
export default function ApiReference() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbJsonLd([
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
{ name: 'Docs', path: '/docs' },
|
||||||
|
{ name: 'API reference', path: '/docs/api-reference' },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
<DocsTitle kicker="Reference">API reference</DocsTitle>
|
<DocsTitle kicker="Reference">API reference</DocsTitle>
|
||||||
<DocsLead>
|
<DocsLead>
|
||||||
Every endpoint on the control plane. Authenticated routes use the session cookie set by
|
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>
|
<DocsH2 id="oauth">OAuth (clients of generated servers, not dashboard)</DocsH2>
|
||||||
<DocsP>
|
<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>
|
||||||
<DocsP><Mono>GET /oauth/jwks</Mono> — RS256 public key for verifying access tokens.</DocsP>
|
<DocsP><Mono>GET /oauth/jwks</Mono> — RS256 public key for verifying access tokens.</DocsP>
|
||||||
<DocsP><Mono>POST /oauth/register</Mono> — RFC 7591 dynamic client registration.</DocsP>
|
<DocsP><Mono>POST /oauth/register</Mono> — RFC 7591 dynamic client registration.</DocsP>
|
||||||
|
|||||||
@@ -8,12 +8,26 @@ import {
|
|||||||
DocsCode,
|
DocsCode,
|
||||||
Mono,
|
Mono,
|
||||||
} from '@/components/docs-page';
|
} 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() {
|
export default function Authoring() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbJsonLd([
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
{ name: 'Docs', path: '/docs' },
|
||||||
|
{ name: 'Authoring tools', path: '/docs/authoring' },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
<DocsTitle kicker="Build">Authoring tools</DocsTitle>
|
<DocsTitle kicker="Build">Authoring tools</DocsTitle>
|
||||||
<DocsLead>
|
<DocsLead>
|
||||||
What you write in the prompt is what Claude turns into TypeScript. Better prompts mean
|
What you write in the prompt is what Claude turns into TypeScript. Better prompts mean
|
||||||
|
|||||||
@@ -8,12 +8,26 @@ import {
|
|||||||
DocsCode,
|
DocsCode,
|
||||||
Mono,
|
Mono,
|
||||||
} from '@/components/docs-page';
|
} 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() {
|
export default function Concepts() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbJsonLd([
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
{ name: 'Docs', path: '/docs' },
|
||||||
|
{ name: 'MCP concepts', path: '/docs/concepts' },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
<DocsTitle kicker="Get started">MCP concepts</DocsTitle>
|
<DocsTitle kicker="Get started">MCP concepts</DocsTitle>
|
||||||
<DocsLead>
|
<DocsLead>
|
||||||
Model Context Protocol is an open standard from Anthropic for connecting AI assistants to
|
Model Context Protocol is an open standard from Anthropic for connecting AI assistants to
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { DocsTitle, DocsLead, DocsH2, DocsP, Mono } from '@/components/docs-page';
|
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 }[] = [
|
const ITEMS: { q: string; a: React.ReactNode }[] = [
|
||||||
{
|
{
|
||||||
@@ -56,6 +63,13 @@ const ITEMS: { q: string; a: React.ReactNode }[] = [
|
|||||||
export default function Faq() {
|
export default function Faq() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbJsonLd([
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
{ name: 'Docs', path: '/docs' },
|
||||||
|
{ name: 'FAQ', path: '/docs/faq' },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
<DocsTitle kicker="Reference">FAQ</DocsTitle>
|
<DocsTitle kicker="Reference">FAQ</DocsTitle>
|
||||||
<DocsLead>Common questions, direct answers.</DocsLead>
|
<DocsLead>Common questions, direct answers.</DocsLead>
|
||||||
<div className="space-y-7">
|
<div className="space-y-7">
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { Logo } from '@/components/logo';
|
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 }[] }[] = [
|
const SECTIONS: { heading: string; items: { href: string; label: string }[] }[] = [
|
||||||
{
|
{
|
||||||
@@ -54,8 +62,8 @@ export default function DocsLayout({ children }: { children: React.ReactNode })
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-1 gap-12 px-6 py-10">
|
<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="w-[240px] shrink-0">
|
<aside className="hidden w-[240px] shrink-0 lg:block">
|
||||||
<nav className="sticky top-20 space-y-5">
|
<nav className="sticky top-20 space-y-5">
|
||||||
{SECTIONS.map((section) => (
|
{SECTIONS.map((section) => (
|
||||||
<div key={section.heading}>
|
<div key={section.heading}>
|
||||||
@@ -78,7 +86,7 @@ export default function DocsLayout({ children }: { children: React.ReactNode })
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,12 +8,26 @@ import {
|
|||||||
DocsCode,
|
DocsCode,
|
||||||
Mono,
|
Mono,
|
||||||
} from '@/components/docs-page';
|
} 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() {
|
export default function OAuthDocs() {
|
||||||
return (
|
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>
|
<DocsTitle kicker="Auth">OAuth 2.1 flow</DocsTitle>
|
||||||
<DocsLead>
|
<DocsLead>
|
||||||
Every generated server is an OAuth 2.1 Resource Server. The control plane is the
|
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>
|
<DocsH2 id="rfcs">Standards we follow</DocsH2>
|
||||||
<DocsList>
|
<DocsList>
|
||||||
<DocsLi>OAuth 2.1 draft (<Mono>draft-ietf-oauth-v2-1</Mono>) — no implicit, mandatory PKCE</DocsLi>
|
<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 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</Mono></DocsLi>
|
<DocsLi>RFC 9728 — Protected Resource Metadata at <Mono>/.well-known/oauth-protected-resource/<server-path></Mono></DocsLi>
|
||||||
<DocsLi>RFC 8707 — Resource Indicators (audience binding)</DocsLi>
|
<DocsLi>RFC 8707 — Resource Indicators (audience binding)</DocsLi>
|
||||||
<DocsLi>RFC 7591 — Dynamic Client Registration</DocsLi>
|
<DocsLi>RFC 7591 — Dynamic Client Registration</DocsLi>
|
||||||
</DocsList>
|
</DocsList>
|
||||||
@@ -41,7 +55,7 @@ export default function OAuthDocs() {
|
|||||||
code={`$ curl -i http://localhost:4103/mcp -d '{}' -H 'content-type: application/json'
|
code={`$ curl -i http://localhost:4103/mcp -d '{}' -H 'content-type: application/json'
|
||||||
|
|
||||||
HTTP/1.1 401 Unauthorized
|
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
|
content-type: application/json
|
||||||
|
|
||||||
{"error":"unauthorized"}`}
|
{"error":"unauthorized"}`}
|
||||||
@@ -53,10 +67,10 @@ content-type: application/json
|
|||||||
</DocsP>
|
</DocsP>
|
||||||
<DocsCode
|
<DocsCode
|
||||||
label="step 2 — resource metadata"
|
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"],
|
"authorization_servers": ["http://localhost:4000/oauth"],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
"scopes_supported": ["mcp:read"]
|
"scopes_supported": ["mcp:read"]
|
||||||
@@ -74,7 +88,7 @@ content-type: application/json
|
|||||||
"client_name": "Claude Desktop",
|
"client_name": "Claude Desktop",
|
||||||
"redirect_uris": ["claude://oauth/callback"],
|
"redirect_uris": ["claude://oauth/callback"],
|
||||||
"token_endpoint_auth_method": "none",
|
"token_endpoint_auth_method": "none",
|
||||||
"resource": "http://localhost:4103"
|
"resource": "http://localhost:4103/mcp"
|
||||||
}
|
}
|
||||||
|
|
||||||
201 Created
|
201 Created
|
||||||
@@ -94,7 +108,7 @@ content-type: application/json
|
|||||||
"code_verifier": "riSU-w1DT…",
|
"code_verifier": "riSU-w1DT…",
|
||||||
"client_id": "bmm_8aee2fe0…",
|
"client_id": "bmm_8aee2fe0…",
|
||||||
"redirect_uri": "claude://oauth/callback",
|
"redirect_uri": "claude://oauth/callback",
|
||||||
"resource": "http://localhost:4103"
|
"resource": "http://localhost:4103/mcp"
|
||||||
}
|
}
|
||||||
|
|
||||||
200 OK
|
200 OK
|
||||||
@@ -109,7 +123,7 @@ content-type: application/json
|
|||||||
<DocsP>
|
<DocsP>
|
||||||
Subsequent <Mono>/mcp</Mono> calls carry the JWT. The runner verifies the signature
|
Subsequent <Mono>/mcp</Mono> calls carry the JWT. The runner verifies the signature
|
||||||
against the AS's JWKS, checks the <Mono>iss</Mono>, the <Mono>aud</Mono>
|
against the AS's JWKS, checks the <Mono>iss</Mono>, the <Mono>aud</Mono>
|
||||||
(RFC 8707 — must match the runner's own public URL), and the expiry. No token
|
(RFC 8707 — must match the runner's MCP resource URL), and the expiry. No token
|
||||||
passthrough; the runner never forwards the client's token to a downstream API.
|
passthrough; the runner never forwards the client's token to a downstream API.
|
||||||
</DocsP>
|
</DocsP>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,14 @@ import {
|
|||||||
DocsCode,
|
DocsCode,
|
||||||
Mono,
|
Mono,
|
||||||
} from '@/components/docs-page';
|
} 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() {
|
export default function Quickstart() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,12 +8,26 @@ import {
|
|||||||
DocsCode,
|
DocsCode,
|
||||||
Mono,
|
Mono,
|
||||||
} from '@/components/docs-page';
|
} 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() {
|
export default function SelfHosting() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<JsonLd
|
||||||
|
data={breadcrumbJsonLd([
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
{ name: 'Docs', path: '/docs' },
|
||||||
|
{ name: 'Self-hosting', path: '/docs/self-hosting' },
|
||||||
|
])}
|
||||||
|
/>
|
||||||
<DocsTitle kicker="Build">Self-hosting</DocsTitle>
|
<DocsTitle kicker="Build">Self-hosting</DocsTitle>
|
||||||
<DocsLead>
|
<DocsLead>
|
||||||
The control plane and generator are open. Bring your own Postgres, Redis, Docker host and
|
The control plane and generator are open. Bring your own Postgres, Redis, Docker host and
|
||||||
|
|||||||
54
apps/web/app/feed.xml/route.ts
Normal file
54
apps/web/app/feed.xml/route.ts
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
Reference in New Issue
Block a user