Compare commits

...

15 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-31 12:08:05 +02:00
74 changed files with 5580 additions and 1088 deletions

View File

@@ -13,6 +13,9 @@ BETTER_AUTH_SECRET=replace-me-with-32-bytes-of-random-hex-1234567890abcdef
BETTER_AUTH_URL=http://localhost:3001
NEXT_PUBLIC_APP_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:4000
# Google Search Console HTML-tag verification token (content attribute only).
# Leave empty in dev; set in production, then submit /sitemap.xml in GSC.
NEXT_PUBLIC_GSC_VERIFICATION=
# ---- GitHub OAuth ("Continue with GitHub") ----
# Create at https://github.com/settings/applications/new

View File

@@ -1,6 +1,15 @@
import type { Plan } from '@bmm/llm';
import { Queue } from 'bullmq';
import { getRedis } from './redis.js';
// BullMQ priority: LOWER number = processed sooner. Paid tiers jump ahead of
// free in the shared build queue — this is what makes the "priority build
// queue" plan claim actually true.
const PLAN_PRIORITY: Record<Plan, number> = { enterprise: 1, team: 2, pro: 3, hobby: 4 };
export function buildPriority(plan: Plan): number {
return PLAN_PRIORITY[plan] ?? 4;
}
export interface BuildJobData {
buildId: string;
serverId: string;

View File

@@ -10,9 +10,20 @@ import { getRedis } from './redis.js';
*/
export const stripe: Stripe | null = config.STRIPE_SECRET_KEY
? new Stripe(config.STRIPE_SECRET_KEY, {
// biome-ignore lint/suspicious/noExplicitAny: SDK type lags behind real API version strings
apiVersion: '2025-10-29.acacia' as any,
// 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;

View File

@@ -6,6 +6,7 @@ import {
eq,
inArray,
mcpServers,
memberships,
organizations,
supportMessages,
supportTickets,
@@ -14,8 +15,11 @@ import {
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> {
@@ -179,4 +183,87 @@ export async function accountRoutes(app: FastifyInstance): Promise<void> {
supportMessages: userTicketMessages,
});
});
/**
* GDPR Art. 17 / Swiss DSG Art. 32 — right to erasure. Self-service account
* deletion. Requires the caller to re-type their email (or phone) as a
* confirmation guard. For every org where the caller is the SOLE member, we
* stop its running containers and hard-delete the org (FK cascade removes its
* servers, builds, logs and encrypted secrets). Orgs with other members are
* left intact — only the caller's membership goes. Finally the user row is
* deleted (cascade drops sessions; audit/ticket/template refs are set null).
*/
app.delete('/v1/account', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Body = z.object({ confirm: z.string().min(1) });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const [row] = await db
.select({ email: users.email, phone: users.phone })
.from(users)
.where(eq(users.id, user.userId))
.limit(1);
if (!row) return reply.code(404).send({ error: 'user_not_found' });
// Confirmation: must match the account's own email or phone.
const expected = (row.email ?? row.phone ?? '').trim().toLowerCase();
if (!expected || parsed.data.confirm.trim().toLowerCase() !== expected) {
return reply.code(400).send({
error: 'confirm_mismatch',
detail: 'Type your account email (or phone) exactly to confirm deletion.',
});
}
const memberRows = await db
.select({ orgId: memberships.orgId })
.from(memberships)
.where(eq(memberships.userId, user.userId));
const orgIds = [...new Set(memberRows.map((m) => m.orgId))];
const deletedOrgIds: string[] = [];
for (const orgId of orgIds) {
const members = await db
.select({ userId: memberships.userId })
.from(memberships)
.where(eq(memberships.orgId, orgId));
// Only erase the org if this user is its sole member — never nuke a
// teammate's data. (Multi-member orgs: just the membership is dropped
// when the user row is deleted below.)
if (members.length > 1) continue;
// Stop live containers before the cascade removes their DB rows.
const servers = await db
.select({ containerId: mcpServers.containerId, slug: mcpServers.slug })
.from(mcpServers)
.where(eq(mcpServers.orgId, orgId));
for (const s of servers) {
if (s.containerId) {
try {
await stopContainer(s.containerId, s.slug ?? undefined);
} catch {
// best-effort — a leftover container must not block erasure
}
}
}
await db.delete(organizations).where(eq(organizations.id, orgId));
deletedOrgIds.push(orgId);
}
// Audit while userId is still valid (the row survives erasure; its userId
// is set null by cascade). No orgId — those rows are already gone.
await audit({
userId: user.userId,
action: 'account.deleted',
resourceType: 'account',
metadata: { deletedOrgIds, email: row.email ?? null },
ipAddress: req.ip,
});
// Delete the user — cascade removes sessions; nulls audit/ticket/template refs.
await db.delete(users).where(eq(users.id, user.userId));
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return reply.send({ ok: true, deletedOrgIds });
});
}

View File

@@ -28,6 +28,7 @@ import {
IterateServerInput,
PreviewInput,
type SpecEdit,
findSecretInPrompt,
} from '@bmm/types';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
@@ -37,7 +38,7 @@ 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 { getBuildQueue } from '../lib/queue.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';
@@ -62,6 +63,16 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
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) {
@@ -190,6 +201,16 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
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) {
@@ -525,7 +546,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.returning();
if (!build) return reply.code(500).send({ error: 'build_create_failed' });
await getBuildQueue().add('generate', {
await getBuildQueue().add(
'generate',
{
buildId: build.id,
serverId: server.id,
orgId: user.orgId,
@@ -535,7 +558,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
serverName: name,
secrets: secretValues,
previewId,
});
},
{ priority: buildPriority(plan) },
);
await audit({
orgId: user.orgId,
@@ -631,7 +656,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.set({ status: 'queued', updatedAt: new Date() })
.where(eq(mcpServers.id, server.id));
await getBuildQueue().add('generate', {
await getBuildQueue().add(
'generate',
{
buildId: build.id,
serverId: server.id,
orgId: user.orgId,
@@ -640,7 +667,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
slug: server.slug,
serverName: server.name,
secrets: parsed.data.secrets,
});
},
{ priority: buildPriority(billing.plan) },
);
await audit({
orgId: user.orgId,

View File

@@ -17,12 +17,17 @@ interface ServerRow {
export default function Overview() {
const [servers, setServers] = useState<ServerRow[] | null>(null);
const [plan, setPlan] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
apiFetch<{ servers: ServerRow[] }>('/v1/servers')
.then((r) => setServers(r.servers))
.catch((e) => setErr((e as Error).message));
// Real plan from billing status — never render a hardcoded tier.
apiFetch<{ plan: string }>('/v1/billing/status')
.then((r) => setPlan(r.plan))
.catch(() => setPlan(null));
}, []);
if (err?.includes('401')) {
@@ -46,8 +51,16 @@ export default function Overview() {
<div className="mt-6 grid gap-3 md:grid-cols-3">
<Card label="Servers" value={total.toString()} sub={`${live} live`} />
<Card label="Calls this period" value="0" sub="of 100,000" />
<Card label="Plan" value="Hobby" sub="Upgrade in Settings" />
<Card
label="Calls this period"
value="—"
sub="Per-server metrics live on each server page"
/>
<Card
label="Plan"
value={plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : '—'}
sub="Manage in Settings → Billing"
/>
</div>
<div className="mt-10">

View File

@@ -5,9 +5,9 @@ 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 } from '@/lib/api';
import { apiFetch, apiSseStream, humanizeError } from '@/lib/api';
import { findSecretInPrompt } from '@bmm/types';
import { Loader2, RotateCcw, X } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
@@ -240,6 +240,16 @@ function NewServerPageInner() {
setError('Name and slug are required.');
return;
}
// Keep credentials out of the model: block a prompt that contains a real
// key/token before it is ever sent. Credentials go in the encrypted fields
// in the next step, never in the prompt.
const leaked = findSecretInPrompt(prompt);
if (leaked) {
setError(
`Looks like your prompt contains ${leaked}. Remove it — API keys must never go in the prompt (it is sent to the AI). You'll add credentials in their own encrypted fields in the next step.`,
);
return;
}
setStep('analyzing');
// Streaming preview: pipes Anthropic's token deltas back as SSE. Cloudflare's
@@ -291,8 +301,7 @@ function NewServerPageInner() {
setStep('confirm');
return;
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
setError(humanizeError(e));
setStep('prompt');
return;
}
@@ -447,7 +456,7 @@ function NewServerPageInner() {
setError(detail?.detail ?? 'Daily build limit reached — try again tomorrow or upgrade.');
return;
}
setError(detail?.detail ?? code ?? (e as Error).message);
setError(humanizeError(e));
}
}
@@ -480,7 +489,11 @@ function NewServerPageInner() {
/>
<p className="text-[12px] leading-relaxed text-[--color-fg-subtle]">
Next step we&apos;ll show you exactly which tools we&apos;ll expose and let you tweak
the spec before we build.
the spec before we build.{' '}
<span className="text-[--color-fg-muted]">
Don&apos;t paste API keys or access tokens here you&apos;ll add each one in its own
encrypted field in the next step.
</span>
</p>
<div className="flex flex-wrap gap-1.5 pt-1">
{EXAMPLE_PROMPTS.map((p) => (
@@ -544,14 +557,6 @@ function NewServerPageInner() {
drafting the tool spec. Usually{' '}
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}.
</p>
{userPlan === 'hobby' && (
<p className="mt-2 text-[11px] text-[--color-fg-muted]">
<Link href="/pricing" className="text-[--color-accent] hover:underline">
Upgrade to Pro
</Link>{' '}
for ~3× faster analysis with Claude Haiku.
</p>
)}
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
{elapsedSec}s elapsed
</p>
@@ -601,6 +606,31 @@ function NewServerPageInner() {
</div>
</div>
)}
{!forkedTemplateTitle && (
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="confirm-name">Name</Label>
<Input
id="confirm-name"
value={name}
onChange={(e) => {
setName(e.target.value);
if (!slug || slug === trySlug(name)) setSlug(trySlug(e.target.value));
}}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-slug" hint="must be unique in your workspace · part of the URL">
Slug
</Label>
<Input
id="confirm-slug"
value={slug}
onChange={(e) => setSlug(trySlug(e.target.value))}
/>
</div>
</div>
)}
<div className="panel p-4">
<div className="flex items-baseline justify-between">
<h2 className="text-[14px] font-semibold tracking-tight">
@@ -675,15 +705,18 @@ function NewServerPageInner() {
</div>
<div>
<h3 className="text-[13px] font-semibold tracking-tight">Credentials we need</h3>
<h3 className="text-[13px] font-semibold tracking-tight">API keys &amp; credentials</h3>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
implementation doesn&apos;t actually use one.
One field per key or access token entered here, separately from your prompt.
AES-256-GCM encrypted at rest, injected as env vars at runtime only. Remove any your
implementation doesn&apos;t use; add any we missed.
</p>
<div className="mt-3 space-y-2">
{editable.requiredSecrets.length === 0 && (
<p className="text-[12.5px] text-[--color-fg-muted]">
No credentials. This server runs self-contained.
None detected. If your tool calls an API that needs a key or access token, add it
below with <span className="mono">+ Add credential</span> never put secrets in
the prompt.
</p>
)}
{editable.requiredSecrets.map((key, idx) => (

View File

@@ -1,12 +1,33 @@
'use client';
import { Button } from '@/components/ui/button';
import { apiUrl } from '@/lib/api';
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);
@@ -42,20 +63,35 @@ export default function AccountPage() {
</div>
</section>
<section className="panel p-5">
<h2 className="text-[14px] font-semibold tracking-tight">Delete account</h2>
<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]">
We don&apos;t do one-click account deletion yet too easy to fat-finger and lose
paid-tier server configs. Open a ticket and we&apos;ll wipe everything within 30
days (servers, secrets, audit, tickets) per Swiss DSG Art. 32 / GDPR Art. 17.
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>
<div className="mt-4">
<Link href="/settings/support">
<Button variant="secondary" size="md">
Open deletion ticket
</Button>
</Link>
<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">

View File

@@ -306,7 +306,7 @@ function BillingInner() {
name="Pro"
monthly={49}
yearly={490}
features={['5 MCP servers', '1M tool calls / mo', 'Custom domain', 'Claude Haiku 4.5']}
features={['5 MCP servers', '1M tool calls / mo', 'Priority build queue', 'Claude AI']}
busy={busy}
onSubscribe={startCheckout}
monthlyTier="pro_monthly"
@@ -320,8 +320,8 @@ function BillingInner() {
features={[
'25 MCP servers',
'10M tool calls / mo',
'RBAC + audit log',
'Claude Sonnet 4.6',
'Audit log',
'Claude AI',
]}
busy={busy}
onSubscribe={startCheckout}

View File

@@ -20,7 +20,7 @@ const SECTIONS: Array<{ h: string; p: string[] }> = [
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" mit angestrebter Verfügbarkeit gemäss tariflicher SLA (Hobby/Pro: keine SLA; Team: 99.9% monatlich; Enterprise: vertraglich vereinbart).',
'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.',
],
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/80 backdrop-blur-md">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-5 sm:px-6">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5 sm:px-6">
<div className="flex items-center gap-6">
<Logo />
<nav className="hidden items-center gap-5 text-[13px] text-[--color-fg-muted] md:flex">
@@ -24,6 +24,9 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
<Link href="/docs" className="transition-colors hover:text-[--color-fg]">
Docs
</Link>
<Link href="/guides" className="transition-colors hover:text-[--color-fg]">
Guides
</Link>
<Link href="/changelog" className="transition-colors hover:text-[--color-fg]">
Changelog
</Link>
@@ -36,42 +39,83 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t border-[--color-border] py-8">
<div className="mx-auto flex max-w-6xl flex-col gap-4 px-6 text-[12px] text-[--color-fg-subtle] md:flex-row md:items-center md:justify-between">
<footer className="border-t border-[--color-border] py-12">
<div className="mx-auto max-w-6xl px-6">
<div className="grid gap-10 sm:grid-cols-2 md:grid-cols-4">
{/* Brand column — positioning line + live status. */}
<div className="sm:col-span-2 md:col-span-1">
<Logo />
<p className="mt-3 max-w-xs text-[12.5px] leading-relaxed text-[--color-fg-muted]">
From a prompt to a hosted, OAuth-protected MCP server for Claude, Cursor and
ChatGPT.
</p>
<Link
href="/status"
className="flex items-center gap-2 transition-colors hover:text-[--color-fg]"
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-emerald-400" />
<span className="size-1.5 animate-pulse rounded-full bg-[--color-success]" />
<span>System status</span>
</Link>
<div className="flex flex-wrap gap-x-5 gap-y-1">
<Link href="/docs" className="transition-colors hover:text-[--color-fg]">
Docs
</Link>
<Link href="/contact" className="transition-colors hover:text-[--color-fg]">
Contact
</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="/agb" className="transition-colors hover:text-[--color-fg]">
AGB
</Link>
<Link href="/impressum" className="transition-colors hover:text-[--color-fg]">
Impressum
</Link>
<Link href="/terms" className="transition-colors hover:text-[--color-fg]">
Terms
</Link>
</div>
<div>&copy; {new Date().getFullYear()} BuildMyMCPServer</div>
<FooterColumn
title="Product"
links={[
{ href: '/templates', label: 'Templates' },
{ href: '/pricing', label: 'Pricing' },
{ href: '/changelog', label: 'Changelog' },
{ href: '/security', label: 'Security' },
]}
/>
<FooterColumn
title="Resources"
links={[
{ href: '/docs', label: 'Docs' },
{ href: '/guides', label: 'Guides' },
{ href: '/docs/faq', label: 'FAQ' },
{ href: '/contact', label: 'Contact' },
]}
/>
<FooterColumn
title="Legal"
links={[
{ href: '/privacy', label: 'Privacy' },
{ href: '/terms', label: 'Terms' },
{ href: '/agb', label: 'AGB' },
{ href: '/impressum', label: 'Impressum' },
]}
/>
</div>
<div className="mt-10 border-t border-[--color-border] pt-6 text-[12px] text-[--color-fg-subtle]">
&copy; {new Date().getFullYear()} BuildMyMCPServer
</div>
</div>
</footer>
<CookieBanner />
</div>
);
}
function FooterColumn({
title,
links,
}: {
title: string;
links: { href: string; label: string }[];
}) {
return (
<div>
<h3 className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[--color-fg-muted]">
{title}
</h3>
<ul className="mt-3 space-y-2 text-[12.5px] text-[--color-fg-muted]">
{links.map((l) => (
<li key={l.href}>
<Link href={l.href} className="transition-colors hover:text-[--color-fg]">
{l.label}
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@@ -12,31 +12,11 @@ import { JsonLd } from '@/components/json-ld';
import { ParticleHero } from '@/components/particle-hero';
import { PulseLink } from '@/components/pulse';
import { ScrollCue } from '@/components/scroll-cue';
import { StaticCodeBlock } from '@/components/static-code-block';
import { TIERS } from '@/lib/pricing';
import { FAQ, faqJsonLd } from '@/lib/seo';
import { ChevronDown } from 'lucide-react';
import type { ComponentType } from 'react';
import { Activity, ChevronDown, Container, ShieldCheck } from 'lucide-react';
import Link from 'next/link';
const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace.
Tools: search_pages, get_page_content.
Auth: NOTION_API_KEY.`;
const OUTPUT_EXAMPLE = `> Generating spec... OK (2 tools)
> Static checks OK
> Building image bmm-mcp-notion OK 17.2s
> Deploying container OK
> Live at https://notion-x9.mcp.buildmymcpserver.com
> First request: 401 → token → 200 OK`;
const INSTALL_SNIPPET = `{
"mcpServers": {
"notion": {
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
"auth": "oauth2"
}
}
}`;
import type { ComponentType } from 'react';
interface ExampleEntry {
title: string;
@@ -50,41 +30,58 @@ interface ExampleEntry {
}
const EXAMPLES: ExampleEntry[] = [
{ title: 'PostgreSQL', desc: 'Read-only access to your tables with schema introspection.', Icon: PostgresIcon, bg: '#336791', fg: '#ffffff' },
{ title: 'Salesforce', desc: 'Query opportunities, accounts and leads from Claude.', Icon: SalesforceCloudIcon, bg: '#00a1e0', fg: '#ffffff' },
{ title: 'Notion', desc: 'Search pages, read content, append blocks.', Icon: NotionIcon, bg: '#ffffff', fg: '#0a0a0b' },
{ title: 'GitHub', desc: 'List issues, search code, post comments. Scoped to one repo.', Icon: GitHubIcon, bg: '#181717', fg: '#ffffff' },
{ title: 'Stripe', desc: 'Look up charges, customers, refunds (read-only by default).', Icon: StripeIcon, bg: '#635bff', fg: '#ffffff' },
{ title: 'Custom REST',desc: 'Wrap any HTTP API behind one prompt-defined tool surface.', Icon: RestIcon, bg: '#6366f1', fg: '#ffffff' },
{
title: 'PostgreSQL',
desc: 'Read-only access to your tables with schema introspection.',
Icon: PostgresIcon,
bg: '#336791',
fg: '#ffffff',
},
{
title: 'Salesforce',
desc: 'Query opportunities, accounts and leads from Claude.',
Icon: SalesforceCloudIcon,
bg: '#00a1e0',
fg: '#ffffff',
},
{
title: 'Notion',
desc: 'Search pages, read content, append blocks.',
Icon: NotionIcon,
bg: '#ffffff',
fg: '#0a0a0b',
},
{
title: 'GitHub',
desc: 'List issues, search code, post comments. Scoped to one repo.',
Icon: GitHubIcon,
bg: '#181717',
fg: '#ffffff',
},
{
title: 'Stripe',
desc: 'Look up charges, customers, refunds (read-only by default).',
Icon: StripeIcon,
bg: '#635bff',
fg: '#ffffff',
},
{
title: 'Custom REST',
desc: 'Wrap any HTTP API behind one prompt-defined tool surface.',
Icon: RestIcon,
bg: '#6366f1',
fg: '#ffffff',
},
];
interface ClientEntry {
name: string;
/** Single-character mark for inline visual identity. */
mark: string;
}
const CLIENTS: ClientEntry[] = [
{ name: 'Claude Desktop', mark: 'C' },
{ name: 'Cursor', mark: '⌘' },
{ name: 'ChatGPT', mark: '✦' },
{ name: 'VS Code Copilot', mark: '<>' },
{ name: 'Continue.dev', mark: '→' },
];
interface MockTemplate {
name: string;
author: string;
tools: number;
forks: number;
verified: boolean;
}
const MOCK_TEMPLATES: MockTemplate[] = [
{ name: 'notion-search', author: 'core', tools: 2, forks: 247, verified: true },
{ name: 'github-issues', author: 'core', tools: 3, forks: 89, verified: true },
{ name: 'stripe-readonly', author: 'core', tools: 4, forks: 156, verified: true },
{ name: 'linear-tasks', author: 'community', tools: 5, forks: 34, verified: false },
// Honest marketplace preview data: template names + tool counts only. These
// mirror the first-party starter templates; no invented fork counts, no
// "verified" theatre — the frame is labelled as a preview of the card format.
const PREVIEW_TEMPLATES: { name: string; author: string; tools: number }[] = [
{ name: 'notion-search', author: 'core', tools: 2 },
{ name: 'github-issues', author: 'core', tools: 3 },
{ name: 'stripe-readonly', author: 'core', tools: 4 },
{ name: 'postgres-readonly', author: 'core', tools: 3 },
];
const MARKETPLACE_POINTS: { t: string; d: string }[] = [
@@ -97,167 +94,45 @@ const MARKETPLACE_POINTS: { t: string; d: string }[] = [
d: "A template carries the spec and generated code, never the author's API keys. You add your own on fork.",
},
{
t: 'Ranked by real usage',
d: 'Templates rise on fork count and active deploys, not vanity stars. The useful ones surface themselves.',
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.',
},
];
const TIERS = [
// 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 }>;
}[] = [
{
name: 'Hobby',
price: '€0',
tag: 'Forever free',
features: ['1 server', '100k calls/mo', 'BMM subdomain', 'Community support'],
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,
},
{
name: 'Pro',
price: '€49',
tag: '/ month',
features: [
'5 servers',
'1M calls/mo',
'Custom domain',
'Priority build queue',
'Email support',
],
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,
},
{
name: 'Team',
price: '€149',
tag: '/ month',
features: ['25 servers', '10M calls/mo', 'RBAC + audit log', 'SLA 99.9%', 'Slack support'],
},
{
name: 'Enterprise',
price: '€499+',
tag: '/ month',
features: ['Unlimited', 'BYOC', 'SSO / SAML', 'Dedicated cluster', 'Customer success'],
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,
},
];
export default function Landing() {
return (
<>
{/* Hero — left: copy + CTAs, right: cycling step-rotator tile.
The old layout stacked three static code blocks vertically; the
new layout shows one centered tile that cycles through the same
three artifacts (prompt → build.log → claude config) with a
mouse-reactive 3D tilt and a step indicator. Shorter overall
so the video section below is teased above the fold. */}
<section
className="relative flex items-center overflow-hidden border-b border-[--color-border]"
style={{ minHeight: 'calc(100svh - 3rem)' }}
>
{/* WebGL particle field — capability-detected client component.
Sits behind the hero content at z-0 with pointer-events:none
so the CTAs above remain fully interactive. The canvas listens
for pointermove on window itself, so the ring still tracks
the cursor through the content above. With the hero now
filling the full first-viewport (minus the 48px sticky nav),
the field has cinematic-scale room and the indigo radial
glow + dot mask read as the dominant background motif. */}
<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">
<span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]">
v0.1 · updated 2026-05-20
</span>
<h1 className="mt-6 text-balance text-[30px] font-semibold leading-[1.06] tracking-tight sm:text-[40px] md:text-[52px]">
Describe your tool.
<br />
We host the server.
<br />
<span className="text-[--color-fg-muted]">AI uses it.</span>
</h1>
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-[--color-fg-muted]">
From prompt to production MCP server in 60 seconds. OAuth 2.1, Streamable HTTP, ready
for Claude, Cursor and ChatGPT.
</p>
<div className="mt-7 flex flex-wrap items-center gap-3">
<PulseLink
href="/login"
className="inline-flex h-9 items-center justify-center rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
>
Start building free
</PulseLink>
<PulseLink
href="/docs"
className="inline-flex h-9 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-4 text-[13px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Read the docs
</PulseLink>
</div>
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-2 text-[12px] text-[--color-fg-subtle]">
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> OAuth 2.1 + PKCE
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> Streamable HTTP
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> AES-256 secrets
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" /> Per-server isolation
</span>
</div>
</div>
<div className="relative min-w-0">
<HeroStepRotator />
</div>
</div>
</section>
{/* Scroll cue — fixed at the bottom of the loadscreen rather than
inside the hero, so it sits at the natural lower edge of the
first viewport regardless of how tall the hero ends up. Fades
out once the user has scrolled past the loadscreen. */}
<ScrollCue targetId="flow" />
{/* Flow video — full-width edge-to-edge under the hero. The clip
shows the real flow (prompt → server schematic → live connection
to Claude Desktop) in three smooth phases. autoplay-muted-loop +
playsInline satisfies every mobile browser autoplay policy; the
`poster` carries first paint while the video decodes. */}
<section
id="flow"
className="relative w-full overflow-hidden border-b border-[--color-border] bg-black"
>
<div className="relative aspect-video w-full">
{/* HeroVideo: native <video> with autoplay+muted+loop, plus a
frosted mute toggle pinned bottom-right so visitors can
switch the narration on. See components/hero-video.tsx for
the autoplay/unmute mechanics. */}
<HeroVideo />
{/* Subtle vignette to integrate edges into the rest of the page */}
<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 */}
<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">
<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>
{/* The same video used to live here; it now has its own
full-width section directly under the hero so it's teased
above the fold and gets edge-to-edge real estate. This
section keeps the three explanatory cards as supporting
copy under the video. */}
<div className="grid gap-6 md:grid-cols-3">
{[
const PIPELINE_STEPS: { n: string; t: string; d: string }[] = [
{
n: '01',
t: 'Describe your tool',
@@ -273,71 +148,199 @@ export default function Landing() {
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]">
];
/** Mono shell-comment section kicker: `## how_it_works` */
function Kicker({ children }: { children: string }) {
return <p className="kicker">## {children}</p>;
}
export default function Landing() {
const teaserTiers = TIERS.filter((t) => t.name === 'Hobby' || t.name === 'Pro');
return (
<>
{/* Hero — left: copy + CTAs, right: cycling terminal tile. The WebGL
particle field sits behind at z-0 with pointer-events:none so the
CTAs stay interactive. */}
<section
className="relative flex items-center overflow-hidden border-b border-[--color-border]"
style={{ minHeight: 'calc(100svh - 3.5rem)' }}
>
<ParticleHero />
<div className="relative z-10 mx-auto grid w-full max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12">
<div className="min-w-0">
<h1 className="text-balance text-[36px] font-semibold leading-[1.04] tracking-[-0.03em] sm:text-[44px] md:text-[60px]">
Describe your tool.
<br />
We host the server.
<br />
<span className="text-[--color-fg-muted]">AI uses it.</span>
</h1>
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-[--color-fg-muted] sm:text-[16px]">
From a prompt to a production MCP server OAuth 2.1, Streamable HTTP, ready for
Claude, Cursor and ChatGPT.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<PulseLink
href="/login"
className="btn-brand inline-flex h-11 items-center justify-center rounded-md px-5 text-[14px] font-medium"
>
Start building free
</PulseLink>
<PulseLink
href="#flow"
className="inline-flex h-11 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-5 text-[14px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Watch a build
</PulseLink>
</div>
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-2 text-[12px] text-[--color-fg-muted]">
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-[--color-success]" /> OAuth 2.1 + PKCE
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-[--color-success]" /> Streamable HTTP
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-[--color-success]" /> AES-256 secrets
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-[--color-success]" /> Per-server isolation
</span>
</div>
</div>
<div className="relative min-w-0">
<HeroStepRotator />
</div>
</div>
</section>
<ScrollCue targetId="flow" />
{/* Flow video — full-width edge-to-edge under the hero. Plays when
scrolled into view (see hero-video.tsx); preload=metadata keeps the
2.6 MB mp4 off the critical path on mobile. */}
<section
id="flow"
className="relative w-full overflow-hidden border-b border-[--color-border] bg-black"
>
<div className="relative aspect-video w-full">
<HeroVideo />
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(ellipse at center, transparent 60%, rgba(10,10,11,0.55) 100%)',
}}
/>
</div>
</section>
{/* How it works — three pipeline nodes joined by a gradient connector.
Desktop: horizontal line behind the numbered nodes. Mobile: the grid
stacks and each card keeps its own node, joined by a left rail. */}
<section id="how" className="border-b border-[--color-border] py-14 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-10 max-w-2xl">
<Kicker>how_it_works</Kicker>
<h2 className="mt-3 text-[28px] font-semibold tracking-tight sm:text-[32px]">
Three steps. No JSON to write, no Docker to manage.
</h2>
</div>
<div className="relative">
{/* Gradient connector — desktop only, sits behind the node row. */}
<div
aria-hidden
className="absolute left-0 right-0 top-[22px] hidden h-px md:block"
style={{ background: 'var(--gradient-brand)', opacity: 0.35 }}
/>
{/* Mobile left rail joining the stacked nodes. */}
<div
aria-hidden
className="absolute bottom-6 left-[22px] top-[22px] w-px md:hidden"
style={{ background: 'var(--gradient-brand)', opacity: 0.35 }}
/>
<div className="grid gap-8 md:grid-cols-3 md:gap-6">
{PIPELINE_STEPS.map((s) => (
<div key={s.n} className="relative flex gap-4 md:block">
<div
className="mono relative z-10 flex size-11 shrink-0 items-center justify-center rounded-full border border-[--color-border-strong] text-[12px] tracking-widest text-[--color-fg]"
style={{
background: 'var(--color-bg)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.05)',
}}
>
{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>
{/* ── Clients · trust-signal row ─────────────────────────────────
Archetype: typographic logo row. No panels, no bullets, just
well-spaced word-marks with a small symbolic mark. The whole
section breathes — generous py — so it reads as a moment of
trust, not a feature card. */}
<section className="border-b border-[--color-border] bg-[--color-bg] py-20 sm:py-24">
<div className="mx-auto max-w-6xl px-6">
<p className="text-center text-[11px] uppercase tracking-[0.24em] text-[--color-fg-subtle]">
Connects everywhere your AI lives
<div className="min-w-0 md:mt-5">
<h3 className="text-[15px] font-semibold tracking-tight">{s.t}</h3>
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted]">
{s.d}
</p>
<div className="mt-12 flex flex-wrap items-center justify-center gap-x-14 gap-y-7 sm:gap-x-20">
{CLIENTS.map((c) => (
<div
key={c.name}
className="group flex items-center gap-3 transition-colors"
>
<span
aria-hidden
className="mono flex size-7 items-center justify-center rounded-md border border-[--color-border] text-[12px] text-[--color-fg-muted] transition-colors group-hover:border-[--color-accent] group-hover:text-[--color-accent]"
>
{c.mark}
</span>
<span className="text-[17px] font-medium tracking-tight text-[--color-fg-muted] transition-colors group-hover:text-[--color-fg]">
{c.name}
</span>
</div>
</div>
))}
</div>
</div>
</div>
</section>
{/* ── Examples · integration grid with brand-coloured marks ──────
Archetype: 2-col header (title left, supporting copy right) +
asymmetric 3-col card grid where each card carries a coloured
square brand mark for the service. The marks give each card
its own visual identity, breaking the "every card looks the
same" pattern of the previous version. */}
<section className="border-b border-[--color-border] py-20 sm:py-28">
{/* Proof by specificity — three verifiable claims, each one click from
its evidence. Replaces the old pseudo-logo row: no invented marks,
just a plain-text compatibility line. */}
<section className="py-16 sm:py-24">
<div className="mx-auto max-w-6xl px-6">
<Kicker>verify_it_yourself</Kicker>
<h2 className="mt-3 max-w-2xl text-[32px] font-semibold leading-[1.1] tracking-tight sm:text-[40px]">
Don&apos;t take our word for it.
<br />
<span className="text-[--color-fg-muted]">Every claim links to its proof.</span>
</h2>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{PROOF_POINTS.map((p) => {
const Icon = p.Icon;
return (
<Link
key={p.t}
href={p.href}
className="panel-raised group flex flex-col p-5 transition-colors hover:border-[--color-border-strong]"
>
<Icon size={20} className="text-[--color-accent]" />
<h3 className="mt-4 text-[15px] font-semibold tracking-tight">{p.t}</h3>
<p className="mt-2 flex-1 text-[13px] leading-relaxed text-[--color-fg-muted]">
{p.d}
</p>
<span className="mt-4 text-[13px] font-medium text-[--color-accent] transition-colors group-hover:text-[--color-fg]">
{p.linkLabel}
</span>
</Link>
);
})}
</div>
<p className="mt-10 text-center text-[13px] text-[--color-fg-muted]">
Works with Claude Desktop, Cursor, ChatGPT, VS Code Copilot and Continue.dev anything
that speaks MCP.
</p>
</div>
</section>
{/* Use cases — brand-coloured integration grid. No bottom hairline:
the marketplace section below brings its own border-y. */}
<section className="py-20 sm:py-28">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-12 grid gap-6 md:grid-cols-[1fr_auto] md:items-end md:gap-12">
<div>
<p className="text-[11px] uppercase tracking-[0.22em] text-[--color-fg-subtle]">
Use cases
</p>
<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]">In minutes.</span>
<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]">
Real integrations our customers ship today. Built from one prompt each.
Each shipped from a single prompt.
</p>
</div>
@@ -347,7 +350,7 @@ export default function Landing() {
return (
<div
key={e.title}
className="group flex items-start gap-4 rounded-xl border border-[--color-border] bg-[--color-bg-elevated] p-5 transition-colors hover:border-[--color-border-strong]"
className="panel-raised group flex items-start gap-4 p-5 transition-colors hover:border-[--color-border-strong]"
>
<div
aria-hidden
@@ -372,27 +375,21 @@ export default function Landing() {
</div>
</section>
{/* ── Marketplace · two-column with product preview ──────────────
Archetype: split layout. Left column carries the headline,
three bullet-form selling points, and the CTA. Right column
carries a mock browser frame containing real-looking template
cards — so the visitor SEES the marketplace, not just reads
a description of it. */}
<section className="border-b border-[--color-border] py-20 sm:py-28">
{/* 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">
{/* Left — text + points + CTA */}
<div>
<p className="text-[11px] uppercase tracking-[0.22em] text-[--color-fg-subtle]">
Marketplace
</p>
<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 the community already built.
Fork one, paste your credentials, deploy. Or publish yours and let others build on it.
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">
@@ -414,29 +411,22 @@ export default function Landing() {
<PulseLink
href="/templates"
className="mt-8 inline-flex h-9 items-center gap-2 rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
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>
{/* Right — mock marketplace browser frame */}
<MarketplaceMock />
<MarketplacePreview />
</div>
</section>
{/* ── Pricing · 4 cards with Pro highlighted as recommended ─────
Archetype: centred header + 4-card row where the Pro tier is
visually distinct (accent border, indigo glow shadow, floating
"recommended" pill, slightly elevated). Other tiers stay calm
so the eye lands on Pro first. Big price typography (text-
[40px]) replaces the previous flat 26px. */}
{/* 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-6xl px-6">
<div className="mb-14 text-center">
<p className="text-[11px] uppercase tracking-[0.22em] text-[--color-fg-subtle]">
Pricing
</p>
<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 />
@@ -444,16 +434,16 @@ export default function Landing() {
</h2>
</div>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-4">
{TIERS.map((t) => {
const featured = t.name === 'Pro';
<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 transition-colors ${
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] hover:border-[--color-border-strong]'
: 'border-[--color-border] bg-[--color-bg-elevated]'
}`}
style={
featured
@@ -461,7 +451,7 @@ export default function Landing() {
boxShadow:
'0 0 0 4px rgba(99, 102, 241, 0.12), 0 24px 50px rgba(0, 0, 0, 0.35)',
}
: undefined
: { boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.04)' }
}
>
{featured && (
@@ -483,6 +473,9 @@ export default function Landing() {
</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]">
@@ -498,23 +491,42 @@ export default function Landing() {
</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 still see the full Q+A in the HTML; users
see one question at a time and expand on demand. No JS, no
state, semantically correct. `list-none` + the WebKit-marker
pseudo-class suppress the default disclosure triangle so we
can render our own chevron that rotates via `group-open`. */}
<section className="py-14 sm:py-20">
{/* 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">
<h2 className="text-[28px] font-semibold tracking-tight">FAQ</h2>
<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
@@ -536,35 +548,65 @@ export default function Landing() {
</div>
</div>
</section>
{/* Final CTA — the page must not end on FAQ. Gradient top border keeps
the brand mark scarce but present at the exit point. */}
<section className="relative py-20 sm:py-28">
<div
aria-hidden
className="absolute inset-x-0 top-0 h-px"
style={{ background: 'var(--gradient-brand)', opacity: 0.6 }}
/>
<div className="mx-auto max-w-3xl px-6 text-center">
<h2 className="text-balance text-[32px] font-semibold leading-[1.1] tracking-tight sm:text-[44px]">
Your first server is <span className="text-brand-gradient">one prompt away</span>.
</h2>
<p className="mx-auto mt-4 max-w-md text-[14.5px] leading-relaxed text-[--color-fg-muted]">
Free tier, full source export, no lock-in. If it doesn't work for you, take the
TypeScript and leave.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<PulseLink
href="/login"
className="btn-brand inline-flex h-11 items-center justify-center rounded-md px-6 text-[14px] font-medium"
>
Start building free →
</PulseLink>
<PulseLink
href="/docs"
className="inline-flex h-11 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-6 text-[14px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Read the docs
</PulseLink>
</div>
</div>
</section>
</>
);
}
/**
* Mock marketplace preview.
* Honest marketplace preview.
*
* Static, server-rendered. Renders a faux-browser frame containing four
* realistic template cards so the visitor sees what the marketplace
* actually looks like rather than reading marketing copy about it.
*
* The card data lives in `MOCK_TEMPLATES` at the top of this file —
* those are real template-flavoured names, modest fork counts, and the
* `core` / `community` author tag. Numbers are deliberately small and
* truthful-looking for a young marketplace; nothing here pretends to be
* GitHub-trending traffic.
* 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 MarketplaceMock() {
function MarketplacePreview() {
return (
<div
className="overflow-hidden rounded-xl border border-[--color-border-strong] bg-[--color-bg-elevated]"
style={{ boxShadow: '0 24px 60px rgba(0, 0, 0, 0.45)' }}
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-zinc-700" />
<span className="size-2.5 rounded-full bg-zinc-700" />
<span className="size-2.5 rounded-full bg-zinc-700" />
<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
@@ -573,27 +615,21 @@ function MarketplaceMock() {
{/* Toolbar inside the page chrome */}
<div className="flex items-center justify-between gap-4 border-b border-[--color-border] px-5 py-3">
<div className="flex items-center gap-3">
<span className="text-[13px] font-semibold tracking-tight text-[--color-fg]">
Templates
</span>
<span className="text-[11px] text-[--color-fg-subtle]">·</span>
<span className="text-[11px] text-[--color-fg-subtle]">{MOCK_TEMPLATES.length} live</span>
</div>
<div className="hidden items-center gap-1.5 rounded-md border border-[--color-border] px-2 py-1 sm:flex">
<span className="text-[11px] text-[--color-fg-subtle]">trending</span>
<ChevronDown size={11} className="text-[--color-fg-subtle]" />
</div>
<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">
{MOCK_TEMPLATES.map((t) => (
{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="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="mono truncate text-[13px] font-semibold tracking-tight text-[--color-fg]">
{t.name}
@@ -602,21 +638,14 @@ function MarketplaceMock() {
<span className="mono text-[10px] uppercase tracking-wider text-[--color-fg-subtle]">
{t.author}
</span>
{t.verified && (
<span className="inline-flex items-center gap-0.5 rounded-full border border-[--color-accent]/50 bg-[--color-accent]/10 px-1.5 py-px text-[9.5px] font-medium uppercase tracking-wider text-[--color-accent]">
verified
<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>
<div className="mt-4 flex items-center justify-between text-[11px] text-[--color-fg-subtle]">
<div className="mt-4 text-[11px] text-[--color-fg-subtle]">
<span className="mono">{t.tools} tools</span>
<span className="mono inline-flex items-center gap-1">
<ForkGlyph />
{t.forks}
</span>
</div>
</div>
))}
@@ -624,20 +653,3 @@ function MarketplaceMock() {
</div>
);
}
function ForkGlyph() {
return (
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" aria-hidden>
<circle cx="3.5" cy="3" r="1.4" stroke="currentColor" strokeWidth={1.2} />
<circle cx="10.5" cy="3" r="1.4" stroke="currentColor" strokeWidth={1.2} />
<circle cx="7" cy="11" r="1.4" stroke="currentColor" strokeWidth={1.2} />
<path
d="M 3.5 4.5 L 3.5 6.5 Q 3.5 7.5 4.5 7.5 L 9.5 7.5 Q 10.5 7.5 10.5 6.5 L 10.5 4.5"
stroke="currentColor"
strokeWidth={1.2}
fill="none"
/>
<path d="M 7 7.5 L 7 9.5" stroke="currentColor" strokeWidth={1.2} />
</svg>
);
}

View File

@@ -1,3 +1,4 @@
import { TIERS } from '@/lib/pricing';
import { pageMetadata } from '@/lib/seo';
import Link from 'next/link';
@@ -8,80 +9,6 @@ export const metadata = pageMetadata({
path: '/pricing',
});
const TIERS = [
{
name: 'Hobby',
price: '€0',
tag: 'Forever free',
description: 'For trying things out and shipping single-user tools.',
model: 'Open-tier AI',
modelDetail: 'Free-tier model · ~30-60s analyze',
features: [
'1 MCP server',
'100,000 tool calls / month',
'5 prompt analyses / day',
'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.',
model: 'Claude Haiku 4.5',
modelDetail: 'Anthropic · ~10-20s analyze',
features: [
'5 MCP servers',
'1M tool calls / month',
'40 prompt analyses / day',
'Custom domain',
'Priority build queue',
'Email support, 1 business-day SLA',
],
cta: 'Start Pro',
href: '/settings/billing?tier=pro_monthly',
highlight: true,
},
{
name: 'Team',
price: '€199',
tag: '/ month',
description: 'For teams with RBAC, audit, and 99.9% SLA needs.',
model: 'Claude Sonnet 4.6',
modelDetail: "Anthropic's flagship",
features: [
'25 MCP servers',
'10M tool calls / month',
'50 prompt analyses / day',
'RBAC + extended audit log',
'99.9% uptime SLA',
'Shared Slack channel support',
],
cta: 'Start Team',
href: '/settings/billing?tier=team_monthly',
},
{
name: 'Enterprise',
price: '€999+',
tag: '/ month',
description: 'For organizations bringing their own cloud, SSO and dedicated infra.',
model: 'Sonnet + Opus on build',
modelDetail: 'EU data-residency option',
features: [
'Unlimited servers',
'BYOC (AWS, GCP, Azure, Hetzner)',
'SSO / SAML',
'Dedicated cluster',
'Customer success manager',
],
cta: 'Contact sales',
href: 'mailto:sales@buildmymcpserver.com',
},
];
const FAQ = [
{
q: 'What counts as a tool call?',
@@ -89,11 +16,11 @@ const FAQ = [
},
{
q: 'What happens if I exceed my quota?',
a: 'Hobby: 429 with a hint to upgrade. Pro/Team: overage at €0.02 per 1000 calls, billed the following month. Soft caps configurable.',
a: 'Daily build and analysis limits return a 429 with a clear upgrade hint. Monthly tool-call volumes are generous soft limits — we reach out before anything is capped.',
},
{
q: 'Annual billing?',
a: 'Yes — save 20% on Pro and Team paying annually. Enterprise is annual by default.',
a: 'Annual plans are coming — contact us for annual invoicing today. Enterprise is annual by default.',
},
{
q: 'Plan changes?',

View File

@@ -26,7 +26,7 @@ const SECTIONS = [
},
{
h: '5. Service availability',
p: 'Free and Pro plans are best-effort. Team plan carries a 99.9% monthly uptime SLA with service credits as the sole remedy. Enterprise SLAs are negotiated separately.',
p: 'Free, Pro and Team plans are provided on a best-effort basis with no guaranteed uptime SLA. Enterprise availability commitments are negotiated separately by contract.',
},
{
h: '6. Billing',

View File

@@ -6,12 +6,26 @@ import {
DocsCode,
Mono,
} from '@/components/docs-page';
import { JsonLd } from '@/components/json-ld';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
export const metadata = { title: 'API reference — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'API reference',
description:
'REST API reference for the BuildMyMCPServer control plane — auth, server CRUD, build streaming, templates and the OAuth 2.1 endpoints.',
path: '/docs/api-reference',
});
export default function ApiReference() {
return (
<>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Docs', path: '/docs' },
{ name: 'API reference', path: '/docs/api-reference' },
])}
/>
<DocsTitle kicker="Reference">API reference</DocsTitle>
<DocsLead>
Every endpoint on the control plane. Authenticated routes use the session cookie set by

View File

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

View File

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

View File

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

View File

@@ -8,12 +8,26 @@ import {
DocsCode,
Mono,
} from '@/components/docs-page';
import { JsonLd } from '@/components/json-ld';
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
export const metadata = { title: 'OAuth 2.1 flow — BuildMyMCPServer docs' };
export const metadata = pageMetadata({
title: 'OAuth 2.1 flow',
description:
'How every generated MCP server is protected: OAuth 2.1 with PKCE, Dynamic Client Registration (RFC 7591) and Resource Indicators (RFC 8707), walked through request by request.',
path: '/docs/oauth',
});
export default function OAuthDocs() {
return (
<>
<JsonLd
data={breadcrumbJsonLd([
{ name: 'Home', path: '/' },
{ name: 'Docs', path: '/docs' },
{ name: 'OAuth 2.1 flow', path: '/docs/oauth' },
])}
/>
<DocsTitle kicker="Auth">OAuth 2.1 flow</DocsTitle>
<DocsLead>
Every generated server is an OAuth 2.1 Resource Server. The control plane is the

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,21 @@
@import 'tailwindcss';
@import "tailwindcss";
@theme {
--color-bg: #0a0a0b;
--color-bg-elevated: #111114;
--color-bg-subtle: #16161a;
/* Elevated surfaces carry a ~1% indigo tint so panels read as part of the
brand instead of flat gray. Kept close enough to the old #111114/#16161a
that dashboard contrast ratios are unaffected. */
--color-bg-elevated: #121218;
--color-bg-subtle: #17171d;
--color-fg: #fafafa;
--color-fg-muted: #a1a1aa;
--color-fg-subtle: #71717a;
--color-border: #1f1f22;
--color-border-strong: #2a2a2e;
--color-accent: #6366f1;
/* Secondary brand hue — cyan endpoint of the identity gradient. Reserved
for the gradient, kickers and "live" accents; never a surface color. */
--color-accent-2: #22d3ee;
--color-accent-fg: #ffffff;
--color-success: #22c55e;
--color-warn: #f59e0b;
@@ -39,7 +45,7 @@
background: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-sans);
font-feature-settings: 'cv11', 'ss01';
font-feature-settings: "cv11", "ss01";
}
::selection {
background: rgba(99, 102, 241, 0.3);
@@ -100,7 +106,56 @@
}
}
/* Identity gradient — indigo → cyan. Defined outside @theme (it's not a
Tailwind token, just a shared value) and used ONLY for: primary CTAs,
section kickers, the featured pricing card and one hero glow. Scarcity is
what keeps it feeling like a brand mark instead of decoration. */
:root {
--gradient-brand: linear-gradient(135deg, #6366f1 0%, #22d3ee 100%);
}
@layer components {
/* Gradient-filled primary CTA. A dark inner overlay on hover (instead of a
second gradient) keeps the transition GPU-cheap. */
.btn-brand {
position: relative;
background: var(--gradient-brand);
color: #ffffff;
isolation: isolate;
}
.btn-brand::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(10, 10, 11, 0);
transition: background-color 0.2s ease-out;
z-index: -1;
}
.btn-brand:hover::after {
background: rgba(10, 10, 11, 0.18);
}
/* Gradient text for the rare display moment (final CTA heading). */
.text-brand-gradient {
background: var(--gradient-brand);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Mono section kicker, shell-comment style: `## how_it_works` */
.kicker {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
color: var(--color-accent);
}
/* Elevated panel with an inner top highlight — reads as machined edge. */
.panel-raised {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.panel {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
@@ -139,7 +194,8 @@
}
@keyframes pulse-dot {
0%, 100% {
0%,
100% {
opacity: 1;
transform: scale(1);
}

View File

@@ -26,7 +26,15 @@ export const metadata: Metadata = {
authors: [{ name: SITE_NAME }],
creator: SITE_NAME,
publisher: SITE_NAME,
alternates: { canonical: '/' },
alternates: {
canonical: '/',
types: { 'application/rss+xml': [{ url: '/feed.xml', title: `${SITE_NAME} — MCP guides` }] },
},
// Google Search Console ownership token. Unset in dev; set in production so
// the sitemap can be submitted and indexing monitored.
...(process.env.NEXT_PUBLIC_GSC_VERIFICATION
? { verification: { google: process.env.NEXT_PUBLIC_GSC_VERIFICATION } }
: {}),
openGraph: {
type: 'website',
locale: 'en_US',

View File

@@ -203,9 +203,12 @@ export default function LoginPage() {
sms: false,
email: false,
});
// Default to SMS — email is off by default until an SMTP/Resend provider
// is wired. The effect below flips to 'email' if the backend says it's on.
const [method, setMethod] = useState<'email' | 'phone'>('phone');
// Phone sign-in is deliberately collapsed behind a text link whenever any
// other provider (OAuth or email) is available — a developer evaluating the
// product should never see a phone-number field as the front door. It only
// renders expanded when SMS is the sole configured provider.
const [phoneOpen, setPhoneOpen] = useState(false);
const [providersState, setProvidersState] = useState<'loading' | 'ready' | 'failed'>('loading');
const [error, setError] = useState<string | null>(null);
// Email magic-link
@@ -226,11 +229,21 @@ export default function LoginPage() {
)
.then((p) => {
setProviders(p);
// Pick the most-likely method up-front: email if enabled, else SMS.
if (p.email) setMethod('email');
else if (p.sms) setMethod('phone');
setProvidersState('ready');
// SMS as the only provider → show the phone form directly (no point
// hiding the sole sign-in method behind a toggle).
if (p.sms && !p.email && !p.google && !p.github) setPhoneOpen(true);
})
.catch(() => undefined);
.catch(() => {
// If the providers lookup fails, the page must not strand the visitor
// with zero sign-in methods — fall back to SMS + phone form expanded
// (the one flow that has no provider-specific client config) and say
// why the other options are missing.
setProviders({ google: false, github: false, sms: true, email: false });
setPhoneOpen(true);
setProvidersState('failed');
setError('Some sign-in options could not be loaded. Reload the page to try again.');
});
const err = new URLSearchParams(window.location.search).get('error');
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
}, []);
@@ -294,6 +307,15 @@ export default function LoginPage() {
Passwordless pick whichever is easiest.
</p>
{/* Providers still loading → neutral skeleton instead of a blank card
(previously nothing rendered until the fetch resolved). */}
{providersState === 'loading' && (
<div aria-hidden className="mt-7 space-y-2">
<div className="h-10 w-full animate-pulse rounded-md bg-[--color-bg-elevated]" />
<div className="h-10 w-full animate-pulse rounded-md bg-[--color-bg-elevated]" />
</div>
)}
{hasOAuth && (
<div className="mt-7 space-y-2">
{providers.google && (
@@ -317,7 +339,7 @@ export default function LoginPage() {
</div>
)}
{hasOAuth && (
{hasOAuth && (providers.email || providers.sms) && (
<div className="my-5 flex items-center gap-3">
<span className="h-px flex-1 bg-[--color-border]" />
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
@@ -327,35 +349,8 @@ export default function LoginPage() {
</div>
)}
{/* Tab toggle only shown when BOTH email and SMS are enabled — if just
one is configured, that method's form renders directly without a
useless one-tab toggle. */}
{providers.sms && providers.email && (
<div
className={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
>
{(['email', 'phone'] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => {
setMethod(m);
setError(null);
}}
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
method === m
? 'bg-[--color-bg-subtle] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
}`}
>
{m === 'email' ? 'Email' : 'Phone'}
</button>
))}
</div>
)}
<div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
{method === 'email' && providers.email && emailState !== 'sent' && (
{providers.email && !phoneOpen && emailState !== 'sent' && (
<form onSubmit={sendMagicLink} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
@@ -381,7 +376,7 @@ export default function LoginPage() {
</form>
)}
{method === 'email' && providers.email && emailState === 'sent' && (
{providers.email && !phoneOpen && emailState === 'sent' && (
<div className="panel p-4">
<p className="text-[13px]">
Magic link sent to <span className="mono">{email}</span>.
@@ -392,15 +387,11 @@ export default function LoginPage() {
</div>
)}
{method === 'phone' && smsStep === 'phone' && (
{providers.sms && phoneOpen && smsStep === 'phone' && (
<form onSubmit={requestSmsCode} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="country">Country</Label>
<CountryPicker
countries={COUNTRIES}
value={country}
onChange={setCountry}
/>
<CountryPicker countries={COUNTRIES} value={country} onChange={setCountry} />
</div>
<div className="space-y-1.5">
<Label htmlFor="phone" hint={dialFor(country)}>
@@ -429,7 +420,7 @@ export default function LoginPage() {
</form>
)}
{method === 'phone' && smsStep === 'code' && (
{providers.sms && phoneOpen && smsStep === 'code' && (
<form onSubmit={verifySmsCode} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="code" hint={`sent to ${sentTo}`}>
@@ -471,6 +462,35 @@ export default function LoginPage() {
)}
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
{/* Phone sign-in stays available but demoted: expand link when any
other provider exists, collapse link to get back. */}
{providers.sms && !phoneOpen && (hasOAuth || providers.email) && (
<button
type="button"
onClick={() => {
setPhoneOpen(true);
setError(null);
}}
className="mt-4 w-full text-center text-[12px] text-[--color-fg-muted] underline-offset-2 transition-colors hover:text-[--color-fg] hover:underline"
>
Sign in with phone number instead
</button>
)}
{providers.sms && phoneOpen && (hasOAuth || providers.email) && (
<button
type="button"
onClick={() => {
setPhoneOpen(false);
setSmsStep('phone');
setCode('');
setError(null);
}}
className="mt-4 w-full text-center text-[12px] text-[--color-fg-muted] underline-offset-2 transition-colors hover:text-[--color-fg] hover:underline"
>
Use another sign-in method
</button>
)}
</div>
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">

View File

@@ -1,36 +1,73 @@
import { ARTICLES } from '@/lib/articles';
import { SITE_URL } from '@/lib/seo';
import { fetchPublicTemplateSlugs } from '@/lib/templates-server';
import type { MetadataRoute } from 'next';
type Entry = {
path: string;
priority: number;
changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'];
/** Real last-substantive-change date. Bump when the page meaningfully changes. */
lastModified: string;
};
// lastModified must reflect actual content changes — Google discounts sitemaps
// whose lastmod is always "now". Bump a route's date when you edit its page.
const ROUTES: Entry[] = [
{ path: '/', priority: 1.0, changeFrequency: 'weekly' },
{ path: '/pricing', priority: 0.9, changeFrequency: 'weekly' },
{ path: '/templates', priority: 0.9, changeFrequency: 'daily' },
{ path: '/docs', priority: 0.8, changeFrequency: 'weekly' },
{ path: '/docs/concepts', priority: 0.7, changeFrequency: 'monthly' },
{ path: '/docs/oauth', priority: 0.7, changeFrequency: 'monthly' },
{ path: '/docs/authoring', priority: 0.7, changeFrequency: 'monthly' },
{ path: '/docs/api-reference', priority: 0.7, changeFrequency: 'monthly' },
{ path: '/docs/self-hosting', priority: 0.7, changeFrequency: 'monthly' },
{ path: '/docs/faq', priority: 0.6, changeFrequency: 'monthly' },
{ path: '/changelog', priority: 0.6, changeFrequency: 'weekly' },
{ path: '/security', priority: 0.5, changeFrequency: 'monthly' },
{ path: '/status', priority: 0.4, changeFrequency: 'weekly' },
{ path: '/privacy', priority: 0.3, changeFrequency: 'yearly' },
{ path: '/terms', priority: 0.3, changeFrequency: 'yearly' },
{ path: '/', priority: 1.0, changeFrequency: 'weekly', lastModified: '2026-07-08' },
{ path: '/pricing', priority: 0.9, changeFrequency: 'weekly', lastModified: '2026-07-08' },
{ path: '/templates', priority: 0.9, changeFrequency: 'daily', lastModified: '2026-07-08' },
{ path: '/guides', priority: 0.8, changeFrequency: 'weekly', lastModified: '2026-07-08' },
{ path: '/docs', priority: 0.8, changeFrequency: 'weekly', lastModified: '2026-07-08' },
{ path: '/docs/concepts', priority: 0.7, changeFrequency: 'monthly', lastModified: '2026-07-08' },
{ path: '/docs/oauth', priority: 0.7, changeFrequency: 'monthly', lastModified: '2026-07-08' },
{ path: '/docs/authoring', priority: 0.7, changeFrequency: 'monthly', lastModified: '2026-07-08' },
{
path: '/docs/api-reference',
priority: 0.7,
changeFrequency: 'monthly',
lastModified: '2026-07-08',
},
{
path: '/docs/self-hosting',
priority: 0.7,
changeFrequency: 'monthly',
lastModified: '2026-07-08',
},
{ path: '/docs/faq', priority: 0.6, changeFrequency: 'monthly', lastModified: '2026-07-08' },
{ path: '/changelog', priority: 0.6, changeFrequency: 'weekly', lastModified: '2026-06-11' },
{ path: '/security', priority: 0.5, changeFrequency: 'monthly', lastModified: '2026-06-04' },
{ path: '/contact', priority: 0.4, changeFrequency: 'yearly', lastModified: '2026-06-04' },
{ path: '/status', priority: 0.4, changeFrequency: 'weekly', lastModified: '2026-06-04' },
{ path: '/privacy', priority: 0.3, changeFrequency: 'yearly', lastModified: '2026-06-29' },
{ path: '/terms', priority: 0.3, changeFrequency: 'yearly', lastModified: '2026-06-29' },
];
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
return ROUTES.map((r) => ({
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticEntries: MetadataRoute.Sitemap = ROUTES.map((r) => ({
url: `${SITE_URL}${r.path}`,
lastModified: now,
lastModified: new Date(r.lastModified),
changeFrequency: r.changeFrequency,
priority: r.priority,
}));
// Guide articles come from the single registry, with their true dates.
const guideEntries: MetadataRoute.Sitemap = ARTICLES.map((a) => ({
url: `${SITE_URL}/guides/${a.slug}`,
lastModified: new Date(a.dateModified ?? a.datePublished),
changeFrequency: 'monthly',
priority: 0.8,
}));
// Marketplace templates — each public template is its own indexable page.
// Best-effort: if the API is unreachable the static entries still ship.
const slugs = await fetchPublicTemplateSlugs();
const templateEntries: MetadataRoute.Sitemap = slugs.map((slug) => ({
url: `${SITE_URL}/templates/${slug}`,
lastModified: new Date('2026-07-08'),
changeFrequency: 'weekly',
priority: 0.6,
}));
return [...staticEntries, ...guideEntries, ...templateEntries];
}

View File

@@ -0,0 +1,34 @@
'use client';
import { CodeBlock } from '@/components/code-block';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';
/**
* Client island: the "audit the generated code before you fork" toggle. Kept
* out of the server-rendered page so the page itself stays static + indexable.
*/
export function CollapsibleCode({ code }: { code: string }) {
const [show, setShow] = useState(false);
return (
<section className="mt-10">
<button
type="button"
onClick={() => setShow((s) => !s)}
className="inline-flex items-center gap-1 text-[14px] font-semibold tracking-tight text-[--color-fg] transition-colors hover:text-[--color-fg-muted]"
>
{show ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Generated code ({code.length} chars)
</button>
<p className="mt-1 text-[12px] text-[--color-fg-muted]">
Audit before you fork. We re-scan every published template for banned patterns (eval,
child_process, prompt-injection markers).
</p>
{show && (
<div className="mt-3">
<CodeBlock label="src/server.ts" code={code} />
</div>
)}
</section>
);
}

View File

@@ -1,95 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ShieldCheck, GitFork, Activity, ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { CodeBlock } from '@/components/code-block';
import { JsonLd } from '@/components/json-ld';
import { Logo } from '@/components/logo';
import { type TemplateDetail, fetchTemplate } from '@/lib/templates-server';
import { pageMetadata, templateJsonLd } from '@/lib/seo';
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Activity, ExternalLink, GitFork, ShieldCheck } from 'lucide-react';
import { CollapsibleCode } from './collapsible-code';
interface Tool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
// Server-rendered for SEO: per-template <title>, description, OpenGraph and
// SoftwareApplication JSON-LD. The only interactive piece (the code-audit
// toggle) lives in a client island.
interface PageProps {
params: Promise<{ slug: string }>;
}
interface SecretHint {
key: string;
description: string;
howToGetUrl?: string;
}
interface TemplateDetail {
id: string;
slug: string;
title: string;
shortDescription: string;
longDescription: string | null;
category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean;
forkCount: number;
activeDeployments: number;
toolsSchema: Tool[];
generatedCode: string;
requiredSecrets: SecretHint[];
scopes: string[];
ownerName: string | null;
ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string;
}
export default function TemplateDetail() {
const params = useParams<{ slug: string }>();
const router = useRouter();
const [template, setTemplate] = useState<TemplateDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [showCode, setShowCode] = useState(false);
useEffect(() => {
apiFetch<{ template: TemplateDetail }>(`/v1/templates/${params.slug}`)
.then((r) => setTemplate(r.template))
.catch((e) => {
const detail = (e as { detail?: { error?: string } }).detail;
setError(detail?.error ?? (e as Error).message);
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const t = await fetchTemplate(slug);
if (!t) {
return pageMetadata({
title: 'Template not found',
description: 'This MCP server template is not available.',
path: `/templates/${slug}`,
});
}, [params.slug]);
function useTemplate() {
if (!template) return;
router.push(`/servers/new?template=${template.slug}`);
}
return pageMetadata({
title: `${t.title} — MCP server for Claude, Cursor & ChatGPT`,
description:
t.shortDescription.length > 0
? t.shortDescription
: `Fork the ${t.title} MCP server and deploy your own OAuth-protected copy in seconds.`,
path: `/templates/${slug}`,
});
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center px-6">
<div className="text-center">
<p className="text-[14px]">Template not found.</p>
<Link href="/templates" className="mt-3 inline-block text-[12px] text-[--color-accent] underline">
Back to marketplace
</Link>
</div>
</div>
);
}
export default async function TemplateDetailPage({ params }: PageProps) {
const { slug } = await params;
const template: TemplateDetail | null = await fetchTemplate(slug);
if (!template) notFound();
if (!template) {
return (
<div className="px-8 py-20 text-center mono text-[12px] text-[--color-fg-muted]">Loading</div>
);
}
const forkHref = `/servers/new?template=${template.slug}`;
return (
<div className="flex min-h-screen flex-col">
<JsonLd
data={templateJsonLd({
slug: template.slug,
title: template.title,
description: template.shortDescription,
category: template.category,
tools: template.toolsSchema.map((t) => t.name),
author: template.ownerName ?? template.ownerOrgName,
})}
/>
<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-5xl items-center justify-between px-6">
<div className="flex items-center gap-3">
<Logo />
<span className="text-[12.5px] text-[--color-fg-subtle]">
/ <Link href="/templates" className="hover:text-[--color-fg]">templates</Link> / {template.slug}
/{' '}
<Link href="/templates" className="hover:text-[--color-fg]">
templates
</Link>{' '}
/ {template.slug}
</span>
</div>
<Link
@@ -130,17 +106,20 @@ export default function TemplateDetail() {
Tools ({template.toolsSchema.length})
</h2>
<div className="mt-3 space-y-3">
{template.toolsSchema.map((tool) => (
{template.toolsSchema.map((tool) => {
const paramCount = Object.keys(tool.inputSchema ?? {}).length;
return (
<div key={tool.name} className="panel p-3">
<div className="flex items-baseline gap-2">
<span className="mono text-[13px] font-semibold">{tool.name}</span>
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
{Object.keys(tool.inputSchema ?? {}).length} param
{Object.keys(tool.inputSchema ?? {}).length === 1 ? '' : 's'}
{paramCount} param{paramCount === 1 ? '' : 's'}
</span>
</div>
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p>
{Object.keys(tool.inputSchema ?? {}).length > 0 && (
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">
{tool.description}
</p>
{paramCount > 0 && (
<div className="mt-2">
<CodeBlock
label="input schema"
@@ -149,7 +128,8 @@ export default function TemplateDetail() {
</div>
)}
</div>
))}
);
})}
</div>
</section>
@@ -178,62 +158,34 @@ export default function TemplateDetail() {
</a>
)}
</div>
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">
{s.description}
</p>
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">{s.description}</p>
</div>
))}
</div>
</section>
)}
<section className="mt-10">
<button
type="button"
onClick={() => setShowCode((s) => !s)}
className="inline-flex items-center gap-1 text-[14px] font-semibold tracking-tight text-[--color-fg] transition-colors hover:text-[--color-fg-muted]"
>
{showCode ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Generated code ({template.generatedCode.length} chars)
</button>
<p className="mt-1 text-[12px] text-[--color-fg-muted]">
Audit before you fork. We re-scan every published template for banned patterns
(eval, child_process, prompt-injection markers).
</p>
{showCode && (
<div className="mt-3">
<CodeBlock label="src/server.ts" code={template.generatedCode} />
</div>
)}
</section>
<CollapsibleCode code={template.generatedCode} />
</div>
<aside className="space-y-3">
<div className="panel p-4">
{template.status === 'public' ? (
<>
<Button variant="primary" size="lg" className="w-full" onClick={useTemplate}>
<Link
href={forkHref}
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-[--color-accent] text-[13.5px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
>
Fork this template
</Button>
</Link>
<p className="mt-2 text-[11.5px] text-[--color-fg-muted]">
One click your own isolated container.
</p>
</>
) : (
<>
<div className="rounded-md border border-amber-400/30 bg-amber-400/5 p-2.5 text-[12px] text-amber-200/90">
This template is <span className="mono">{template.status}</span> not
forkable. {template.sourceServerId ? 'Re-share it from the servers Publish tab to allow forks.' : ''}
This template is <span className="mono">{template.status}</span> not forkable.
</div>
{template.sourceServerId && (
<a
href={`/servers/${template.sourceServerId}`}
className="mt-2 inline-flex h-8 w-full items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[12.5px] text-[--color-fg] transition-colors hover:bg-[--color-bg-subtle]"
>
Manage in server
</a>
)}
</>
)}
</div>
@@ -245,17 +197,17 @@ export default function TemplateDetail() {
icon={<Activity size={11} />}
/>
<Row label="Category" value={template.category} mono />
<Row label="Published" value={new Date(template.createdAt).toLocaleDateString()} />
<Row
label="Published"
value={new Date(template.createdAt).toLocaleDateString()}
label="Author"
value={template.ownerName ?? template.ownerOrgName ?? 'anonymous'}
/>
<Row label="Author" value={template.ownerName ?? template.ownerOrgName ?? 'anonymous'} />
</div>
<div className="panel p-3 text-[11.5px] leading-relaxed text-[--color-fg-muted]">
<strong className="text-[--color-fg]">Forking is safe.</strong> Your fork gets its own
Docker container, its own port, its own AES-256-encrypted secrets. The template
author has no visibility into your traffic or data.
Docker container, its own port, its own AES-256-encrypted secrets. The template author
has no visibility into your traffic or data.
</div>
</aside>
</div>

View File

@@ -1,351 +1,12 @@
'use client';
import { fetchPublicTemplates } from '@/lib/templates-server';
import { TemplatesBrowser } from './templates-browser';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { ShieldCheck, GitFork, Activity } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { Logo } from '@/components/logo';
import { Input } from '@/components/input';
import { MobileActionBar } from '@/components/mobile-action-bar';
import { UserMenu } from '@/components/user-menu';
// Server component: fetch the default (all/trending) template list at render
// time so the marketplace grid is present in the initial HTML for crawlers.
// All interactivity (search, filters, scope) lives in TemplatesBrowser.
export const revalidate = 300;
interface Template {
id: string;
slug: string;
title: string;
shortDescription: string;
category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean;
forkCount: number;
activeDeployments: number;
ownerName: string | null;
ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string;
}
type Sort = 'trending' | 'top';
type Scope = 'all' | 'mine';
const STATUS_STYLE: Record<Template['status'], string> = {
public: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
hidden: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
takedown: 'border-red-400/40 bg-red-400/10 text-red-300',
draft: 'border-zinc-400/40 bg-zinc-400/10 text-zinc-300',
};
export default function TemplatesMarketplace() {
const [me, setMe] = useState<{ email: string } | null | undefined>(undefined);
const [scope, setScope] = useState<Scope>('all');
const [templates, setTemplates] = useState<Template[] | null>(null);
const [categories, setCategories] = useState<string[]>([]);
const [sort, setSort] = useState<Sort>('trending');
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
// Detect login state once
useEffect(() => {
apiFetch<{ user: { email: string } }>('/v1/auth/me')
.then((r) => setMe({ email: r.user.email }))
.catch(() => setMe(null));
}, []);
// Load templates whenever scope/sort/category changes
useEffect(() => {
setTemplates(null);
if (scope === 'mine') {
apiFetch<{ templates: Template[]; categories: string[] }>('/v1/templates/mine')
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
} else {
const params = new URLSearchParams({ sort });
if (category) params.set('category', category);
apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`)
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
}
}, [scope, sort, category]);
const visible = templates?.filter((t) => {
if (search) {
const q = search.toLowerCase();
if (!t.title.toLowerCase().includes(q) && !t.shortDescription.toLowerCase().includes(q)) {
return false;
}
}
// category filter is server-side for 'all', client-side for 'mine'
if (scope === 'mine' && category && t.category !== category) return false;
return true;
});
const loggedIn = me != null;
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/85 backdrop-blur-md">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Logo />
{/* "/ templates" subtitle is redundant on mobile — h1 below
already names the page. Keep on desktop as breadcrumb. */}
<span className="hidden text-[12.5px] text-[--color-fg-subtle] sm:inline">
/ templates
</span>
</div>
<nav className="flex items-center gap-1.5 sm:gap-2">
{loggedIn ? (
<>
{/* Dashboard link + "+ New server" pill hidden on mobile —
the UserMenu (avatar) + the MobileActionBar below cover
both navigation paths there. */}
<Link
href="/dashboard"
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
>
Dashboard
</Link>
<Link
href="/servers/new"
className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex"
>
+ New server
</Link>
<UserMenu />
</>
) : (
<>
<Link
href="/"
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
>
Home
</Link>
<Link
href="/login"
className="rounded-md bg-[--color-accent] px-2.5 py-1 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:px-3 sm:py-1.5 sm:text-[12.5px]"
>
Start building
</Link>
</>
)}
</nav>
</div>
</header>
<main
className={cn(
'mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6 sm:py-12',
// Bottom-bar clearance on mobile for logged-in users only — the
// MobileActionBar is fixed bottom and would overlap the last row
// of template cards otherwise.
loggedIn && 'pb-24 sm:pb-12',
)}
>
<header className="mb-6 max-w-2xl sm:mb-8">
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
Marketplace
</div>
<h1 className="mt-2 text-[24px] font-semibold tracking-tight sm:text-[32px]">
MCP server templates
</h1>
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted] sm:mt-3 sm:text-[14px]">
Pre-built MCP servers from the community. Fork in one click your own container, your
own credentials, fully isolated. The template author never sees your data.
</p>
</header>
{/* Filter row: stacks vertically on mobile (search on top for thumb
reach), inline on desktop. The order utility on the Input flips
it to the right side at sm: while filters wrap normally. */}
<div className="mb-6 flex flex-col gap-3 border-b border-[--color-border] pb-4 sm:flex-row sm:flex-wrap sm:items-center">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="order-first w-full sm:order-last sm:ml-auto sm:w-60"
/>
{/* Chips row — horizontally scrollable on narrow mobile so the
segmented controls never get squeezed below their min-width. */}
<div className="-mx-1 flex items-center gap-2 overflow-x-auto px-1 sm:m-0 sm:flex-wrap sm:gap-3 sm:overflow-visible sm:p-0">
{loggedIn && (
<>
<div className="flex shrink-0 gap-1 rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-0.5">
{(['all', 'mine'] as Scope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
'rounded-[4px] px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === scope
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s === 'all' ? 'All' : 'Mine'}
</button>
))}
</div>
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
</>
)}
{scope === 'all' && (
<>
<div className="flex shrink-0 gap-1">
{(['trending', 'top'] as Sort[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setSort(s)}
className={cn(
'rounded-md px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === sort
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s}
</button>
))}
</div>
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
</>
)}
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
// Hard width-cap — without it a long category name in the
// selected slot would blow the chip row out on mobile and
// push the scrollable region beyond the viewport. Native
// <select> truncates the visible label with ellipsis when
// the box is narrower than the text; the dropdown panel
// itself still shows full names when opened.
className="h-7 w-[140px] shrink-0 truncate rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[12.5px] focus:border-[--color-accent] focus:outline-none sm:w-[160px]"
>
<option value="">All categories</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
{!visible && <p className="mono text-[12px] text-[--color-fg-muted]">Loading</p>}
{visible && visible.length === 0 && (
<div className="panel p-12 text-center">
{scope === 'mine' ? (
<>
<p className="text-[14px] text-[--color-fg]">You haven&apos;t published any templates.</p>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Build a server, then tick <span className="mono">Share as template</span> on the
done screen or use the Publish tab on any live server.
</p>
</>
) : (
<>
<p className="text-[14px] text-[--color-fg]">No templates yet.</p>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Build a server you&apos;re proud of and share it to the marketplace.
</p>
</>
)}
</div>
)}
{visible && visible.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{visible.map((t) => (
<TemplateCard key={t.id} t={t} showStatus={scope === 'mine'} />
))}
</div>
)}
</main>
<footer className="border-t border-[--color-border] py-8">
<div className="mx-auto max-w-6xl px-4 text-[12px] text-[--color-fg-subtle] sm:px-6">
Every template is isolated: forking creates your own container with your own secrets.
</div>
</footer>
{/* Mobile tab-bar — only when signed in. Logged-out marketplace
browsing keeps the simple marketing chrome (login CTA in header). */}
{loggedIn && <MobileActionBar />}
</div>
);
}
function TemplateCard({ t, showStatus }: { t: Template; showStatus: boolean }) {
// takedown templates can't be opened (the detail route 410s); link to the
// server's Publish tab so the owner can see why. Everything else opens detail.
const href =
t.status === 'takedown' && t.sourceServerId
? `/servers/${t.sourceServerId}`
: `/templates/${t.slug}`;
return (
<Link
href={href}
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5">
<h2 className="text-[14.5px] font-semibold tracking-tight">{t.title}</h2>
{t.verified && (
<span
className="inline-flex items-center gap-0.5 rounded-full border border-[--color-accent]/40 bg-[--color-accent]/10 px-1.5 py-0.5 text-[9.5px] font-medium text-[--color-accent]"
title="Verified by BuildMyMCPServer"
>
<ShieldCheck size={9} /> verified
</span>
)}
</div>
{showStatus ? (
<span
className={cn(
'mono rounded-full border px-1.5 py-0.5 text-[9.5px]',
STATUS_STYLE[t.status],
)}
>
{t.status}
</span>
) : (
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 text-[10px] text-[--color-fg-subtle]">
{t.category}
</span>
)}
</div>
<p className="mt-2 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
{t.shortDescription}
</p>
<div className="mt-4 flex items-center justify-between text-[11px] text-[--color-fg-subtle]">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1 mono" title="Total forks">
<GitFork size={11} />
{t.forkCount}
</span>
<span className="inline-flex items-center gap-1 mono" title="Active deployments">
<Activity size={11} />
{t.activeDeployments}
</span>
</div>
<span className="truncate">
{showStatus ? t.category : `by ${t.ownerName ?? t.ownerOrgName ?? 'anonymous'}`}
</span>
</div>
</Link>
);
export default async function TemplatesPage() {
const { templates, categories } = await fetchPublicTemplates();
return <TemplatesBrowser initialTemplates={templates} initialCategories={categories} />;
}

View File

@@ -0,0 +1,395 @@
'use client';
import { Input } from '@/components/input';
import { Logo } from '@/components/logo';
import { MobileActionBar } from '@/components/mobile-action-bar';
import { UserMenu } from '@/components/user-menu';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { TemplateSummary } from '@/lib/templates-server';
import { Activity, GitFork, Lightbulb, ShieldCheck } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
type Template = TemplateSummary;
type Sort = 'trending' | 'top';
type Scope = 'all' | 'mine';
const STATUS_STYLE: Record<Template['status'], string> = {
public: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
hidden: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
takedown: 'border-red-400/40 bg-red-400/10 text-red-300',
draft: 'border-zinc-400/40 bg-zinc-400/10 text-zinc-300',
};
// Honest inspiration cards for the young-marketplace empty state — clearly
// labeled ideas, not fake templates with invented usage numbers.
const STARTER_IDEAS: { title: string; desc: string }[] = [
{
title: 'Notion workspace search',
desc: 'search_pages + get_page_content over your Notion API key. One prompt, ~60s to live.',
},
{
title: 'Read-only PostgreSQL',
desc: 'Let Claude query your tables with schema introspection — no write access.',
},
{
title: 'Wrap any REST API',
desc: 'Turn the endpoints you already have into typed MCP tools behind OAuth.',
},
];
export function TemplatesBrowser({
initialTemplates,
initialCategories,
}: {
initialTemplates: Template[];
initialCategories: string[];
}) {
const [me, setMe] = useState<{ email: string } | null | undefined>(undefined);
const [scope, setScope] = useState<Scope>('all');
const [templates, setTemplates] = useState<Template[] | null>(initialTemplates);
const [categories, setCategories] = useState<string[]>(initialCategories);
const [sort, setSort] = useState<Sort>('trending');
const [category, setCategory] = useState('');
const [search, setSearch] = useState('');
// Detect login state once
useEffect(() => {
apiFetch<{ user: { email: string } }>('/v1/auth/me')
.then((r) => setMe({ email: r.user.email }))
.catch(() => setMe(null));
}, []);
// Load templates whenever scope/sort/category changes. The first run is
// skipped: the server component already fetched the default (all/trending)
// list and passed it as props, so the grid is in the SSR HTML for crawlers.
const skippedInitialLoad = useRef(false);
useEffect(() => {
if (!skippedInitialLoad.current) {
skippedInitialLoad.current = true;
return;
}
setTemplates(null);
if (scope === 'mine') {
apiFetch<{ templates: Template[]; categories: string[] }>('/v1/templates/mine')
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
} else {
const params = new URLSearchParams({ sort });
if (category) params.set('category', category);
apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`)
.then((r) => {
setTemplates(r.templates);
setCategories(r.categories);
})
.catch(() => setTemplates([]));
}
}, [scope, sort, category]);
const visible = templates?.filter((t) => {
if (search) {
const q = search.toLowerCase();
if (!t.title.toLowerCase().includes(q) && !t.shortDescription.toLowerCase().includes(q)) {
return false;
}
}
// category filter is server-side for 'all', client-side for 'mine'
if (scope === 'mine' && category && t.category !== category) return false;
return true;
});
const loggedIn = me != null;
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/85 backdrop-blur-md">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Logo />
{/* "/ templates" subtitle is redundant on mobile — h1 below
already names the page. Keep on desktop as breadcrumb. */}
<span className="hidden text-[12.5px] text-[--color-fg-subtle] sm:inline">
/ templates
</span>
</div>
<nav className="flex items-center gap-1.5 sm:gap-2">
{loggedIn ? (
<>
{/* Dashboard link + "+ New server" pill hidden on mobile —
the UserMenu (avatar) + the MobileActionBar below cover
both navigation paths there. */}
<Link
href="/dashboard"
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
>
Dashboard
</Link>
<Link
href="/servers/new"
className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex"
>
+ New server
</Link>
<UserMenu />
</>
) : (
<>
<Link
href="/"
className="hidden text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg] sm:inline"
>
Home
</Link>
<Link
href="/login"
className="rounded-md bg-[--color-accent] px-2.5 py-1 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:px-3 sm:py-1.5 sm:text-[12.5px]"
>
Start building
</Link>
</>
)}
</nav>
</div>
</header>
<main
className={cn(
'mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6 sm:py-12',
// Bottom-bar clearance on mobile for logged-in users only — the
// MobileActionBar is fixed bottom and would overlap the last row
// of template cards otherwise.
loggedIn && 'pb-24 sm:pb-12',
)}
>
<header className="mb-6 max-w-2xl sm:mb-8">
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
Marketplace
</div>
<h1 className="mt-2 text-[24px] font-semibold tracking-tight sm:text-[32px]">
MCP server templates
</h1>
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted] sm:mt-3 sm:text-[14px]">
Pre-built MCP servers from the community. Fork in one click your own container, your
own credentials, fully isolated. The template author never sees your data.
</p>
</header>
{/* Filter row: stacks vertically on mobile (search on top for thumb
reach), inline on desktop. The order utility on the Input flips
it to the right side at sm: while filters wrap normally. */}
<div className="mb-6 flex flex-col gap-3 border-b border-[--color-border] pb-4 sm:flex-row sm:flex-wrap sm:items-center">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="order-first w-full sm:order-last sm:ml-auto sm:w-60"
/>
{/* Chips row — horizontally scrollable on narrow mobile so the
segmented controls never get squeezed below their min-width. */}
<div className="-mx-1 flex items-center gap-2 overflow-x-auto px-1 sm:m-0 sm:flex-wrap sm:gap-3 sm:overflow-visible sm:p-0">
{loggedIn && (
<>
<div className="flex shrink-0 gap-1 rounded-md border border-[--color-border] bg-[--color-bg-subtle] p-0.5">
{(['all', 'mine'] as Scope[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
'rounded-[4px] px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === scope
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s === 'all' ? 'All' : 'Mine'}
</button>
))}
</div>
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
</>
)}
{scope === 'all' && (
<>
<div className="flex shrink-0 gap-1">
{(['trending', 'top'] as Sort[]).map((s) => (
<button
key={s}
type="button"
onClick={() => setSort(s)}
className={cn(
'rounded-md px-2.5 py-1 text-[12.5px] capitalize transition-colors',
s === sort
? 'bg-[--color-bg-elevated] text-[--color-fg]'
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
)}
>
{s}
</button>
))}
</div>
<div className="hidden h-4 w-px shrink-0 bg-[--color-border] sm:block" />
</>
)}
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
// Hard width-cap — without it a long category name in the
// selected slot would blow the chip row out on mobile and
// push the scrollable region beyond the viewport. Native
// <select> truncates the visible label with ellipsis when
// the box is narrower than the text; the dropdown panel
// itself still shows full names when opened.
className="h-7 w-[140px] shrink-0 truncate rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[12.5px] focus:border-[--color-accent] focus:outline-none sm:w-[160px]"
>
<option value="">All categories</option>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
{!visible && <p className="mono text-[12px] text-[--color-fg-muted]">Loading</p>}
{visible && visible.length === 0 && scope === 'mine' && (
<div className="panel p-12 text-center">
<p className="text-[14px] text-[--color-fg]">
You haven&apos;t published any templates.
</p>
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
Build a server, then tick <span className="mono">Share as template</span> on the done
screen or use the Publish tab on any live server.
</p>
</div>
)}
{visible && visible.length === 0 && scope === 'all' && (
<div>
<div className="panel p-8 text-center sm:p-10">
<p className="text-[15px] font-medium text-[--color-fg]">
The marketplace is young be one of the first to publish.
</p>
<p className="mx-auto mt-2 max-w-md text-[12.5px] leading-relaxed text-[--color-fg-muted]">
Every server you build can be shared as a forkable template (your credentials never
travel with it). Not sure where to start? The{' '}
<Link href="/guides" className="text-[--color-accent] hover:underline">
guides
</Link>{' '}
walk through hosting and auth.
</p>
</div>
<p className="mt-8 mb-3 flex items-center gap-1.5 text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
<Lightbulb size={12} /> Starter ideas build one from a single prompt
</p>
<div className="grid gap-3 md:grid-cols-3">
{STARTER_IDEAS.map((idea) => (
<Link
key={idea.title}
href="/login"
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
>
<h2 className="text-[14px] font-semibold tracking-tight">{idea.title}</h2>
<p className="mt-1.5 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
{idea.desc}
</p>
<span className="mt-3 text-[11.5px] text-[--color-accent]">Build this </span>
</Link>
))}
</div>
</div>
)}
{visible && visible.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{visible.map((t) => (
<TemplateCard key={t.id} t={t} showStatus={scope === 'mine'} />
))}
</div>
)}
</main>
<footer className="border-t border-[--color-border] py-8">
<div className="mx-auto max-w-6xl px-4 text-[12px] text-[--color-fg-subtle] sm:px-6">
Every template is isolated: forking creates your own container with your own secrets.
</div>
</footer>
{/* Mobile tab-bar — only when signed in. Logged-out marketplace
browsing keeps the simple marketing chrome (login CTA in header). */}
{loggedIn && <MobileActionBar />}
</div>
);
}
function TemplateCard({ t, showStatus }: { t: Template; showStatus: boolean }) {
// takedown templates can't be opened (the detail route 410s); link to the
// server's Publish tab so the owner can see why. Everything else opens detail.
const href =
t.status === 'takedown' && t.sourceServerId
? `/servers/${t.sourceServerId}`
: `/templates/${t.slug}`;
return (
<Link
href={href}
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1.5">
<h2 className="text-[14.5px] font-semibold tracking-tight">{t.title}</h2>
{t.verified && (
<span
className="inline-flex items-center gap-0.5 rounded-full border border-[--color-accent]/40 bg-[--color-accent]/10 px-1.5 py-0.5 text-[9.5px] font-medium text-[--color-accent]"
title="Verified by BuildMyMCPServer"
>
<ShieldCheck size={9} /> verified
</span>
)}
</div>
{showStatus ? (
<span
className={cn(
'mono rounded-full border px-1.5 py-0.5 text-[9.5px]',
STATUS_STYLE[t.status],
)}
>
{t.status}
</span>
) : (
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 text-[10px] text-[--color-fg-subtle]">
{t.category}
</span>
)}
</div>
<p className="mt-2 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
{t.shortDescription}
</p>
<div className="mt-4 flex items-center justify-between text-[11px] text-[--color-fg-subtle]">
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1 mono" title="Total forks">
<GitFork size={11} />
{t.forkCount}
</span>
<span className="inline-flex items-center gap-1 mono" title="Active deployments">
<Activity size={11} />
{t.activeDeployments}
</span>
</div>
<span className="truncate">
{showStatus ? t.category : `by ${t.ownerName ?? t.ownerOrgName ?? 'anonymous'}`}
</span>
</div>
</Link>
);
}

View File

@@ -174,10 +174,20 @@ export function HeroStepRotator() {
y: glowDy,
}}
/>
{/* Terminal chrome: traffic lights + filename tab. The tile IS
the product's aesthetic (build logs, configs), so it should
look like a real terminal window, not a generic card. */}
<div className="relative flex items-center justify-between border-b border-[--color-border] px-4 py-2.5">
<span className="flex items-center gap-3">
<span aria-hidden 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" />
</span>
<span className="mono text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
{current.label}
</span>
</span>
<span className="mono text-[10.5px] tracking-[0.16em] text-[--color-accent]">
{current.badge}
</span>
@@ -189,23 +199,28 @@ export function HeroStepRotator() {
</AnimatePresence>
</div>
{/* Step indicator — accent dot is wider + glows so the active step
reads at a glance. Buttons stay clickable so users can jump. */}
<div className="flex items-center gap-2" role="tablist" aria-label="Hero flow steps">
{/* Step indicator — the visible dot stays small, but each button
carries generous padding so the effective touch target is ≥44px.
Plain buttons with aria-current (not a tablist — no arrow-key
semantics to honour, they're simple jump buttons). */}
<div className="flex items-center" aria-label="Hero flow steps">
{STEPS.map((s, i) => (
<button
key={s.badge}
type="button"
role="tab"
aria-selected={i === step}
aria-current={i === step}
aria-label={`Jump to ${s.badge}`}
onClick={() => setStep(i)}
className="flex min-h-11 min-w-11 items-center justify-center"
>
<span
className={`h-1.5 rounded-full transition-all duration-300 ${
i === step
? 'w-9 bg-[--color-accent] shadow-[0_0_10px_rgba(99,102,241,0.65)]'
: 'w-1.5 bg-[--color-border-strong] hover:bg-[--color-fg-subtle]'
}`}
/>
</button>
))}
</div>
</div>

View File

@@ -47,6 +47,9 @@ function formatTime(seconds: number): string {
export function HeroVideo() {
const videoRef = useRef<HTMLVideoElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set when the user explicitly pauses — the IntersectionObserver must not
// restart a video the user chose to stop.
const userPausedRef = useRef(false);
const [muted, setMuted] = useState(true);
const [playing, setPlaying] = useState(false);
const [playFailed, setPlayFailed] = useState(false);
@@ -69,10 +72,26 @@ export function HeroVideo() {
v.addEventListener('timeupdate', onTime);
v.addEventListener('loadedmetadata', onMeta);
v.addEventListener('durationchange', onMeta);
// Best-effort autoplay attempt — silently fail on browsers that
// block it; the overlay is the user's escape hatch.
v.play().catch(() => undefined);
// Play only while scrolled into view: with preload="metadata" the 2.6 MB
// mp4 stays off the critical path until the section is actually visible,
// and playback pauses again off-screen (saves battery + decode). The
// .play() attempt is still best-effort — browsers that block autoplay
// leave the overlay as the user's escape hatch. `userPaused` tracks an
// explicit pause so the observer doesn't fight the user's choice.
const io = new IntersectionObserver(
([entry]) => {
if (!entry) return;
if (entry.isIntersecting) {
if (!userPausedRef.current && v.paused) v.play().catch(() => undefined);
} else if (!v.paused) {
v.pause();
}
},
{ threshold: 0.35 },
);
io.observe(v);
return () => {
io.disconnect();
v.removeEventListener('play', onPlay);
v.removeEventListener('pause', onPause);
v.removeEventListener('timeupdate', onTime);
@@ -82,9 +101,12 @@ export function HeroVideo() {
}, []);
// Clear any pending hide timer on unmount.
useEffect(() => () => {
useEffect(
() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
}, []);
},
[],
);
const scheduleHide = useCallback(() => {
if (hideTimer.current) clearTimeout(hideTimer.current);
@@ -106,6 +128,7 @@ export function HeroVideo() {
if (!v) return;
if (v.paused) {
setPlayFailed(false);
userPausedRef.current = false;
try {
// .load() resets the media element's resource selection — required
// when an earlier autoplay attempt was blocked before the source
@@ -116,6 +139,7 @@ export function HeroVideo() {
setPlayFailed(true);
}
} else {
userPausedRef.current = true;
v.pause();
}
}, []);
@@ -165,11 +189,10 @@ export function HeroVideo() {
>
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
preload="auto"
preload="metadata"
poster="/videos/hero-poster.jpg"
onClick={onVideoClick}
className="size-full cursor-pointer object-cover"

View File

@@ -2,27 +2,52 @@
import { Menu, X } from 'lucide-react';
import Link from 'next/link';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
const LINKS = [
{ href: '/#how', label: 'How it works' },
{ href: '/templates', label: 'Templates' },
{ href: '/pricing', label: 'Pricing' },
{ href: '/docs', label: 'Docs' },
{ href: '/guides', label: 'Guides' },
{ href: '/changelog', label: 'Changelog' },
];
/** Hamburger menu shown below the md breakpoint, where the inline nav is hidden. */
export function MarketingMobileMenu() {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// While the panel is open: lock body scroll, close on Escape and on any
// pointerdown outside the menu subtree. All three effects unwind together
// when the panel closes or the component unmounts.
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
const onPointerDown = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPointerDown);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPointerDown);
};
}, [open]);
return (
<div className="md:hidden">
<div ref={rootRef} className="md:hidden">
<button
type="button"
aria-label={open ? 'Close menu' : 'Open menu'}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex size-8 items-center justify-center rounded-md text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
className="flex size-11 items-center justify-center rounded-md text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
{open ? <X size={18} /> : <Menu size={18} />}
</button>
@@ -34,20 +59,27 @@ export function MarketingMobileMenu() {
// browser falls back to transparent. Inline `var()` is unambiguous.
className="absolute inset-x-0 top-12 z-40 border-b border-[--color-border] shadow-lg shadow-black/40 backdrop-blur-md"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg) 80%, transparent)',
backgroundColor: 'color-mix(in oklab, var(--color-bg) 92%, transparent)',
}}
>
<nav className="mx-auto flex max-w-6xl flex-col px-6">
<nav className="mx-auto flex max-w-6xl flex-col px-6 pb-5">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
onClick={() => setOpen(false)}
className="border-b border-[--color-border] py-3.5 text-[14px] text-[--color-fg-muted] transition-colors last:border-0 hover:text-[--color-fg]"
className="flex min-h-11 items-center border-b border-[--color-border] text-[14px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
{l.label}
</Link>
))}
<Link
href="/login"
onClick={() => setOpen(false)}
className="btn-brand mt-4 inline-flex h-11 w-full items-center justify-center rounded-md text-[14px] font-medium"
>
Start building free
</Link>
</nav>
</div>
)}

View File

@@ -1,5 +1,60 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
// Friendly messages for known backend error codes. Anything not listed falls
// back to a status-based sentence — the user must never see a raw
// "api_error_409" or a bare code like "slug_taken".
const CODE_MESSAGES: Record<string, string> = {
slug_taken: 'That slug is already taken by one of your servers — pick a different one.',
unauthorized: 'Your session expired — please sign in again.',
forbidden: 'You dont have access to that.',
not_found: 'We couldnt find that.',
org_not_found: 'We couldnt find your workspace.',
user_not_found: 'Account not found.',
invalid_input: 'Some details werent valid — please check and try again.',
invalid_id: 'That link looks invalid.',
invalid_query: 'That request wasnt valid.',
rate_limited: 'Youve hit a rate limit — wait a moment and try again.',
plan_limit_reached: 'Youve reached your plans limit — upgrade to add more.',
subscription_suspended: 'Your subscription is paused — see Settings → Billing.',
secret_in_prompt:
'Your prompt looks like it contains an API key — remove it and add credentials in the next step.',
stripe_not_configured: 'Payments arent configured yet.',
no_active_subscription: 'No active subscription found.',
checkout_failed: 'Checkout couldnt start — please try again in a moment.',
google_oauth_not_configured: 'Google sign-in isnt available right now.',
taken_down: 'This template was taken down and cant be used.',
};
function statusFallback(status: number): string {
if (status === 400) return 'That request wasnt valid.';
if (status === 401) return 'Your session expired — please sign in again.';
if (status === 402) return 'A billing action is required to continue.';
if (status === 403) return 'You dont have access to that.';
if (status === 404) return 'We couldnt find that.';
if (status === 409) return 'That conflicts with something that already exists.';
if (status === 429) return 'Too many requests — please wait a moment.';
if (status >= 500) return 'Something went wrong on our side — please try again.';
return `Request failed (HTTP ${status}).`;
}
/**
* Turn any thrown error (from apiFetch or a network failure) into a clear,
* human-readable sentence. Prefers an explicit backend `detail`, then a mapped
* error code, then a status-based fallback. Never returns a raw code.
*/
export function humanizeError(e: unknown): string {
const err = e as { detail?: { detail?: string; error?: string }; status?: number; message?: string };
const d = err?.detail;
if (d && typeof d.detail === 'string' && d.detail.trim()) return d.detail;
if (d?.error) {
const mapped = CODE_MESSAGES[d.error];
if (mapped) return mapped;
}
if (typeof err?.status === 'number') return statusFallback(err.status);
if (err?.message && !/^api_error_/.test(err.message)) return err.message; // network / TypeError
return 'Something went wrong — please try again.';
}
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
@@ -15,9 +70,12 @@ export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}
try {
detail = await res.json();
} catch {}
const err = new Error(`api_error_${res.status}`);
(err as unknown as { detail?: unknown }).detail = detail;
(err as unknown as { status?: number }).status = res.status;
const err = new Error('') as Error & { detail?: unknown; status?: number };
err.detail = detail;
err.status = res.status;
// Human-readable message so any `(e as Error).message` fallback in the UI
// shows a real sentence instead of "api_error_409".
err.message = humanizeError(err);
throw err;
}
return (await res.json()) as T;

141
apps/web/lib/articles.ts Normal file
View File

@@ -0,0 +1,141 @@
// Single registry for all /guides/* articles. The guides index, sitemap and
// RSS feed all render from this list, so adding an article here is the only
// bookkeeping step a new guide page needs besides its own directory.
export interface Article {
slug: string;
title: string;
description: string;
/** Short badge shown on the index card, e.g. "Guide", "Comparison". */
tag: string;
/** ISO date the article first shipped. */
datePublished: string;
/** ISO date of last substantive edit. Defaults to datePublished. */
dateModified?: string;
}
export const ARTICLES: Article[] = [
{
slug: 'create-mcp-server-without-code',
title: 'How to create an MCP server without writing code (2026)',
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.',
tag: 'Guide',
datePublished: '2026-07-08',
},
{
slug: 'claude-desktop-mcp-setup',
title: 'Connect a custom MCP server to Claude Desktop (step by step)',
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.',
tag: 'Setup',
datePublished: '2026-07-08',
},
{
slug: 'chatgpt-mcp-connector',
title: 'Add a custom MCP connector to ChatGPT (2026 guide)',
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.',
tag: 'Setup',
datePublished: '2026-07-08',
},
{
slug: 'rest-api-to-mcp-server',
title: 'Wrap any REST API as an MCP server',
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.',
tag: 'Guide',
datePublished: '2026-07-08',
},
{
slug: 'mcp-server-hosting-pricing',
title: 'MCP server hosting: pricing & options compared (2026)',
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.',
tag: 'Comparison',
datePublished: '2026-07-08',
},
{
slug: 'composio-alternative',
title: 'Composio alternative for bespoke MCP tools',
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.',
tag: 'Alternative',
datePublished: '2026-07-08',
},
{
slug: 'smithery-alternative',
title: 'Smithery alternative: when you need hosting, not a directory',
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.',
tag: 'Alternative',
datePublished: '2026-07-08',
},
{
slug: 'mcp-transports-explained',
title: 'MCP transports explained: stdio vs SSE vs Streamable HTTP',
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.',
tag: 'Explainer',
datePublished: '2026-07-08',
},
{
slug: 'mcp-oauth-plain-english',
title: 'OAuth 2.1 for MCP servers: PKCE, DCR and RFC 8707 in plain English',
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.',
tag: 'Explainer',
datePublished: '2026-07-08',
},
{
slug: 'mcp-server-security-checklist',
title: 'MCP server security checklist: secrets, isolation, auth',
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.',
tag: 'Checklist',
datePublished: '2026-07-08',
},
{
slug: 'mcp-server-ohne-code-erstellen',
title: 'MCP Server ohne Code erstellen und hosten (2026)',
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.',
tag: 'Anleitung',
datePublished: '2026-07-08',
},
{
slug: 'host-mcp-server-with-oauth',
title: 'How to host a remote MCP server with OAuth (2026)',
description:
'Streamable HTTP, OAuth 2.1, PKCE and Resource Indicators — what it actually takes to put a remote MCP server in production, and the shortcuts.',
tag: 'Guide',
datePublished: '2026-05-31',
},
{
slug: 'hosted-mcp-platforms-compared',
title: 'Hosted MCP platforms compared: Cloudflare, Smithery, Composio & generating your own',
description:
'The MCP hosting landscape splits into four categories. Which one fits depends on whether you have a server already, need a catalog, or need bespoke logic.',
tag: 'Comparison',
datePublished: '2026-05-31',
},
{
slug: 'mintmcp-alternative',
title: 'MintMCP alternative: generate and host a custom MCP server',
description:
'MintMCP wraps an existing STDIO server into a remote one. If you do not have a server yet, here is the generate-from-a-prompt route — and where MintMCP still wins.',
tag: 'Alternative',
datePublished: '2026-05-31',
},
];
export function articleBySlug(slug: string): Article | undefined {
return ARTICLES.find((a) => a.slug === slug);
}
/** Articles newest-first, for the index page and the RSS feed. */
export function articlesNewestFirst(): Article[] {
return [...ARTICLES].sort((a, b) =>
(b.dateModified ?? b.datePublished).localeCompare(a.dateModified ?? a.datePublished),
);
}

118
apps/web/lib/og-article.tsx Normal file
View File

@@ -0,0 +1,118 @@
import { ImageResponse } from 'next/og';
// Shared Open Graph card for /guides/* articles. Each guide directory ships a
// tiny opengraph-image.tsx that calls this with its title + tag — the design
// stays consistent and Google Discover gets a ≥1200px image per article.
export const OG_SIZE = { width: 1200, height: 630 };
export function articleOgImage(opts: { title: string; tag: string }): ImageResponse {
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
backgroundColor: '#0a0a0b',
padding: '72px',
fontFamily: 'sans-serif',
position: 'relative',
}}
>
{/* Indigo→cyan accent bar along the top edge */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '1200px',
height: '8px',
background: 'linear-gradient(90deg, #6366f1 0%, #22d3ee 100%)',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<div
style={{
width: '46px',
height: '46px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px solid #fafafa',
borderRadius: '9px',
color: '#fafafa',
fontSize: '26px',
fontWeight: 700,
}}
>
M
</div>
<div
style={{
color: '#fafafa',
fontSize: '28px',
fontWeight: 600,
letterSpacing: '-0.02em',
}}
>
BuildMyMCPServer
</div>
</div>
<div
style={{
color: '#22d3ee',
fontSize: '20px',
textTransform: 'uppercase',
letterSpacing: '0.18em',
border: '1px solid #164e63',
borderRadius: '999px',
padding: '8px 22px',
}}
>
{opts.tag}
</div>
</div>
<div
style={{
color: '#fafafa',
fontSize: opts.title.length > 60 ? '56px' : '66px',
fontWeight: 700,
lineHeight: 1.08,
letterSpacing: '-0.03em',
maxWidth: '1000px',
}}
>
{opts.title}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '11px',
color: '#71717a',
fontSize: '22px',
}}
>
<div
style={{
width: '9px',
height: '9px',
borderRadius: '9px',
backgroundColor: '#6366f1',
}}
/>
MCP guides
</div>
<div style={{ color: '#71717a', fontSize: '22px' }}>buildmymcpserver.com/guides</div>
</div>
</div>,
{ ...OG_SIZE },
);
}

91
apps/web/lib/pricing.ts Normal file
View File

@@ -0,0 +1,91 @@
// Single source of truth for pricing tiers. Rendered on /pricing (all tiers)
// and as the landing-page teaser (Hobby + Pro). The landing page previously
// carried its own hardcoded copy of this data, which had already drifted —
// one array, two consumers, no drift.
export interface PricingTier {
name: string;
price: string;
tag: string;
description: string;
model: string;
modelDetail: string;
features: string[];
cta: string;
href: string;
highlight?: boolean;
}
export const TIERS: PricingTier[] = [
{
name: 'Hobby',
price: '€0',
tag: 'Forever free',
description: 'For trying things out and shipping single-user tools.',
model: 'Open-tier AI',
modelDetail: 'Free-tier model · ~30-60s analyze',
features: [
'1 MCP server',
'100,000 tool calls / month',
'5 prompt analyses / day',
'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.',
model: 'Claude AI',
modelDetail: 'Powered by Anthropic Claude · ~1020s analyze',
features: [
'5 MCP servers',
'1M tool calls / month',
'40 prompt analyses / day',
'Priority build queue',
'Custom domain · coming soon',
'Email support, 1 business-day response',
],
cta: 'Start Pro',
href: '/settings/billing?tier=pro_monthly',
highlight: true,
},
{
name: 'Team',
price: '€199',
tag: '/ month',
description: 'For teams that need an audit trail and room to scale.',
model: 'Claude AI',
modelDetail: "Anthropic's flagship quality",
features: [
'25 MCP servers',
'10M tool calls / month',
'50 prompt analyses / day',
'Audit log',
'RBAC · coming soon',
'Shared Slack channel support',
],
cta: 'Start Team',
href: '/settings/billing?tier=team_monthly',
},
{
name: 'Enterprise',
price: 'Custom',
tag: 'talk to us',
description: 'For organizations with custom infrastructure, compliance and scale needs.',
model: 'Claude AI',
modelDetail: 'Top-tier Claude · EU data-residency option',
features: [
'Unlimited servers',
'Custom infrastructure & data residency — on request',
'Dedicated hosting — scoped per contract',
'SSO / SAML — on request',
'Customer success manager',
],
cta: 'Contact sales',
href: 'mailto:sales@buildmymcpserver.com',
},
];

View File

@@ -61,11 +61,11 @@ export const FAQ: FaqItem[] = [
},
{
q: 'Cold starts?',
a: 'No cold starts. Containers stay warm. Sub-50ms tool-call overhead on average for in-region requests.',
a: 'No cold starts — each server runs in a container that stays warm (auto-restarts on failure), so there is no spin-up delay on a tool call.',
},
{
q: 'Rate limits?',
a: 'Default 100 requests/min/IP per tool. Configurable per server. Quota enforced at the proxy layer before hitting your container.',
a: 'Every request is gated by OAuth 2.1 before it reaches your container. Configurable per-server rate limiting is on the roadmap.',
},
{
q: 'How fast is generation?',
@@ -73,7 +73,7 @@ export const FAQ: FaqItem[] = [
},
{
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.',
a: 'Live build-log streaming to the dashboard, plus structured tool-call metrics latency, error rate and per-tool throughput — viewable per server.',
},
{
q: 'What if I cancel?',
@@ -81,7 +81,7 @@ export const FAQ: FaqItem[] = [
},
{
q: 'Custom domain?',
a: 'Pro plan and above. Add a CNAME, we provision a TLS certificate automatically.',
a: 'On the roadmap. Today every server is reachable at a buildmymcpserver.com URL with TLS; bring-your-own-domain support is coming.',
},
];
@@ -97,6 +97,8 @@ const SOFTWARE_FEATURES = [
'Self-hostable control plane with BYO Postgres and Redis',
];
// Enterprise is deliberately absent: its price is "Custom" and schema.org
// Offers with fabricated numeric prices are exactly what commit ee4713f purged.
const OFFERS = [
{
name: 'Hobby',
@@ -107,18 +109,12 @@ const OFFERS = [
name: 'Pro',
price: '49',
description:
'5 servers, 1M tool calls/month, custom domain, priority build queue, email support.',
'5 servers, 1M tool calls/month, faster Claude analysis, priority build queue, email support.',
},
{
name: 'Team',
price: '149',
description: '25 servers, 10M tool calls/month, RBAC + audit log, 99.9% SLA, Slack support.',
},
{
name: 'Enterprise',
price: '499',
description:
'Unlimited servers, bring-your-own-cloud, SSO/SAML, dedicated cluster, customer success.',
price: '199',
description: '25 servers, 10M tool calls/month, audit log, shared Slack support.',
},
];
@@ -184,6 +180,88 @@ export function faqJsonLd(items: FaqItem[] = FAQ): object {
};
}
/** TechArticle structured data for /guides/* SEO articles. */
export function articleJsonLd(opts: {
title: string;
description: string;
path: string;
datePublished: string;
dateModified?: string;
/**
* Absolute or site-relative image URL. Defaults to the route's generated
* opengraph-image (1200×630), which satisfies Google Discover's large-image
* requirement once the route ships an opengraph-image.tsx.
*/
image?: string;
/** Named human author. Falls back to the Organization. */
authorName?: string;
wordCount?: number;
/** BCP-47 language tag; the guides corpus is English except where noted. */
inLanguage?: string;
}): object {
const image = opts.image ?? `${SITE_URL}${opts.path}/opengraph-image`;
return {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: opts.title,
description: opts.description,
url: `${SITE_URL}${opts.path}`,
mainEntityOfPage: { '@type': 'WebPage', '@id': `${SITE_URL}${opts.path}` },
image: image.startsWith('http') ? image : `${SITE_URL}${image}`,
datePublished: opts.datePublished,
dateModified: opts.dateModified ?? opts.datePublished,
inLanguage: opts.inLanguage ?? 'en',
author: opts.authorName
? { '@type': 'Person', name: opts.authorName }
: { '@id': `${SITE_URL}/#organization` },
publisher: { '@id': `${SITE_URL}/#organization` },
...(opts.wordCount ? { wordCount: opts.wordCount } : {}),
};
}
/** BreadcrumbList structured data. Pass the trail from root to current page. */
export function breadcrumbJsonLd(trail: { name: string; path: string }[]): object {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: trail.map((crumb, i) => ({
'@type': 'ListItem',
position: i + 1,
name: crumb.name,
item: `${SITE_URL}${crumb.path}`,
})),
};
}
/** SoftwareApplication structured data for a published marketplace template. */
export function templateJsonLd(opts: {
slug: string;
title: string;
description: string;
category: string;
tools: string[];
author: string | null;
}): object {
return {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
'@id': `${SITE_URL}/templates/${opts.slug}#software`,
name: opts.title,
description: opts.description,
url: `${SITE_URL}/templates/${opts.slug}`,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'MCP server',
operatingSystem: 'Web Browser',
inLanguage: 'en',
keywords: ['MCP server', 'Model Context Protocol', opts.category],
featureList: opts.tools,
isAccessibleForFree: true,
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
...(opts.author ? { author: { '@type': 'Person', name: opts.author } } : {}),
publisher: { '@id': `${SITE_URL}/#organization` },
};
}
/**
* Per-page metadata. `title` is a bare string so the root layout's
* "%s | BuildMyMCPServer" template appends the brand exactly once.

View File

@@ -0,0 +1,114 @@
// Server-only fetchers for the public template marketplace. Used by the
// server-rendered template detail page (SEO metadata + JSON-LD) and the
// sitemap. Never import this into a client component.
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
export interface TemplateTool {
name: string;
description: string;
inputSchema: Record<string, unknown>;
}
export interface TemplateSecretHint {
key: string;
description: string;
howToGetUrl?: string;
}
export interface TemplateDetail {
id: string;
slug: string;
title: string;
shortDescription: string;
longDescription: string | null;
category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean;
forkCount: number;
activeDeployments: number;
toolsSchema: TemplateTool[];
generatedCode: string;
requiredSecrets: TemplateSecretHint[];
scopes: string[];
ownerName: string | null;
ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string;
}
/**
* Fetch a single public template by slug for server rendering. Returns null
* for missing / non-public templates so the page can `notFound()` — we only
* want `public` templates indexed.
*/
export async function fetchTemplate(slug: string): Promise<TemplateDetail | null> {
try {
const res = await fetch(`${API_BASE}/v1/templates/${encodeURIComponent(slug)}`, {
// Cache server-side for 5 min so crawler hits don't hammer the API.
next: { revalidate: 300 },
});
if (!res.ok) return null;
const data = (await res.json()) as { template?: TemplateDetail };
const t = data.template;
if (!t || t.status !== 'public') return null;
return t;
} catch {
return null;
}
}
export interface TemplateSummary {
id: string;
slug: string;
title: string;
shortDescription: string;
category: string;
status: 'draft' | 'public' | 'hidden' | 'takedown';
verified: boolean;
forkCount: number;
activeDeployments: number;
ownerName: string | null;
ownerOrgName: string | null;
sourceServerId: string | null;
createdAt: string;
}
/**
* Full public template list for server-rendering the marketplace grid so
* crawlers see the templates in the initial HTML. Best-effort: returns empty
* lists on error so the page still renders (the client component refetches
* on filter changes anyway).
*/
export async function fetchPublicTemplates(): Promise<{
templates: TemplateSummary[];
categories: string[];
}> {
try {
const res = await fetch(`${API_BASE}/v1/templates?sort=trending`, {
next: { revalidate: 300 },
});
if (!res.ok) return { templates: [], categories: [] };
const data = (await res.json()) as { templates?: TemplateSummary[]; categories?: string[] };
return { templates: data.templates ?? [], categories: data.categories ?? [] };
} catch {
return { templates: [], categories: [] };
}
}
/** Slugs of public templates, for the sitemap. Best-effort: returns [] on error. */
export async function fetchPublicTemplateSlugs(): Promise<string[]> {
try {
const res = await fetch(`${API_BASE}/v1/templates?limit=100&sort=newest`, {
next: { revalidate: 600 },
});
if (!res.ok) return [];
const data = (await res.json()) as
| { templates?: Array<{ slug?: string }> }
| Array<{ slug?: string }>;
const list = Array.isArray(data) ? data : (data.templates ?? []);
return list.map((t) => t.slug).filter((s): s is string => typeof s === 'string');
} catch {
return [];
}
}

View File

@@ -0,0 +1,91 @@
# BuildMyMCPServer — full documentation
> Turn a natural-language prompt into a hosted, OAuth 2.1-protected Model Context Protocol (MCP) server in about 60 seconds. This file condenses the full documentation at https://buildmymcpserver.com/docs for LLM consumption. See https://buildmymcpserver.com/llms.txt for the short index.
## Quickstart (https://buildmymcpserver.com/docs)
Describe the tool you want, paste in any credentials, watch the build stream, copy a snippet into your AI client. Five minutes from first prompt to a live OAuth-protected MCP server.
Prerequisites: an MCP-capable AI client (Claude Desktop, Cursor, ChatGPT Custom Connectors, VS Code Copilot, Continue.dev) and API credentials for whatever the server should access — or pick the echo example to skip credentials.
1. **Sign in** to the dashboard.
2. **Describe your tool** in plain language. Example prompt: "Search and read pages from our Notion workspace via the Notion API. Tools: search_pages(query), get_page_content(page_id). Auth: NOTION_API_KEY."
3. **Confirm the plan.** The wizard shows which tools were parsed from the prompt, the input schemas, and which credentials are needed. Everything is editable before the build starts.
4. **Watch the build stream** over WebSocket through five states: queued → generating (Claude returns the spec) → building (TypeScript rendered, static checks, Docker image) → deploying (container boot) → live (endpoint responds, OAuth gate active).
5. **Install in your client.** The Done screen shows copy-ready snippets for Claude Desktop, Cursor and ChatGPT. The OAuth handshake runs automatically on the first tool call.
Example client config:
{
"mcpServers": {
"notion-reader": {
"url": "https://<your-server>.mcp.buildmymcpserver.com/mcp",
"auth": "oauth2"
}
}
}
## MCP concepts (https://buildmymcpserver.com/docs/concepts)
Model Context Protocol is an open standard from Anthropic for connecting AI assistants to external tools, data and APIs. Three primitives, one transport:
- **Tools** — functions the AI can invoke. Each has a name, description, input schema (JSON Schema/Zod) and a server-side implementation. The AI decides when to call them based on the description.
- **Resources** — read-only, URI-addressed data the AI can fetch (files, documents, database records).
- **Prompts** — parameterized prompt templates the server exposes to encourage specific orchestration patterns.
**Transport: Streamable HTTP.** Every generated server speaks Streamable HTTP (MCP spec 2025-11-25). The older SSE transport was deprecated in June 2025 and is not supported. One HTTP endpoint at /mcp, optionally negotiating a long-lived text/event-stream when the server pushes updates.
**Session lifecycle:** initialize (client sends protocol version + capabilities; server assigns a session id via the mcp-session-id header) → notifications/initialized → tools/list, tools/call, resources/list, prompts/list.
**Why MCP and not just REST:** REST APIs need bespoke OpenAPI integration per client. MCP standardizes discovery, invocation, auth and streaming, so any spec-compliant client picks up any spec-compliant server with zero glue code.
## OAuth 2.1 flow (https://buildmymcpserver.com/docs/oauth)
Every generated server is an OAuth 2.1 Resource Server; the control plane is the Authorization Server. Standards implemented:
- OAuth 2.1 draft (draft-ietf-oauth-v2-1) — no implicit flow, mandatory PKCE
- RFC 8414 — Authorization Server Metadata
- RFC 9728 — Protected Resource Metadata
- RFC 8707 — Resource Indicators (audience binding)
- RFC 7591 — Dynamic Client Registration
End-to-end: the first unauthenticated request gets a 401 with a WWW-Authenticate header pointing at the server's protected-resource metadata. The client fetches it, discovers the authorization server, registers itself dynamically (no human in the loop; each AI surface gets its own ephemeral client identity), then runs Authorization Code + PKCE. The user consents, the client exchanges the code for an RS256-signed JWT bound to the specific server's resource URL. The runner verifies signature (against the AS JWKS), issuer, audience and expiry on every call. No token passthrough — the runner never forwards the client's token to a downstream API.
Why audience binding matters: without RFC 8707, a token issued for one customer's MCP server could be replayed against another customer's server.
## Authoring tools (https://buildmymcpserver.com/docs/authoring)
What you write in the prompt is what Claude turns into TypeScript. Rules the generator enforces on generated code:
- No eval, no new Function, no child_process — static checks reject the build.
- No import statements in tool bodies; the runtime injects fetch, pg and z (Zod).
- Secrets live in process.env, never embedded literally.
- External HTTP calls must use AbortSignal.timeout (default 10s).
- Database access via pg with parameterized queries only.
- Errors return as MCP error-content, not thrown exceptions.
Prompt patterns that work: be explicit about tool names ("Tool: search_pages(query)"); name the credentials ("Auth: NOTION_API_KEY"); flag destructive tools so clients can warn; one server per integration rather than one server per tool.
Iteration: open the server's Iterate tab, describe the change, a new build version is queued and rolling-deployed — the old version stays live until the new one is healthy.
## Self-hosting (https://buildmymcpserver.com/docs/self-hosting)
The control plane and generator are open. Requirements: Node.js 20+, pnpm 9+, a Docker engine reachable from the generator, Postgres 16+, Redis 7+, and an Anthropic API key (optional — a mock generator covers offline dev).
Key environment variables: DATABASE_URL, REDIS_URL, ANTHROPIC_API_KEY, SECRETS_ENCRYPTION_KEY (32-byte hex AES-256-GCM key), CONTROL_PLANE_PUBLIC_URL (OAuth issuer), OAUTH_KEY_DIR (RS256 keypair), RUNNER_PORT_RANGE_START/END (host-port window for generated containers).
Production container sandboxing flags: --read-only, --cap-drop=ALL, --security-opt=no-new-privileges, --cpus=0.5 --memory=512m.
## FAQ highlights (https://buildmymcpserver.com/docs/faq)
- **How does LLM-generated code stay safe?** Three layers: strict Zod validation of the JSON spec, a regex scan for banned tokens (eval, child_process, prompt-injection markers), and a static check on the rendered TypeScript before the Docker build. Any failure stops the deploy.
- **What if Claude generates a broken tool?** The build fails at static-check or Docker-build stage; the user sees the exact error in the live log, refines the prompt and rebuilds. No invalid server serves traffic.
- **Do secrets leave the environment?** No. AES-256-GCM encrypted at rest, decrypted only when injected into the container at boot. Never in audit logs, build logs, or prompts sent to Claude.
- **No API key?** The generator falls back to a deterministic mock spec (echo + now tools) so the full pipeline can be verified without credits.
## Pricing (https://buildmymcpserver.com/pricing)
- Hobby: €0 — 1 server, 100,000 tool calls/month, community support.
- Pro: €49/month — 5 servers, 1M tool calls/month, priority build queue, email support. Custom domains on the roadmap.
- Team: €199/month — 25 servers, 10M tool calls/month, audit log, Slack support. RBAC on the roadmap.
- Enterprise: custom — unlimited servers; infrastructure, data residency, SSO/SAML scoped per contract.

View File

@@ -19,22 +19,39 @@ The workflow:
- **OAuth 2.1 authorization server** — PKCE, Dynamic Client Registration (RFC 7591), Resource Indicators (RFC 8707), RS256 JWKS.
- **Streamable HTTP transport** — the modern MCP transport, compatible with every major MCP client.
- **Per-server isolation** — each generated server runs in its own Docker container.
- **Encrypted secrets** — customer credentials are stored with AES-256-GCM envelope encryption and injected only at runtime; never logged.
- **Encrypted secrets** — customer credentials are stored with AES-256-GCM encryption and injected only at runtime; never logged.
- **Template marketplace** — publish a server you built as a template, or fork one someone else published and add your own credentials.
- **Source export** — export the full TypeScript source of any server. No vendor lock-in.
- **Self-hostable** — the runner is a plain Docker container; the control plane runs against your own Postgres and Redis.
## Pricing
- **Hobby** — free. 1 server, 100k tool calls/month.
- **Pro** — €49/month. 5 servers, 1M tool calls/month, custom domain, priority build queue.
- **Team** — €149/month. 25 servers, 10M tool calls/month, RBAC, audit log, 99.9% SLA.
- **Enterprise** — from €499/month. Unlimited servers, bring-your-own-cloud, SSO/SAML.
- **Hobby** — €0, forever free. 1 MCP server, 100,000 tool calls/month, BuildMyMCP subdomain, community support.
- **Pro** — €49/month. 5 MCP servers, 1M tool calls/month, priority build queue, email support. Custom domains are on the roadmap (not shipped yet).
- **Team** — €199/month. 25 MCP servers, 10M tool calls/month, audit log, shared Slack channel support. RBAC is on the roadmap (not shipped yet).
- **Enterprise** — custom pricing. Unlimited servers; custom infrastructure, data residency, dedicated hosting and SSO/SAML scoped per contract.
Full details: https://buildmymcpserver.com/pricing
## Docs
- Quickstart: https://buildmymcpserver.com/docs
- Concepts: https://buildmymcpserver.com/docs/concepts
- OAuth: https://buildmymcpserver.com/docs/oauth
- Authoring servers: https://buildmymcpserver.com/docs/authoring
- API reference: https://buildmymcpserver.com/docs/api-reference
- Self-hosting: https://buildmymcpserver.com/docs/self-hosting
- FAQ: https://buildmymcpserver.com/docs/faq
## Guides
- How to host a remote MCP server with OAuth (2026): https://buildmymcpserver.com/guides/host-mcp-server-with-oauth
- Hosted MCP platforms compared: https://buildmymcpserver.com/guides/hosted-mcp-platforms-compared
- MintMCP alternative: https://buildmymcpserver.com/guides/mintmcp-alternative
## Optional
- Full docs content in one file: https://buildmymcpserver.com/llms-full.txt
- Template marketplace: https://buildmymcpserver.com/templates
- Security posture: https://buildmymcpserver.com/security
- System status: https://buildmymcpserver.com/status

View File

@@ -212,3 +212,30 @@ export const InstallTarget = z.enum([
'raw-url',
]);
export type InstallTarget = z.infer<typeof InstallTarget>;
// ---- Prompt secret guard ----
// Unambiguous provider key/token shapes. Used to reject a prompt that contains
// a real credential BEFORE it is sent to the LLM — keys must never reach the
// model. Kept tight (provider prefixes / structural markers) so normal prompts
// don't trip it. Shared by the API preview endpoints and the web wizard.
const PROMPT_SECRET_PATTERNS: ReadonlyArray<{ name: string; re: RegExp }> = [
{ name: 'an Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
{ name: 'an OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}/ },
{ name: 'an OpenAI key', re: /\bsk-[A-Za-z0-9]{32,}\b/ },
{ name: 'a GitHub token', re: /\bgh[posru]_[A-Za-z0-9]{30,}\b/ },
{ name: 'an AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
{ name: 'a Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
{ name: 'a Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
{ name: 'a Stripe secret key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/ },
{ name: 'a Stripe publishable key', re: /\bpk_(?:live|test)_[A-Za-z0-9]{20,}/ },
{ name: 'a JWT / bearer token', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/ },
{ name: 'a private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
];
/** First secret-like token found in the text, else null. */
export function findSecretInPrompt(text: string): string | null {
for (const { name, re } of PROMPT_SECRET_PATTERNS) {
if (re.test(text)) return name;
}
return null;
}

275
scripts/seed-templates.mjs Normal file
View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
// Seed first-party marketplace templates through the real product flow:
// preview → create server → wait for live → publish as template.
//
// The API only publishes templates from LIVE servers with a successful build,
// so seeding is the honest path — every seeded template carries real generated
// code, exactly what a user's build would produce.
//
// Usage:
// BMM_SESSION=<bmm_session cookie value> node scripts/seed-templates.mjs # dry-run (default)
// BMM_SESSION=... node scripts/seed-templates.mjs --apply # execute
// BMM_API_URL=https://api.buildmymcpserver.com BMM_SESSION=... node ... --apply # against prod (deliberate!)
//
// Secrets: templates whose server needs credentials read them from env
// (SEED_<KEY>). Missing secret → that seed is skipped with a warning, never
// created with a fake value. echo-demo needs none and always works.
//
// Idempotent: seeds whose derived slug already exists in GET /v1/templates
// are skipped. Rate limits apply (previews/builds per day per plan) — run
// under an account with headroom or spread over days.
const API = process.env.BMM_API_URL ?? 'http://localhost:4000';
const SESSION = process.env.BMM_SESSION;
const APPLY = process.argv.includes('--apply');
const BUILD_TIMEOUT_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 5000;
/** @type {Array<{slug: string, name: string, serverSlug: string, category: string, prompt: string, shortDescription: string, longDescription: string, secretEnv: Record<string, string>, secretHints: Array<{key: string, description: string, howToGetUrl?: string}>}>} */
const SEEDS = [
{
slug: 'echo-demo',
name: 'Echo Demo',
serverSlug: 'seed-echo-demo',
category: 'demo',
prompt:
'Create a minimal demo MCP server with two tools: echo (returns the input text unchanged) and now (returns the current UTC timestamp in ISO 8601). No external APIs, no secrets.',
shortDescription:
'The smallest possible MCP server — echo and a UTC clock. Fork it to see the full flow in under a minute.',
longDescription:
'A dependency-free demo server with two tools: `echo` returns whatever text you send it, `now` returns the current UTC timestamp. Useful as a first fork to watch the build pipeline, test your client connection and inspect the OAuth flow before wiring a real API.',
secretEnv: {},
secretHints: [],
},
{
slug: 'notion-search',
name: 'Notion Search',
serverSlug: 'seed-notion-search',
category: 'productivity',
prompt:
'Create an MCP server that searches a Notion workspace. Tools: search_pages (query string, returns matching page titles and ids via the Notion search API) and get_page_content (page_id, returns the page blocks as plain text). Auth: NOTION_API_KEY used as a Bearer token against api.notion.com with Notion-Version header.',
shortDescription:
'Search pages and read content from your Notion workspace. Bring your own integration token.',
longDescription:
'Two tools against the official Notion API: `search_pages` for workspace-wide search and `get_page_content` to read a page as plain text. Fork it, paste your own internal-integration token, and your AI client can look things up in Notion. Read-only.',
secretEnv: { NOTION_API_KEY: 'SEED_NOTION_API_KEY' },
secretHints: [
{
key: 'NOTION_API_KEY',
description:
'Internal integration secret from notion.so/my-integrations (read scope is enough).',
howToGetUrl: 'https://www.notion.so/my-integrations',
},
],
},
{
slug: 'github-issues',
name: 'GitHub Issues',
serverSlug: 'seed-github-issues',
category: 'developer-tools',
prompt:
'Create an MCP server for GitHub issues scoped to a single repository. Tools: list_issues (state filter open/closed/all), get_issue (issue number, returns title/body/labels/comments count) and search_issues (query string). Auth: GITHUB_TOKEN as Bearer token, GITHUB_REPO in owner/repo format used in the API paths.',
shortDescription:
'List, read and search issues in one GitHub repo. Scoped by a fine-grained token you control.',
longDescription:
'Three read-only tools against the GitHub REST API, scoped to a single repository via GITHUB_REPO: `list_issues`, `get_issue` and `search_issues`. Use a fine-grained personal access token with Issues read permission and nothing else.',
secretEnv: { GITHUB_TOKEN: 'SEED_GITHUB_TOKEN', GITHUB_REPO: 'SEED_GITHUB_REPO' },
secretHints: [
{
key: 'GITHUB_TOKEN',
description: 'Fine-grained PAT with read-only Issues permission on the target repo.',
howToGetUrl: 'https://github.com/settings/personal-access-tokens',
},
{ key: 'GITHUB_REPO', description: 'Repository in owner/repo format, e.g. vercel/next.js.' },
],
},
{
slug: 'stripe-readonly',
name: 'Stripe Read-Only',
serverSlug: 'seed-stripe-readonly',
category: 'finance',
prompt:
'Create a read-only MCP server for Stripe. Tools: list_charges (limit param, returns recent charges with amount/currency/status), get_customer (customer id), list_refunds (limit param). Auth: STRIPE_API_KEY as Bearer token against api.stripe.com. Read-only — never create or mutate anything.',
shortDescription:
'Look up charges, customers and refunds from Stripe. Use a restricted read-only key.',
longDescription:
'Read-only Stripe lookups: `list_charges`, `get_customer`, `list_refunds`. Designed for a restricted API key with read-only permissions — the server never writes. Ask your AI “what were yesterdays failed charges?” instead of opening the dashboard.',
secretEnv: { STRIPE_API_KEY: 'SEED_STRIPE_API_KEY' },
secretHints: [
{
key: 'STRIPE_API_KEY',
description: 'Restricted key with read-only scopes (Charges, Customers, Refunds).',
howToGetUrl: 'https://dashboard.stripe.com/apikeys',
},
],
},
{
slug: 'postgres-readonly',
name: 'PostgreSQL Read-Only',
serverSlug: 'seed-postgres-readonly',
category: 'data',
prompt:
'Create a read-only PostgreSQL MCP server. Tools: list_tables (returns schema-qualified table names), describe_table (table name, returns columns and types from information_schema) and run_query (SELECT-only — reject any statement that is not a single SELECT). Auth: DATABASE_URL connection string.',
shortDescription:
'Schema introspection and SELECT-only queries against your Postgres. Nothing gets written.',
longDescription:
'`list_tables`, `describe_table` and a guarded `run_query` that accepts single SELECT statements only. Point it at a read-only database role for defense in depth — the code additionally rejects non-SELECT statements before execution.',
secretEnv: { DATABASE_URL: 'SEED_DATABASE_URL' },
secretHints: [
{
key: 'DATABASE_URL',
description: 'postgres:// connection string — use a read-only role.',
},
],
},
{
slug: 'rest-wrapper',
name: 'REST API Wrapper',
serverSlug: 'seed-rest-wrapper',
category: 'developer-tools',
prompt:
'Create an MCP server that wraps a generic REST API. Tools: get_resource (path param appended to a base URL, returns the JSON response) and search_resources (path plus query-string params object). Auth: API_BASE_URL for the base URL and API_TOKEN sent as a Bearer token. Only allow requests to the configured base URL.',
shortDescription: 'Point it at any JSON REST API — base URL + token in, typed MCP tools out.',
longDescription:
'The template for “I just want my existing API in Claude”: configure API_BASE_URL and API_TOKEN, get `get_resource` and `search_resources` tools that only ever call your configured host. Fork it and iterate the prompt to add endpoint-specific tools.',
secretEnv: { API_BASE_URL: 'SEED_API_BASE_URL', API_TOKEN: 'SEED_API_TOKEN' },
secretHints: [
{
key: 'API_BASE_URL',
description: 'Base URL of your API, e.g. https://api.example.com/v1.',
},
{ key: 'API_TOKEN', description: 'Bearer token the wrapper sends with every request.' },
],
},
];
if (!SESSION) {
console.error(
'BMM_SESSION is required (value of the bmm_session cookie of the seeding account).',
);
process.exit(1);
}
/** @param {string} path @param {RequestInit} [init] */
async function api(path, init = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Cookie: `bmm_session=${SESSION}`,
...(init.headers ?? {}),
},
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = body?.detail ?? body?.error ?? res.status;
throw new Error(`${init.method ?? 'GET'} ${path}${res.status}: ${detail}`);
}
return body;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Derive the slug the publish endpoint will generate from a title. */
function titleSlug(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 48);
}
async function main() {
console.log(`Target API: ${API} ${APPLY ? '(APPLY)' : '(dry-run — pass --apply to execute)'}`);
const existing = await api('/v1/templates?sort=newest');
const existingSlugs = new Set((existing.templates ?? []).map((t) => t.slug));
for (const seed of SEEDS) {
const expectedSlug = titleSlug(seed.name);
if (existingSlugs.has(expectedSlug) || existingSlugs.has(seed.slug)) {
console.log(`${seed.slug} — already published, skipping`);
continue;
}
// Resolve secrets from env; skip seeds we can't provision honestly.
const secrets = {};
let missing = null;
for (const [key, envName] of Object.entries(seed.secretEnv)) {
const val = process.env[envName];
if (!val) {
missing = envName;
break;
}
secrets[key] = val;
}
if (missing) {
console.warn(
`${seed.slug} — skipped: env ${missing} not set (refusing to seed with fake credentials)`,
);
continue;
}
if (!APPLY) {
console.log(
`${seed.slug} — would run preview → create (${seed.serverSlug}) → wait live → publish`,
);
continue;
}
console.log(`${seed.slug} — previewing spec…`);
const preview = await api('/v1/servers/preview', {
method: 'POST',
body: JSON.stringify({ prompt: seed.prompt }),
});
console.log(
` spec ok (${preview.spec.tools.length} tools, source=${preview.source}) — creating server…`,
);
const created = await api('/v1/servers', {
method: 'POST',
body: JSON.stringify({
name: seed.name,
slug: seed.serverSlug,
prompt: seed.prompt,
secrets,
previewId: preview.previewId,
}),
});
const serverId = created.server.id;
process.stdout.write(' building');
const deadline = Date.now() + BUILD_TIMEOUT_MS;
let status = created.server.status;
while (status !== 'live') {
if (Date.now() > deadline)
throw new Error(`${seed.slug}: build timed out (status=${status})`);
if (status === 'failed' || status === 'error') throw new Error(`${seed.slug}: build failed`);
await sleep(POLL_INTERVAL_MS);
const s = await api(`/v1/servers/${serverId}`);
status = s.server.status;
process.stdout.write('.');
}
console.log(' live');
const published = await api('/v1/templates', {
method: 'POST',
body: JSON.stringify({
serverId,
title: seed.name,
shortDescription: seed.shortDescription,
longDescription: seed.longDescription,
category: seed.category,
secretHints: seed.secretHints,
}),
});
console.log(`${seed.slug} — published as /templates/${published.template.slug}`);
}
console.log('Done.');
}
main().catch((err) => {
console.error(`\nSeed failed: ${err.message}`);
process.exit(1);
});