Compare commits
18 Commits
092290bb38
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4f0db5719 | ||
|
|
a08f5f05b1 | ||
|
|
089074d104 | ||
|
|
17056d0b30 | ||
|
|
cba45402ce | ||
|
|
3dc65e4f4d | ||
|
|
2a12ea18cd | ||
|
|
be02600759 | ||
|
|
ee4713f82c | ||
|
|
bd82a67fba | ||
|
|
7eb323e8f8 | ||
|
|
74ca59b8b7 | ||
|
|
4d717d877f | ||
|
|
4687c8be52 | ||
|
|
1349dc1dc0 | ||
|
|
21a5cf5762 | ||
|
|
cf423de3d5 | ||
|
|
9d5386ccba |
@@ -1,5 +1,8 @@
|
||||
# ---- Core ----
|
||||
NODE_ENV=development
|
||||
# Local dev only: skip runner container hardening (--read-only etc. break on
|
||||
# Windows Docker Desktop). NEVER set this in .env.production. (GEN-002)
|
||||
RUNNER_DISABLE_HARDENING=1
|
||||
|
||||
# ---- Database ----
|
||||
DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm
|
||||
@@ -10,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
|
||||
|
||||
@@ -46,9 +46,9 @@ OAUTH_ISSUER=https://api.buildmymcpserver.com
|
||||
SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32
|
||||
|
||||
# ---- Admin bootstrap (upserted idempotently on API boot) ----
|
||||
ADMIN_EMAIL=marco.frangiskatos@gmail.com
|
||||
ADMIN_EMAIL=CHANGE-ME-admin@example.com
|
||||
ADMIN_PASSWORD=CHANGE-ME-strong-admin-password
|
||||
ADMIN_NAME=Marco Frangiskatos
|
||||
ADMIN_NAME=CHANGE-ME-Admin
|
||||
|
||||
# ---- Anthropic (empty = mock generation; set for real Claude generation) ----
|
||||
ANTHROPIC_API_KEY=
|
||||
@@ -75,6 +75,23 @@ RUNNER_HOST=buildmymcpserver.com
|
||||
RUNNER_PORT_RANGE_START=4400
|
||||
RUNNER_PORT_RANGE_END=4900
|
||||
|
||||
# ---- Stripe (billing) ----
|
||||
# Secret key (server-side only — NEVER expose). From Stripe Dashboard → Developers → API keys.
|
||||
STRIPE_SECRET_KEY=CHANGE-ME-sk_live_...
|
||||
# Publishable key (safe to expose). Used by the embedded in-app checkout.
|
||||
STRIPE_PUBLISHABLE_KEY=CHANGE-ME-pk_live_...
|
||||
# Same publishable key, exposed to the web client bundle at BUILD time (the web
|
||||
# image is rebuilt by the deploy, so this must be set before deploying or the
|
||||
# in-app checkout shows "not configured"). Keep it identical to STRIPE_PUBLISHABLE_KEY.
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=CHANGE-ME-pk_live_...
|
||||
# Webhook signing secret — from the endpoint you create at /v1/billing/webhook.
|
||||
STRIPE_WEBHOOK_SECRET=CHANGE-ME-whsec_...
|
||||
# Price IDs (price_… not prod_…) from each product's pricing in the Dashboard.
|
||||
STRIPE_PRICE_PRO_MONTHLY=CHANGE-ME-price_...
|
||||
STRIPE_PRICE_PRO_YEARLY=CHANGE-ME-price_...
|
||||
STRIPE_PRICE_TEAM_MONTHLY=CHANGE-ME-price_...
|
||||
STRIPE_PRICE_TEAM_YEARLY=CHANGE-ME-price_...
|
||||
|
||||
# ---- Observability (optional) ----
|
||||
SENTRY_DSN=
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Plan } from '@bmm/llm';
|
||||
import { Queue } from 'bullmq';
|
||||
import { getRedis } from './redis.js';
|
||||
|
||||
// BullMQ priority: LOWER number = processed sooner. Paid tiers jump ahead of
|
||||
// free in the shared build queue — this is what makes the "priority build
|
||||
// queue" plan claim actually true.
|
||||
const PLAN_PRIORITY: Record<Plan, number> = { enterprise: 1, team: 2, pro: 3, hobby: 4 };
|
||||
export function buildPriority(plan: Plan): number {
|
||||
return PLAN_PRIORITY[plan] ?? 4;
|
||||
}
|
||||
|
||||
export interface BuildJobData {
|
||||
buildId: string;
|
||||
serverId: string;
|
||||
@@ -17,7 +26,14 @@ let queue: Queue<BuildJobData> | null = null;
|
||||
|
||||
export function getBuildQueue(): Queue<BuildJobData> {
|
||||
if (!queue) {
|
||||
queue = new Queue<BuildJobData>('build', { connection: getRedis() });
|
||||
queue = new Queue<BuildJobData>('build', {
|
||||
connection: getRedis(),
|
||||
// Explicit job lifecycle. attempts:1 because a build is non-idempotent
|
||||
// (allocates a host port, runs a container, spends an LLM call) — a blind
|
||||
// BullMQ retry would double-spend; users re-run via /iterate instead.
|
||||
// removeOnComplete/Fail caps Redis growth. (GEN-007)
|
||||
defaultJobOptions: { attempts: 1, removeOnComplete: 100, removeOnFail: 500 },
|
||||
});
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,6 +74,17 @@ export async function isDuplicateEvent(eventId: string): Promise<boolean> {
|
||||
return set === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back the idempotency marker for an event whose handler FAILED, so
|
||||
* Stripe's retry re-processes it. Without this, the marker set by the failed
|
||||
* first attempt makes every retry look like a duplicate and the event is lost
|
||||
* forever (e.g. a paid org that never gets upgraded). (BILL-003)
|
||||
*/
|
||||
export async function clearProcessedEvent(eventId: string): Promise<void> {
|
||||
const redis = getRedis();
|
||||
await redis.del(`stripe:event:${eventId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity-check that price-id env vars actually contain price ids — a common
|
||||
* setup mistake is to paste the product id (prod_…) instead. Logs loudly on
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { config } from '../config.js';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import {
|
||||
type PriceTier,
|
||||
clearProcessedEvent,
|
||||
isDuplicateEvent,
|
||||
planFromPriceId,
|
||||
priceIdForTier,
|
||||
@@ -41,6 +42,14 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
// Embedded UI: the payment form mounts INSIDE our dashboard via Stripe.js
|
||||
// instead of redirecting to checkout.stripe.com. Keeps the flow in-app
|
||||
// (critical for the installed PWA, which otherwise pops out to the
|
||||
// system browser). Embedded mode uses return_url, not success/cancel_url.
|
||||
// NOTE: stripe-node v22 / API 2025-10 renamed this enum 'embedded' →
|
||||
// 'embedded_page'; it returns a client_secret for @stripe/react-stripe-js
|
||||
// EmbeddedCheckout. ('hosted' is now 'hosted_page'.)
|
||||
ui_mode: 'embedded_page',
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card', 'sepa_debit'],
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
@@ -54,8 +63,7 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
||||
subscription_data: {
|
||||
metadata: { orgId: user.orgId, userId: user.userId },
|
||||
},
|
||||
success_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true`,
|
||||
cancel_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?cancelled=true`,
|
||||
return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
||||
automatic_tax: { enabled: true },
|
||||
tax_id_collection: { enabled: true },
|
||||
billing_address_collection: 'required',
|
||||
@@ -71,7 +79,8 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
|
||||
return reply.send({ url: session.url, sessionId: session.id });
|
||||
// client_secret drives the embedded form; sessionId for optional verification.
|
||||
return reply.send({ clientSecret: session.client_secret, sessionId: session.id });
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'checkout session create failed');
|
||||
const msg = err instanceof Error ? err.message : 'unknown_error';
|
||||
@@ -272,6 +281,14 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
||||
items: [{ id: itemId, price: newPriceId }],
|
||||
proration_behavior: 'create_prorations',
|
||||
});
|
||||
// Reconcile the local plan immediately instead of waiting for the
|
||||
// customer.subscription.updated webhook — otherwise quota enforcement
|
||||
// reads a stale tier in the gap between this call and webhook delivery.
|
||||
// Idempotent: the webhook will set the same value. (BILL-001)
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({ plan: planFromPriceId(newPriceId) })
|
||||
.where(eq(organizations.id, user.orgId));
|
||||
await audit({
|
||||
orgId: user.orgId,
|
||||
userId: user.userId,
|
||||
@@ -328,6 +345,11 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
|
||||
await handleStripeEvent(app, event);
|
||||
return reply.send({ ok: true });
|
||||
} catch (err) {
|
||||
// Roll back the idempotency marker so the retry actually re-runs the
|
||||
// handler instead of being skipped as a duplicate. Handlers are
|
||||
// idempotent (they SET state, not increment), so a rare double-process
|
||||
// on concurrent retries is safe. (BILL-003)
|
||||
await clearProcessedEvent(event.id);
|
||||
// Return 5xx so Stripe retries with exponential backoff.
|
||||
app.log.error(
|
||||
{ err, eventId: event.id, type: event.type },
|
||||
@@ -364,11 +386,26 @@ async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Pro
|
||||
}
|
||||
|
||||
async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> {
|
||||
// Prefer the metadata we set at checkout — it's the most reliable mapping.
|
||||
// Fallback: look the org up by stored customer id.
|
||||
const metaOrgId = sub.metadata?.orgId;
|
||||
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) return metaOrgId;
|
||||
// Prefer the metadata we set at checkout — but DON'T blindly trust it. A
|
||||
// webhook signature proves the event came from Stripe, not that
|
||||
// sub.metadata.orgId is honest (metadata is editable in the dashboard/portal).
|
||||
// Only honour the metadata orgId if the subscription's customer actually
|
||||
// matches that org's stored stripeCustomerId; otherwise fall back to the
|
||||
// customer lookup. This prevents a sub with a forged metadata.orgId from
|
||||
// re-planning a victim org. (BILL-004)
|
||||
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
|
||||
const metaOrgId = sub.metadata?.orgId;
|
||||
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) {
|
||||
const [byMeta] = await db
|
||||
.select({ id: organizations.id, customer: organizations.stripeCustomerId })
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, metaOrgId))
|
||||
.limit(1);
|
||||
if (byMeta && (byMeta.customer === null || byMeta.customer === customerId)) {
|
||||
return byMeta.id;
|
||||
}
|
||||
// metadata orgId does not own this customer — ignore it and fall through.
|
||||
}
|
||||
const [row] = await db
|
||||
.select({ id: organizations.id })
|
||||
.from(organizations)
|
||||
@@ -474,15 +511,18 @@ async function handleSubscriptionDeleted(
|
||||
async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
|
||||
const orgId = await findOrgIdForInvoice(invoice);
|
||||
if (!orgId) return;
|
||||
// Successful renewal — clear any past-due suspension and reset the usage
|
||||
// period (so the new month's call quota starts fresh).
|
||||
// Only the actual monthly renewal (`subscription_cycle`) resets the usage
|
||||
// counter. Stripe also sends `invoice.paid` for proration/manual/one-off
|
||||
// invoices (e.g. every plan up/downgrade); resetting on those would let a
|
||||
// user zero their call quota on demand by churning plan changes. For
|
||||
// non-cycle invoices we only clear a past-due suspension. (BILL-002)
|
||||
const isRenewal = invoice.billing_reason === 'subscription_cycle';
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({
|
||||
suspended: false,
|
||||
suspendedReason: null,
|
||||
callsThisPeriod: 0,
|
||||
periodStartsAt: new Date(),
|
||||
...(isRenewal ? { callsThisPeriod: 0, periodStartsAt: new Date() } : {}),
|
||||
})
|
||||
.where(eq(organizations.id, orgId));
|
||||
await audit({
|
||||
|
||||
@@ -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) {
|
||||
@@ -219,7 +240,8 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) {
|
||||
return reply.code(409).send({
|
||||
error: 'streaming_unavailable',
|
||||
detail: 'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
|
||||
detail:
|
||||
'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,7 +276,10 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
// open as long as bytes flow; comments are SSE-noop but count as bytes.
|
||||
const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000);
|
||||
const abort = new AbortController();
|
||||
req.raw.on('close', () => abort.abort());
|
||||
req.raw.on('close', () => {
|
||||
abort.abort();
|
||||
clearInterval(keepalive);
|
||||
});
|
||||
|
||||
// `resolved` is set inside the awaited handlers below — by the time
|
||||
// streamSpecFromAnthropic returns, exactly one of onSpec/onError will
|
||||
@@ -263,6 +288,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ended without either handler running (which would be a programming
|
||||
// bug, not a runtime path).
|
||||
let resolved = false;
|
||||
try {
|
||||
await streamSpecFromAnthropic(
|
||||
parsed.data.prompt,
|
||||
{
|
||||
@@ -350,8 +376,17 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved');
|
||||
send('error', { error: 'preview_failed', detail: 'stream ended without a final event' });
|
||||
}
|
||||
} catch (err) {
|
||||
// If the stream itself rejects (e.g. cacheSpec/Redis throws inside onSpec,
|
||||
// or a network error before either handler runs) we must still tear down
|
||||
// the keepalive timer and close the socket — otherwise the interval keeps
|
||||
// writing to a dead connection forever, leaking a timer + FD per failure. (SRV-004)
|
||||
app.log.error({ err, prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_threw');
|
||||
if (!resolved) send('error', { error: 'preview_failed', detail: 'spec generation failed' });
|
||||
} finally {
|
||||
clearInterval(keepalive);
|
||||
reply.raw.end();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
|
||||
@@ -511,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,
|
||||
@@ -521,7 +558,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
serverName: name,
|
||||
secrets: secretValues,
|
||||
previewId,
|
||||
});
|
||||
},
|
||||
{ priority: buildPriority(plan) },
|
||||
);
|
||||
|
||||
await audit({
|
||||
orgId: user.orgId,
|
||||
@@ -574,6 +613,32 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
.limit(1);
|
||||
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
// iterate queues a full paid LLM build exactly like POST /v1/servers, so it
|
||||
// must enforce the same suspension + daily-build gates. Without these a
|
||||
// suspended (non-paying) or rate-capped org could generate unlimited builds
|
||||
// by hitting iterate instead of create. (SRV-003)
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
if (billing.suspended) {
|
||||
return reply.code(402).send({
|
||||
error: 'subscription_suspended',
|
||||
detail:
|
||||
billing.suspendedReason === 'payment_failed'
|
||||
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
|
||||
: 'Your subscription is paused. Visit /settings/billing for details.',
|
||||
suspendedReason: billing.suspendedReason,
|
||||
});
|
||||
}
|
||||
const iterateRl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[billing.plan]);
|
||||
if (!iterateRl.ok) {
|
||||
return reply.code(429).send({
|
||||
error: 'rate_limited',
|
||||
detail: `Daily build limit reached for plan "${billing.plan}" (${BUILD_DAILY_LIMIT[billing.plan]}/day). Resets in ${Math.ceil(iterateRl.resetIn / 3600)}h.`,
|
||||
plan: billing.plan,
|
||||
limit: BUILD_DAILY_LIMIT[billing.plan],
|
||||
resetIn: iterateRl.resetIn,
|
||||
});
|
||||
}
|
||||
|
||||
const nextVersion = server.currentVersion + 1;
|
||||
const [build] = await db
|
||||
.insert(builds)
|
||||
@@ -591,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,
|
||||
@@ -600,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,
|
||||
|
||||
@@ -250,6 +250,15 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
|
||||
const body = NewMessageBody.safeParse(req.body);
|
||||
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
|
||||
// Confirm the ticket exists first — otherwise the insert below hits a raw
|
||||
// FK violation (500) instead of a clean 404. (SUP-002)
|
||||
const [ticket] = await db
|
||||
.select({ id: supportTickets.id })
|
||||
.from(supportTickets)
|
||||
.where(eq(supportTickets.id, parsed.data.id))
|
||||
.limit(1);
|
||||
if (!ticket) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
await db.insert(supportMessages).values({
|
||||
ticketId: parsed.data.id,
|
||||
authorUserId: user.userId,
|
||||
@@ -282,12 +291,22 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
|
||||
'/v1/admin/support/tickets/:id/status',
|
||||
{ preHandler: requireAdmin },
|
||||
async (req, reply) => {
|
||||
const user = req.user!;
|
||||
const Params = z.object({ id: z.string().uuid() });
|
||||
const parsed = Params.safeParse(req.params);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
|
||||
const body = StatusBody.safeParse(req.body);
|
||||
if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
|
||||
// 404 on unknown ticket instead of a silent no-op `UPDATE ... WHERE id=?`
|
||||
// that returns ok:true and masks the bad id. (SUP-002)
|
||||
const [ticket] = await db
|
||||
.select({ id: supportTickets.id })
|
||||
.from(supportTickets)
|
||||
.where(eq(supportTickets.id, parsed.data.id))
|
||||
.limit(1);
|
||||
if (!ticket) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
await db
|
||||
.update(supportTickets)
|
||||
.set({
|
||||
@@ -297,6 +316,17 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
.where(eq(supportTickets.id, parsed.data.id));
|
||||
|
||||
// Status changes were previously unaudited, unlike admin replies — close
|
||||
// the compliance-trail gap. (SUP-002)
|
||||
await audit({
|
||||
orgId: user.orgId,
|
||||
userId: user.userId,
|
||||
action: 'support.status_changed',
|
||||
resourceType: 'support_ticket',
|
||||
resourceId: parsed.data.id,
|
||||
metadata: { status: body.data.status },
|
||||
});
|
||||
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -194,10 +194,15 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
||||
toolsSchema: server.toolsSchema,
|
||||
generatedCode: build.generatedCode,
|
||||
requiredSecrets: parsed.data.secretHints,
|
||||
scopes: (server.toolsSchema as Array<{ scopes?: string[] }>).reduce<string[]>(
|
||||
() => ['mcp:read'],
|
||||
[],
|
||||
),
|
||||
// Aggregate the distinct scopes actually declared by the server's tools
|
||||
// (deduped), falling back to read-only. The previous reduce ignored its
|
||||
// input and hardcoded ['mcp:read'] for every template regardless of what
|
||||
// its tools did. (TPL-003)
|
||||
scopes: (() => {
|
||||
const tools = (server.toolsSchema as Array<{ scopes?: string[] }> | null) ?? [];
|
||||
const all = [...new Set(tools.flatMap((t) => t.scopes ?? []))];
|
||||
return all.length > 0 ? all : ['mcp:read'];
|
||||
})(),
|
||||
allowedDomains: parsed.data.allowedDomains ?? null,
|
||||
})
|
||||
.returning();
|
||||
@@ -310,13 +315,19 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
||||
.from(templates)
|
||||
.leftJoin(users, eq(users.id, templates.ownerUserId))
|
||||
.leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
|
||||
.where(eq(templates.status, 'public'))
|
||||
// Category filter belongs in the WHERE, BEFORE limit — filtering in JS
|
||||
// after `.limit(50)` meant `?category=x` searched only the 50 newest
|
||||
// public templates (any category), returning far fewer than `limit`. (TPL-008)
|
||||
.where(
|
||||
and(
|
||||
eq(templates.status, 'public'),
|
||||
parsed.data.category ? eq(templates.category, parsed.data.category) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(templates.createdAt))
|
||||
.limit(parsed.data.limit);
|
||||
|
||||
const filtered = parsed.data.category
|
||||
? rows.filter((r) => r.template.category === parsed.data.category)
|
||||
: rows;
|
||||
const filtered = rows;
|
||||
|
||||
// Single grouped query — was N+1 (one COUNT per template). On a 100-row
|
||||
// listing that's 101 round-trips → p95 latency cliff once the marketplace
|
||||
|
||||
@@ -39,7 +39,10 @@ export async function prepareBuildContext(
|
||||
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
|
||||
await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
||||
|
||||
const imageTag = `bmm-mcp-${slug}:v${version}`;
|
||||
// Include serverId in the tag: `slug` is unique only per-org, so two orgs
|
||||
// sharing a slug at the same version would otherwise collide on one global
|
||||
// image tag and run each other's code. Matches the contextDir scheme. (GEN-009)
|
||||
const imageTag = `bmm-mcp-${serverId.slice(0, 8)}-${slug}:v${version}`;
|
||||
return { contextDir, imageTag };
|
||||
}
|
||||
|
||||
@@ -70,12 +73,21 @@ export async function staticCheck(contextDir: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// A hung `docker build` (stalled npm install, wedged daemon) must not pin a
|
||||
// worker slot forever — concurrency is 2, so two stuck builds = zero throughput
|
||||
// with no alarm. Kill and fail the build past this ceiling. (GEN-008)
|
||||
const BUILD_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('docker', ['build', '-t', imageTag, '.'], {
|
||||
cwd: contextDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`docker_build_timeout (exceeded ${BUILD_TIMEOUT_MS / 1000}s)`));
|
||||
}, BUILD_TIMEOUT_MS);
|
||||
child.stdout.on('data', (d) => {
|
||||
for (const line of d.toString().split(/\r?\n/)) {
|
||||
if (line.trim()) onLog(line.trim());
|
||||
@@ -86,8 +98,12 @@ export async function dockerBuild(contextDir: string, imageTag: string, onLog: (
|
||||
if (line.trim()) onLog(line.trim());
|
||||
}
|
||||
});
|
||||
child.on('error', (e) => reject(e));
|
||||
child.on('error', (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`docker_build_failed (exit ${code})`));
|
||||
});
|
||||
|
||||
@@ -81,12 +81,25 @@ const HARDENING_FLAGS = [
|
||||
];
|
||||
|
||||
function shouldHarden(): boolean {
|
||||
// Explicit opt-out for local dev on Windows where --read-only conflicts
|
||||
// with how Docker Desktop binds volumes. Production must always harden.
|
||||
if (process.env.RUNNER_DISABLE_HARDENING === '1') return false;
|
||||
const env = process.env.NODE_ENV;
|
||||
return env === 'production' || env === 'staging';
|
||||
// Fail-CLOSED: harden by default everywhere. The only opt-out is the explicit
|
||||
// RUNNER_DISABLE_HARDENING=1 flag (local Windows Docker Desktop, where
|
||||
// --read-only conflicts with how volumes bind). The previous NODE_ENV gate was
|
||||
// fail-OPEN — a missing/typo'd NODE_ENV silently ran tenant containers as root
|
||||
// with full caps on the shared host, which is the one defense the LLM
|
||||
// static-check explicitly is NOT. (GEN-002)
|
||||
if (process.env.RUNNER_DISABLE_HARDENING === '1') {
|
||||
console.warn(
|
||||
'[deploy] container hardening DISABLED via RUNNER_DISABLE_HARDENING=1 — never set this in production',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// docker run / rm should return in seconds; cap them so a wedged daemon can't
|
||||
// hang a worker slot indefinitely. (GEN-008)
|
||||
const DOCKER_RUN_TIMEOUT_MS = 60 * 1000;
|
||||
const DOCKER_STOP_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
const db = createDb();
|
||||
|
||||
@@ -157,14 +170,24 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
|
||||
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
let err = '';
|
||||
// `docker run -d` returns promptly; if it hangs (wedged daemon) don't pin a
|
||||
// worker slot forever. (GEN-008)
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error('docker_run_timeout'));
|
||||
}, DOCKER_RUN_TIMEOUT_MS);
|
||||
child.stdout.on('data', (d) => {
|
||||
out += d.toString();
|
||||
});
|
||||
child.stderr.on('data', (d) => {
|
||||
err += d.toString();
|
||||
});
|
||||
child.on('error', (e) => reject(e));
|
||||
child.on('error', (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on('close', async (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
|
||||
return;
|
||||
@@ -207,13 +230,21 @@ export async function stopContainer(
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let err = '';
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
resolve({ ok: false, detail: 'stop_timeout' });
|
||||
}, DOCKER_STOP_TIMEOUT_MS);
|
||||
child.stderr?.on('data', (d: Buffer) => {
|
||||
err += d.toString();
|
||||
});
|
||||
child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' }));
|
||||
child.on('close', (code) =>
|
||||
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` }),
|
||||
);
|
||||
child.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, detail: 'spawn_failed' });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@ export const worker = new Worker<JobData>(
|
||||
`Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(builds)
|
||||
.set({ status: 'success', finishedAt: new Date() })
|
||||
@@ -197,9 +198,11 @@ export const worker = new Worker<JobData>(
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(mcpServers.id, serverId));
|
||||
|
||||
// Rolling deploy: the new container is live — now retire the previous one.
|
||||
// Without this every iterate would leave an orphan holding a host port.
|
||||
} finally {
|
||||
// Rolling deploy: retire the previous container even if the success DB
|
||||
// writes above threw — otherwise a DB hiccup after a healthy deploy
|
||||
// leaves the old container orphaned, holding its host port. The new
|
||||
// container is already live and its id is persisted in deployContainer. (GEN-007)
|
||||
if (oldContainerId && oldContainerId !== handle.containerId) {
|
||||
const stopped = await stopContainer(oldContainerId);
|
||||
await log(
|
||||
@@ -209,6 +212,7 @@ export const worker = new Worker<JobData>(
|
||||
: `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await emitStatus(buildId, 'success');
|
||||
await emitDone(buildId, 'success', serverId, handle.publicUrl);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund && npm install --no-save tsx@4.19.2 typescript@5.7.2
|
||||
# --ignore-scripts: generated package.json carries LLM/user-chosen dependencies.
|
||||
# Without this, a malicious dependency's postinstall lifecycle script would run
|
||||
# at `docker build` time on the shared host. Specifiers are also validated to
|
||||
# registry semver ranges at the API boundary (DependencyMap). (GEN-001)
|
||||
RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund && npm install --no-save --ignore-scripts tsx@4.19.2 typescript@5.7.2
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
@@ -24,6 +24,11 @@ RUN pnpm install --frozen-lockfile
|
||||
FROM deps AS build
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
# Stripe publishable key — inlined into the client bundle so the embedded
|
||||
# checkout can initialise. Safe to expose (publishable, not secret). Empty
|
||||
# build = embedded checkout shows a "not configured" message until set.
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY . .
|
||||
RUN pnpm --filter @bmm/web build
|
||||
@@ -34,4 +39,9 @@ ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
WORKDIR /app/apps/web
|
||||
EXPOSE 3001
|
||||
# NOTE (INF-003): non-root `USER node` was reverted — `pnpm start` via corepack
|
||||
# can't reach its root-owned cache as the node user and the deploy health-check
|
||||
# doesn't cover web, so a broken web would deploy "green" but take the site down.
|
||||
# Re-enable only after switching the runtime CMD to invoke next directly
|
||||
# (node_modules/.bin/next) and smoke-testing the image locally.
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'll show you exactly which tools we'll expose and let you tweak
|
||||
the spec before we build.
|
||||
the spec before we build.{' '}
|
||||
<span className="text-[--color-fg-muted]">
|
||||
Don't paste API keys or access tokens here — you'll add each one in its own
|
||||
encrypted field in the next step.
|
||||
</span>
|
||||
</p>
|
||||
<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 & 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'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'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) => (
|
||||
|
||||
@@ -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't do one-click account deletion yet — too easy to fat-finger and lose
|
||||
paid-tier server configs. Open a ticket and we'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">
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
// Load Stripe.js once at module scope (Stripe's recommendation). Null when the
|
||||
// publishable key isn't baked into the build — the modal then shows a clear
|
||||
// "not configured" message instead of throwing.
|
||||
const STRIPE_PK = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
const stripePromise = STRIPE_PK ? loadStripe(STRIPE_PK) : null;
|
||||
|
||||
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
|
||||
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
|
||||
|
||||
@@ -68,6 +76,8 @@ function BillingInner() {
|
||||
const [status, setStatus] = useState<BillingStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
// When set, the in-app embedded Stripe checkout modal is open.
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
apiFetch<BillingStatus>('/v1/billing/status')
|
||||
@@ -98,15 +108,17 @@ function BillingInner() {
|
||||
setBusy(tier);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', {
|
||||
const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tier }),
|
||||
});
|
||||
window.location.href = res.url;
|
||||
// Open the embedded checkout in-app instead of redirecting to Stripe.
|
||||
setClientSecret(res.clientSecret);
|
||||
} catch (e) {
|
||||
setBusy(null);
|
||||
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
|
||||
setError(detail?.detail ?? detail?.error ?? (e as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -183,6 +195,15 @@ function BillingInner() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
{clientSecret && (
|
||||
<CheckoutModal
|
||||
clientSecret={clientSecret}
|
||||
onClose={() => {
|
||||
setClientSecret(null);
|
||||
setBusy(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
@@ -285,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"
|
||||
@@ -299,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}
|
||||
@@ -421,6 +442,44 @@ function BillingInner() {
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutModal({
|
||||
clientSecret,
|
||||
onClose,
|
||||
}: {
|
||||
clientSecret: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm sm:p-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="relative my-auto w-full max-w-xl rounded-lg border border-[--color-border] bg-[--color-bg] p-1 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close checkout"
|
||||
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-[--color-fg-muted] hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
{stripePromise ? (
|
||||
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<Alert tone="error">
|
||||
Payments aren’t configured (missing Stripe publishable key). Please contact support.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Alert({
|
||||
tone,
|
||||
children,
|
||||
@@ -485,7 +544,7 @@ function TierCard({
|
||||
onClick={() => onSubscribe(monthlyTier)}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
{busy === monthlyTier ? 'Redirecting…' : `Subscribe — €${monthly}/mo`}
|
||||
{busy === monthlyTier ? 'Loading…' : `Subscribe — €${monthly}/mo`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -493,7 +552,7 @@ function TierCard({
|
||||
onClick={() => onSubscribe(yearlyTier)}
|
||||
disabled={Boolean(busy)}
|
||||
>
|
||||
{busy === yearlyTier ? 'Redirecting…' : `Or €${yearly}/year — 2 months free`}
|
||||
{busy === yearlyTier ? 'Loading…' : `Or €${yearly}/year — 2 months free`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
15
apps/web/app/(marketing)/contact/layout.tsx
Normal file
15
apps/web/app/(marketing)/contact/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { pageMetadata } from '@/lib/seo';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// The contact page itself is a client component ('use client'), which cannot
|
||||
// export metadata — so the canonical/description live here.
|
||||
export const metadata = pageMetadata({
|
||||
title: 'Contact',
|
||||
description:
|
||||
'Get in touch with the BuildMyMCPServer team — support, sales and security questions answered by email.',
|
||||
path: '/contact',
|
||||
});
|
||||
|
||||
export default function ContactLayout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
103
apps/web/app/(marketing)/guides/article-shell.tsx
Normal file
103
apps/web/app/(marketing)/guides/article-shell.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Shared layout + typographic primitives for /guides/* SEO articles. Server
|
||||
// component (no client JS) so each article page can export its own metadata.
|
||||
|
||||
export function ArticleShell({
|
||||
title,
|
||||
subtitle,
|
||||
updated,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
updated?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-6 py-14">
|
||||
<Link
|
||||
href="/guides"
|
||||
className="text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||
>
|
||||
← Guides
|
||||
</Link>
|
||||
<h1 className="mt-4 text-[30px] font-semibold leading-tight tracking-tight text-[--color-fg]">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && <p className="mt-3 text-[15px] leading-relaxed text-[--color-fg-muted]">{subtitle}</p>}
|
||||
{updated && <p className="mt-2 text-[12px] text-[--color-fg-subtle]">Updated {updated}</p>}
|
||||
<div className="mt-8">{children}</div>
|
||||
|
||||
<div className="mt-14 rounded-lg border border-[--color-border] bg-[--color-bg-subtle] p-5">
|
||||
<p className="text-[14px] font-medium text-[--color-fg]">
|
||||
Skip the boilerplate — describe your tool, get a hosted MCP server.
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
BuildMyMCPServer generates the TypeScript server, wraps it in OAuth 2.1 and deploys it to a
|
||||
public Streamable HTTP URL for Claude, Cursor and ChatGPT. Free tier, source export, no
|
||||
lock-in.
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-3 inline-flex h-9 items-center rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#5557e8]"
|
||||
>
|
||||
Start building →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function H2({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<h2 className="mt-10 text-[19px] font-semibold tracking-tight text-[--color-fg]">{children}</h2>
|
||||
);
|
||||
}
|
||||
|
||||
export function P({ children }: { children: ReactNode }) {
|
||||
return <p className="mt-3 text-[14.5px] leading-relaxed text-[--color-fg-muted]">{children}</p>;
|
||||
}
|
||||
|
||||
export function UL({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ul className="mt-3 list-disc space-y-1.5 pl-5 text-[14.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function Strong({ children }: { children: ReactNode }) {
|
||||
return <strong className="font-semibold text-[--color-fg]">{children}</strong>;
|
||||
}
|
||||
|
||||
export function OL({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ol className="mt-3 list-decimal space-y-1.5 pl-5 text-[14.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Comparison / feature tables. Pass fully-formed <thead>/<tbody> children;
|
||||
* the wrapper provides the horizontal-scroll container so wide tables never
|
||||
* break the mobile viewport. */
|
||||
export function Table({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mt-4 overflow-x-auto rounded-lg border border-[--color-border]">
|
||||
<table className="w-full min-w-[560px] border-collapse text-left text-[13.5px] leading-relaxed [&_td]:border-t [&_td]:border-[--color-border] [&_td]:px-3.5 [&_td]:py-2.5 [&_td]:align-top [&_td]:text-[--color-fg-muted] [&_th]:bg-[--color-bg-subtle] [&_th]:px-3.5 [&_th]:py-2.5 [&_th]:text-[12px] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[--color-fg]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Callout for caveats and version-sensitive facts. */
|
||||
export function Note({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-[--color-border] bg-[--color-bg-subtle] px-4 py-3 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'Add a custom MCP connector to ChatGPT (2026 guide)';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'Add a custom MCP connector to ChatGPT (2026 guide)',
|
||||
tag: 'Setup',
|
||||
});
|
||||
}
|
||||
202
apps/web/app/(marketing)/guides/chatgpt-mcp-connector/page.tsx
Normal file
202
apps/web/app/(marketing)/guides/chatgpt-mcp-connector/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, OL, P, Strong, Table, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/chatgpt-mcp-connector';
|
||||
const TITLE = 'Add a custom MCP connector to ChatGPT (2026 guide)';
|
||||
const DESCRIPTION =
|
||||
'How to connect a custom MCP server to ChatGPT: setup flow, the HTTPS and OAuth requirements, and the plan limits nobody mentions — write-capable connectors need a Business, Enterprise or Edu workspace.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1300,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="ChatGPT has supported custom MCP connectors since September 2025 — but what you can actually do with one depends on your plan, and the requirements for the server side are stricter than Claude's. Here is the full picture before you build."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>The plan limits, first — because they decide everything</H2>
|
||||
<P>
|
||||
Before writing a single prompt or line of code, check what your ChatGPT plan allows.
|
||||
As of mid-2026, OpenAI gates custom MCP connectors by workspace type — verify against
|
||||
OpenAI's current help docs before committing to a plan, since these limits have
|
||||
shifted before:
|
||||
</P>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plan</th>
|
||||
<th>Custom MCP connectors</th>
|
||||
<th>Practical meaning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Free</td>
|
||||
<td>No custom connectors</td>
|
||||
<td>Only built-in connectors.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Plus / Pro (individual)</td>
|
||||
<td>Read/fetch-only, via Developer Mode</td>
|
||||
<td>
|
||||
Your server's search and read tools work; tools that create, update or delete will
|
||||
not be usable.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Business / Enterprise / Edu</td>
|
||||
<td>Full connectors, including write-capable tools</td>
|
||||
<td>The complete MCP tool surface is available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
<Note>
|
||||
This is the most common “my connector is broken” report that is not a bug: a Plus user
|
||||
adds a server with a <Strong>create_issue</Strong> tool and the tool never fires.
|
||||
Read-only tools on the same server work fine. If write actions matter for your use case,
|
||||
you need a Business/Enterprise/Edu workspace — or a client without this restriction, like
|
||||
Claude Desktop (
|
||||
<Link href="/guides/claude-desktop-mcp-setup" className="text-[--color-accent] hover:underline">
|
||||
setup guide
|
||||
</Link>
|
||||
).
|
||||
</Note>
|
||||
|
||||
<H2>What ChatGPT requires from the server</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>A public HTTPS URL.</Strong> Remote MCP server URLs must use HTTPS — no local
|
||||
STDIO servers, no plain HTTP, no localhost tunnels for anything durable.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Streamable HTTP transport.</Strong> The current MCP remote transport; legacy
|
||||
HTTP+SSE-only servers are on borrowed time across all clients.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>OAuth for user-scoped auth.</Strong> ChatGPT walks the standard MCP OAuth flow.
|
||||
One sharp edge: if the authorization server issues tokens without{' '}
|
||||
<Strong>offline_access</Strong>-style refresh, ChatGPT can lose access when the token
|
||||
expires and users must reauthenticate.
|
||||
</li>
|
||||
</UL>
|
||||
<P>
|
||||
If you generate and host your server on{' '}
|
||||
<Link href="/" className="text-[--color-accent] hover:underline">
|
||||
BuildMyMCPServer
|
||||
</Link>
|
||||
, all three are the default: every server deploys to a public HTTPS endpoint speaking
|
||||
Streamable HTTP, behind an OAuth 2.1 authorization server with PKCE and Dynamic Client
|
||||
Registration. There is nothing extra to configure for ChatGPT specifically.
|
||||
</P>
|
||||
|
||||
<H2>Setup, step by step</H2>
|
||||
<OL>
|
||||
<li>
|
||||
Get your server URL — from your own deployment, from{' '}
|
||||
<Link href="/guides/create-mcp-server-without-code" className="text-[--color-accent] hover:underline">
|
||||
a prompt-generated server
|
||||
</Link>
|
||||
, or by forking a{' '}
|
||||
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
||||
template
|
||||
</Link>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
In ChatGPT: <Strong>Settings → Apps & Connectors</Strong>. On individual plans,
|
||||
enable <Strong>Developer Mode</Strong> under advanced settings first — the “create
|
||||
connector” option is hidden without it.
|
||||
</li>
|
||||
<li>Add a new connector: name it, paste the MCP endpoint URL, select OAuth as the auth method.</li>
|
||||
<li>
|
||||
ChatGPT registers itself with the authorization server and opens the consent screen.
|
||||
Approve; the connector shows as connected.
|
||||
</li>
|
||||
<li>
|
||||
In a conversation, enable the connector (via the tools/plus menu) and ask for something
|
||||
only your tool can answer. Name the tool explicitly on the first test.
|
||||
</li>
|
||||
</OL>
|
||||
|
||||
<H2>Verifying it actually works</H2>
|
||||
<P>
|
||||
ChatGPT is more eager than Claude to answer from its own knowledge instead of calling a
|
||||
tool. To force a real call, ask for data the model cannot know — a record you created
|
||||
today, a value behind your API. Then check the server's dashboard: a live tool-call log
|
||||
with latency and status per call is the ground truth for whether the connector fired.
|
||||
</P>
|
||||
|
||||
<H2>Common failure modes</H2>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symptom</th>
|
||||
<th>Cause</th>
|
||||
<th>Fix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>“Create connector” option missing</td>
|
||||
<td>Developer Mode off, or Free plan</td>
|
||||
<td>Enable Developer Mode (Plus/Pro) or upgrade the workspace.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Write tools never execute</td>
|
||||
<td>Individual plan — read/fetch-only restriction</td>
|
||||
<td>Business/Enterprise/Edu workspace, or use a write-capable client.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Connector disconnects after hours/days</td>
|
||||
<td>No refresh token (offline_access missing)</td>
|
||||
<td>Reconnect; if you control the AS, enable refresh token issuance.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>“Unable to reach server”</td>
|
||||
<td>URL is not public HTTPS, or wrong endpoint path</td>
|
||||
<td>Use the full https://…/mcp endpoint; no localhost, no http.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<H2>Is ChatGPT the right first client?</H2>
|
||||
<P>
|
||||
If your tools are read-only — search, lookup, reporting — ChatGPT works well on any paid
|
||||
plan and the setup above takes minutes. If your tools write data, start with Claude
|
||||
Desktop or Cursor where the full tool surface works on individual plans, and add ChatGPT
|
||||
when a team workspace exists. The server is the same either way; only the client config
|
||||
differs. Compare hosting options in{' '}
|
||||
<Link href="/guides/hosted-mcp-platforms-compared" className="text-[--color-accent] hover:underline">
|
||||
our platform comparison
|
||||
</Link>
|
||||
, or check{' '}
|
||||
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
||||
pricing
|
||||
</Link>{' '}
|
||||
— the free tier is enough to test a connector end to end.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'Connect a custom MCP server to Claude Desktop (step by step)';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'Connect a custom MCP server to Claude Desktop (step by step)',
|
||||
tag: 'Setup',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { StaticCodeBlock } from '@/components/static-code-block';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, OL, P, Strong, Table, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/claude-desktop-mcp-setup';
|
||||
const TITLE = 'Connect a custom MCP server to Claude Desktop (step by step)';
|
||||
const DESCRIPTION =
|
||||
'How to add a remote MCP server to Claude Desktop: the config snippet, the OAuth flow on first use, and a troubleshooting table for 401s, missing servers and invisible tools.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
const CONFIG_SNIPPET = `{
|
||||
"mcpServers": {
|
||||
"my-tools": {
|
||||
"url": "https://my-tools-a1.mcp.buildmymcpserver.com/mcp",
|
||||
"auth": "oauth2"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const LOCAL_VS_REMOTE = `# Local (STDIO) — runs on your machine, per-machine setup
|
||||
"command": "npx", "args": ["-y", "@your/mcp-server"]
|
||||
|
||||
# Remote (Streamable HTTP) — hosted, one URL for every machine
|
||||
"url": "https://my-tools-a1.mcp.buildmymcpserver.com/mcp"`;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1400,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="Claude Desktop supports both local STDIO servers and remote servers over Streamable HTTP. Remote is the version that survives a laptop change — here is the exact setup, including what the OAuth consent screen is doing."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>Local vs. remote — pick remote unless you have a reason</H2>
|
||||
<P>
|
||||
Most MCP tutorials wire up a <Strong>local STDIO server</Strong>: Claude Desktop spawns a
|
||||
process on your machine and talks to it over stdin/stdout. That works, but it is
|
||||
per-machine — every teammate repeats the setup, secrets live in local config files, and
|
||||
nothing works from a second device. A <Strong>remote server over Streamable HTTP</Strong>{' '}
|
||||
is one URL that every installation shares, with auth handled by OAuth instead of
|
||||
plaintext keys in a JSON file.
|
||||
</P>
|
||||
<StaticCodeBlock code={LOCAL_VS_REMOTE} label="the difference in config terms" />
|
||||
|
||||
<H2>Step 1 — get a server URL</H2>
|
||||
<P>
|
||||
You need a live MCP endpoint. If you already run one, use its URL. If not, you can{' '}
|
||||
<Link href="/guides/create-mcp-server-without-code" className="text-[--color-accent] hover:underline">
|
||||
generate one from a prompt
|
||||
</Link>{' '}
|
||||
or fork a working one from{' '}
|
||||
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
||||
the template gallery
|
||||
</Link>{' '}
|
||||
— either way you end up with an OAuth-protected URL like{' '}
|
||||
<Strong>https://my-tools-a1.mcp.buildmymcpserver.com/mcp</Strong>.
|
||||
</P>
|
||||
|
||||
<H2>Step 2 — add the server to Claude Desktop</H2>
|
||||
<OL>
|
||||
<li>
|
||||
Open Claude Desktop settings and go to the connectors/MCP section (on recent versions:
|
||||
Settings → Connectors → Add custom connector), or edit{' '}
|
||||
<Strong>claude_desktop_config.json</Strong> directly.
|
||||
</li>
|
||||
<li>Add the server entry:</li>
|
||||
</OL>
|
||||
<StaticCodeBlock code={CONFIG_SNIPPET} label="claude_desktop_config.json" />
|
||||
<OL>
|
||||
<li value={3}>Restart Claude Desktop. Config is read at startup, not live.</li>
|
||||
</OL>
|
||||
<Note>
|
||||
Claude Desktop's settings UI changes between releases; the JSON config path is the stable
|
||||
fallback. On macOS it lives at{' '}
|
||||
<Strong>~/Library/Application Support/Claude/claude_desktop_config.json</Strong>, on
|
||||
Windows at <Strong>%APPDATA%\Claude\claude_desktop_config.json</Strong>.
|
||||
</Note>
|
||||
|
||||
<H2>Step 3 — the OAuth flow on first use</H2>
|
||||
<P>
|
||||
The first time Claude touches the server it will receive a <Strong>401</Strong> — that is
|
||||
correct behavior, not an error. The client then discovers the authorization server,
|
||||
registers itself via Dynamic Client Registration, and opens a browser window for consent.
|
||||
You approve once; the client stores the token and refreshes it silently afterwards.
|
||||
</P>
|
||||
<P>
|
||||
Under the hood this is the OAuth 2.1 handshake the MCP spec requires for remote servers:
|
||||
PKCE so the code exchange cannot be intercepted, and Resource Indicators (RFC 8707) so a
|
||||
token issued for this server cannot be replayed against another. Details in{' '}
|
||||
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
|
||||
the OAuth documentation
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
|
||||
<H2>Step 4 — verify the tools are there</H2>
|
||||
<UL>
|
||||
<li>Open a new conversation and check the tools/connectors icon — your server should be listed with its tools.</li>
|
||||
<li>
|
||||
Ask Claude directly: <em>“Use the search_pages tool to find X.”</em> Naming the tool
|
||||
forces Claude to attempt the call instead of answering from memory.
|
||||
</li>
|
||||
<li>Watch your server's dashboard logs — you should see the tool call arrive with latency and status.</li>
|
||||
</UL>
|
||||
|
||||
<H2>Troubleshooting</H2>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Symptom</th>
|
||||
<th>Likely cause</th>
|
||||
<th>Fix</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Server never appears in the client</td>
|
||||
<td>Config JSON is invalid, or Claude was not restarted</td>
|
||||
<td>
|
||||
Validate the JSON (trailing commas are the classic), restart Claude Desktop fully
|
||||
(quit, not close window).
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Endless 401 loop, consent screen never opens</td>
|
||||
<td>URL points at the server root instead of the /mcp endpoint</td>
|
||||
<td>Use the full endpoint URL ending in /mcp, exactly as the install snippet shows.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Consent worked, tools still missing</td>
|
||||
<td>Server is deployed but tool listing failed on connect</td>
|
||||
<td>
|
||||
Check the server's live logs for an initialize/list_tools error; redeploy if the
|
||||
container restarted with a bad secret.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tool listed, every call errors</td>
|
||||
<td>Upstream credential (e.g. your API key) is wrong or expired</td>
|
||||
<td>
|
||||
Update the secret in the dashboard — secrets are injected at runtime, so a redeploy
|
||||
picks up the new value. They are never shown back, so re-enter rather than inspect.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Worked yesterday, 401 today</td>
|
||||
<td>Token expired and silent refresh failed</td>
|
||||
<td>Remove and re-add the connector to force a fresh OAuth flow.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<H2>One server, every client</H2>
|
||||
<P>
|
||||
The same URL works in Cursor, VS Code Copilot, Continue.dev and{' '}
|
||||
<Link href="/guides/chatgpt-mcp-connector" className="text-[--color-accent] hover:underline">
|
||||
ChatGPT custom connectors
|
||||
</Link>{' '}
|
||||
— the point of hosting a remote MCP server is that the client config is the only
|
||||
per-client step left. If you hit a case this guide does not cover, the{' '}
|
||||
<Link href="/docs/faq" className="text-[--color-accent] hover:underline">
|
||||
docs FAQ
|
||||
</Link>{' '}
|
||||
collects the rarer ones.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'Composio alternative for bespoke MCP tools';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'Composio alternative for bespoke MCP tools',
|
||||
tag: 'Alternative',
|
||||
});
|
||||
}
|
||||
206
apps/web/app/(marketing)/guides/composio-alternative/page.tsx
Normal file
206
apps/web/app/(marketing)/guides/composio-alternative/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/composio-alternative';
|
||||
const TITLE = 'Composio alternative for bespoke MCP tools';
|
||||
const DESCRIPTION =
|
||||
'Composio gives agents 1,000+ pre-built SaaS integrations. The gap is everything not in a catalog: your internal API, your database, your workflow. Here is the generate-your-own route — and where Composio clearly wins.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1200,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="Composio and BuildMyMCPServer both put tools in front of your AI agent — but they answer opposite questions. Composio: 'which of these 1,000 integrations do you want?' Us: 'describe the one that doesn't exist yet.'"
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>What Composio does well</H2>
|
||||
<P>
|
||||
Composio's pitch is breadth with managed auth: as of mid-2026 it exposes{' '}
|
||||
<Strong>1,000+ third-party applications</Strong> — Gmail, Slack, Notion, Salesforce,
|
||||
HubSpot, GitHub, Linear, Stripe, Shopify and the rest of the mainstream SaaS universe — as
|
||||
agent-callable tools behind one MCP gateway, with the OAuth dance to each SaaS handled for
|
||||
you. Pricing is per tool call: a free tier around 20k calls/month, then paid tiers from
|
||||
$29/month. If the integration you need is in that catalog, rebuilding it yourself is
|
||||
almost always the wrong use of your time. That is an honest, strong product.
|
||||
</P>
|
||||
|
||||
<H2>Where the catalog model hits its ceiling</H2>
|
||||
<P>
|
||||
A catalog, by definition, contains what many companies share. It cannot contain what only
|
||||
your company has:
|
||||
</P>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Internal APIs</Strong> — the ERP endpoint, the pricing service, the legacy SOAP
|
||||
bridge your team wrapped in REST five years ago.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Your database</Strong> — a read-only reporting view with exactly the columns
|
||||
the agent may see, and none it may not.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Custom logic between systems</Strong> — "check inventory, then draft the
|
||||
reorder in our format" is a tool, not two catalog entries.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Data-control requirements</Strong> — some teams cannot route production
|
||||
traffic and credentials through a third-party tool-execution layer at all.
|
||||
</li>
|
||||
</UL>
|
||||
<P>
|
||||
When you hit that ceiling with a catalog product, the fallback is suddenly steep: learn
|
||||
the MCP SDK, write and test a server, stand up hosting and OAuth. That cliff is the gap.
|
||||
</P>
|
||||
|
||||
<H2>The alternative: generate the bespoke tool</H2>
|
||||
<P>
|
||||
<Strong>BuildMyMCPServer</Strong> starts where the catalog ends. You describe the tool in
|
||||
natural language — endpoints, secrets, behavior. The platform generates a TypeScript MCP
|
||||
server, runs static checks, builds an isolated container and deploys it behind a full
|
||||
OAuth 2.1 authorization server (PKCE, Dynamic Client Registration, Resource Indicators),
|
||||
with copy-paste install snippets for Claude Desktop, Cursor and ChatGPT. Secrets are
|
||||
AES-256-GCM encrypted and injected only at runtime. You can export the full TypeScript
|
||||
source at any time — the generated server is yours, not a subscription artifact.
|
||||
</P>
|
||||
|
||||
<H2>Side by side</H2>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Composio</th>
|
||||
<th>BuildMyMCPServer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Core object</Strong>
|
||||
</td>
|
||||
<td>Their catalog of pre-built integrations</td>
|
||||
<td>A custom server generated from your prompt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Best for</Strong>
|
||||
</td>
|
||||
<td>Mainstream SaaS (Gmail, Slack, Salesforce…)</td>
|
||||
<td>Internal APIs, databases, bespoke workflows</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Auth to end services</Strong>
|
||||
</td>
|
||||
<td>Managed OAuth to hundreds of SaaS — a real moat</td>
|
||||
<td>You supply credentials for your own APIs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Billing shape</Strong>
|
||||
</td>
|
||||
<td>Per tool call (free 20k/mo, then usage tiers)</td>
|
||||
<td>Per server tier with call allowance (free: 1 server, 100k calls/mo)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Code ownership</Strong>
|
||||
</td>
|
||||
<td>No server code of yours exists</td>
|
||||
<td>Full TypeScript source export, no lock-in</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Maturity</Strong>
|
||||
</td>
|
||||
<td>Established, large developer base</td>
|
||||
<td>Young product (launched 2026), EU-hosted</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<Note>
|
||||
Composio details reflect its public pricing and catalog as of mid-2026; verify current
|
||||
numbers on their site before committing budget.
|
||||
</Note>
|
||||
|
||||
<H2>Pick by the question you are actually asking</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>"Let my agent use Slack and Gmail"</Strong> → Composio. Do not
|
||||
generate what a maintained catalog already does better.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>"Let my agent use our internal order API, read-only"</Strong> → a
|
||||
generated bespoke server. No catalog will ever have it.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Both?</Strong> → they compose. MCP clients speak to multiple servers; a catalog
|
||||
gateway for SaaS plus one generated server for the internal surface is a normal setup,
|
||||
not a compromise.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>Honest limits of the generator route</H2>
|
||||
<P>
|
||||
Generation is bounded by what a prompt can specify: tools with clear inputs, outputs and
|
||||
API calls. A deeply stateful integration with complex pagination and exotic auth may
|
||||
still be a hand-written job. And we repeat the maturity point on purpose: Composio has
|
||||
scale and a large user base; we are new. What we offer against that is the source-export
|
||||
exit and a{' '}
|
||||
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
||||
free tier
|
||||
</Link>{' '}
|
||||
that lets you test the claim in minutes — start from a prompt or fork a{' '}
|
||||
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
||||
template
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
|
||||
<P>
|
||||
Related:{' '}
|
||||
<Link
|
||||
href="/guides/mcp-server-hosting-pricing"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
MCP hosting pricing compared
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link
|
||||
href="/guides/hosted-mcp-platforms-compared"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
the four platform categories
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link href="/guides/mintmcp-alternative" className="text-[--color-accent] hover:underline">
|
||||
MintMCP alternative
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'How to create an MCP server without writing code (2026)';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'How to create an MCP server without writing code (2026)',
|
||||
tag: 'Guide',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { StaticCodeBlock } from '@/components/static-code-block';
|
||||
import { articleJsonLd, breadcrumbJsonLd, faqJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, OL, P, Strong, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/create-mcp-server-without-code';
|
||||
const TITLE = 'How to create an MCP server without writing code (2026)';
|
||||
const DESCRIPTION =
|
||||
'Turn a plain-language description into a hosted, OAuth-protected MCP server — no SDK, no Docker, no TypeScript. What works, what the limits are, and when you still need code.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
const ARTICLE_FAQ = [
|
||||
{
|
||||
q: 'Do I really write zero code to create an MCP server?',
|
||||
a: 'Yes — you describe the tools in natural language. The platform generates a TypeScript MCP server, runs static checks against banned patterns, builds a Docker image and deploys it behind OAuth 2.1. You never touch the code unless you want to: the full source is exportable at any time.',
|
||||
},
|
||||
{
|
||||
q: 'How long does generation take?',
|
||||
a: 'Spec to image to live URL typically completes in 45–90 seconds. You watch the build log stream live in the dashboard.',
|
||||
},
|
||||
{
|
||||
q: 'Which AI clients can use a no-code MCP server?',
|
||||
a: 'Anything that speaks the MCP spec over Streamable HTTP: Claude Desktop, Cursor, ChatGPT custom connectors, VS Code Copilot and Continue.dev. You get a copy-paste install snippet for each.',
|
||||
},
|
||||
{
|
||||
q: 'What does it cost to try?',
|
||||
a: 'The free tier includes one hosted server and 100,000 tool calls per month — no credit card. The full TypeScript source of every server you build is exportable, so there is no lock-in.',
|
||||
},
|
||||
];
|
||||
|
||||
const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace.
|
||||
Tools: search_pages, get_page_content.
|
||||
Auth: NOTION_API_KEY.`;
|
||||
|
||||
const SNIPPET_EXAMPLE = `{
|
||||
"mcpServers": {
|
||||
"notion": {
|
||||
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
|
||||
"auth": "oauth2"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1500,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<JsonLd data={faqJsonLd(ARTICLE_FAQ)} />
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="The Model Context Protocol lets AI assistants call your tools — but the official route to a server means an SDK, a transport, an auth layer and somewhere to host it. Here is the route that skips all four, and an honest list of where it stops."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>What “no code” actually has to cover</H2>
|
||||
<P>
|
||||
An MCP server that works outside your laptop is more than tool functions. To let Claude,
|
||||
Cursor or ChatGPT call it from anywhere, you need four things: the{' '}
|
||||
<Strong>server code</Strong> itself (tool definitions plus handlers), a{' '}
|
||||
<Strong>remote transport</Strong> (Streamable HTTP — the STDIO servers most tutorials
|
||||
build only work locally), an <Strong>auth layer</Strong> (the MCP spec requires OAuth 2.1
|
||||
for remote servers), and <Strong>hosting</Strong> that keeps the process alive. A no-code
|
||||
claim that only generates the first item leaves you with three engineering problems. The
|
||||
flow below covers all four.
|
||||
</P>
|
||||
|
||||
<H2>Step 1 — describe the tool in plain language</H2>
|
||||
<P>
|
||||
You write a prompt, not a spec. Name the tools you want, the credentials they need, and
|
||||
what they should do. A working example:
|
||||
</P>
|
||||
<StaticCodeBlock code={PROMPT_EXAMPLE} label="prompt" />
|
||||
<P>
|
||||
Three lines is enough because the generator asks the model for a structured spec, not
|
||||
prose: tool names, input schemas, the API calls behind them, and which environment
|
||||
variables hold secrets. If the description is ambiguous, you see the interpreted spec
|
||||
before anything builds and can edit it.
|
||||
</P>
|
||||
|
||||
<H2>Step 2 — generation and static checks</H2>
|
||||
<P>
|
||||
From the spec, the platform renders a TypeScript MCP server. Before anything runs, the
|
||||
generated code goes through static checks against banned patterns — no
|
||||
<Strong> child processes, no filesystem escapes, no calls to unlisted hosts</Strong>. This
|
||||
matters more in a no-code flow than in a hand-written one: you did not read the code, so
|
||||
the platform has to.
|
||||
</P>
|
||||
<P>
|
||||
The output is real, exportable TypeScript. If you cancel your account tomorrow, you can{' '}
|
||||
<Link href="/docs/authoring" className="text-[--color-accent] hover:underline">
|
||||
take the source
|
||||
</Link>{' '}
|
||||
and run it yourself — there is no proprietary runtime inside.
|
||||
</P>
|
||||
|
||||
<H2>Step 3 — build and deploy</H2>
|
||||
<P>
|
||||
The server is packaged into a Docker image and deployed to its own isolated container —
|
||||
one container per server, so a bug in your Notion tool cannot see your Stripe tool's
|
||||
credentials. Secrets you provide (like <Strong>NOTION_API_KEY</Strong>) are encrypted
|
||||
with AES-256-GCM at rest and injected as environment variables only at runtime. They are
|
||||
never logged and never echoed back into the dashboard.
|
||||
</P>
|
||||
<P>
|
||||
The whole pipeline — spec, render, checks, image build, deploy — typically completes in{' '}
|
||||
<Strong>45–90 seconds</Strong>, streamed live to the dashboard so you can watch each
|
||||
stage pass.
|
||||
</P>
|
||||
|
||||
<H2>Step 4 — the OAuth-protected URL</H2>
|
||||
<P>
|
||||
The deployed server is a public Streamable HTTP endpoint, but every request is gated by
|
||||
OAuth 2.1 before it reaches your container. The control plane acts as the authorization
|
||||
server — PKCE, Dynamic Client Registration (RFC 7591) and Resource Indicators (RFC 8707)
|
||||
— which is exactly the handshake modern MCP clients expect. You never configure any of
|
||||
it;{' '}
|
||||
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
|
||||
the OAuth docs
|
||||
</Link>{' '}
|
||||
explain what happens under the hood.
|
||||
</P>
|
||||
|
||||
<H2>Step 5 — install in your client</H2>
|
||||
<P>The dashboard renders a copy-paste snippet per client. For Claude Desktop:</P>
|
||||
<StaticCodeBlock code={SNIPPET_EXAMPLE} label="claude_desktop_config.json" />
|
||||
<P>
|
||||
On first use the client opens the OAuth consent flow in your browser; approve it once and
|
||||
the tools appear. The same server works in Cursor, ChatGPT custom connectors, VS Code
|
||||
Copilot and Continue.dev — see the client-specific walkthroughs for{' '}
|
||||
<Link href="/guides/claude-desktop-mcp-setup" className="text-[--color-accent] hover:underline">
|
||||
Claude Desktop
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/guides/chatgpt-mcp-connector" className="text-[--color-accent] hover:underline">
|
||||
ChatGPT
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
|
||||
<H2>What you cannot do without code — honest limits</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Complex business logic.</Strong> Generation is good at “call this API, shape
|
||||
the response”. Multi-step workflows with branching state, retries with compensation, or
|
||||
heavy data transformation deserve hand-written code. Export the generated source and
|
||||
extend it — that is the intended escape hatch.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Unusual protocols.</Strong> Tools that need gRPC, raw TCP, or binary SDKs are
|
||||
out of scope for prompt-generation; the generated servers speak HTTP to upstream APIs.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Long-running jobs.</Strong> A tool call is a request/response. Anything that
|
||||
takes minutes belongs in a queue you own, with the MCP tool submitting and polling.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Compliance paperwork.</Strong> If procurement requires SOC 2 or HIPAA
|
||||
certification today, a generated-and-hosted server will not check that box — no such
|
||||
certifications are claimed. Self-hosting the exported source inside your own audited
|
||||
infrastructure is the workaround.
|
||||
</li>
|
||||
</UL>
|
||||
<Note>
|
||||
Rule of thumb: if you can describe the tool in three sentences, generation gets you to
|
||||
production. If your description needs a diagram, write code — or generate first, export,
|
||||
then edit.
|
||||
</Note>
|
||||
|
||||
<H2>Try it against a real API first</H2>
|
||||
<OL>
|
||||
<li>
|
||||
Pick one API you use daily — Notion, GitHub, your own REST backend (
|
||||
<Link href="/guides/rest-api-to-mcp-server" className="text-[--color-accent] hover:underline">
|
||||
wrapping a REST API
|
||||
</Link>{' '}
|
||||
is the most common first server).
|
||||
</li>
|
||||
<li>Write the three-line prompt: tools, credentials, behavior.</li>
|
||||
<li>Watch the build, paste the snippet, ask your assistant to use the tool.</li>
|
||||
</OL>
|
||||
<P>
|
||||
The{' '}
|
||||
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
||||
free tier
|
||||
</Link>{' '}
|
||||
covers one server and 100,000 tool calls a month, which is more than enough to find out
|
||||
whether the no-code route fits your case. If it does not, you have lost ten minutes and
|
||||
gained a working reference implementation to export. Browse{' '}
|
||||
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
||||
the template gallery
|
||||
</Link>{' '}
|
||||
if you would rather fork a working server than write a prompt.
|
||||
</P>
|
||||
|
||||
<H2>FAQ</H2>
|
||||
{ARTICLE_FAQ.map((f) => (
|
||||
<div key={f.q} className="mt-5">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight text-[--color-fg]">{f.q}</h3>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'How to host a remote MCP server with OAuth (2026)';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'How to host a remote MCP server with OAuth (2026)',
|
||||
tag: 'Guide',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, P, Strong, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/host-mcp-server-with-oauth';
|
||||
const TITLE = 'How to host a remote MCP server with OAuth (2026)';
|
||||
const DESCRIPTION =
|
||||
'What it actually takes to put a remote MCP server in production: Streamable HTTP transport, OAuth 2.1 with PKCE and Resource Indicators, and the shortcuts.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-05-31',
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="Local STDIO servers are easy. A remote MCP server that Claude, Cursor and ChatGPT can install over the internet — without leaving it open to the world — is where the real work is. Here's the whole picture."
|
||||
updated="May 2026"
|
||||
>
|
||||
<H2>Local vs remote: why this is harder than it looks</H2>
|
||||
<P>
|
||||
A local MCP server talks to one client over STDIO on your machine — no network, no auth.
|
||||
The moment you want a server that lives at a URL and any MCP client can connect to, you
|
||||
inherit a full web-service problem: a public transport, TLS, identity, authorization, and
|
||||
isolation between callers. The MCP spec settled on <Strong>Streamable HTTP</Strong> as the
|
||||
remote transport (it replaced the older HTTP+SSE pairing), and on{' '}
|
||||
<Strong>OAuth 2.1</Strong> as the auth model. Both are non-negotiable if you want the
|
||||
server installable from Claude Desktop or ChatGPT.
|
||||
</P>
|
||||
|
||||
<H2>The OAuth 2.1 pieces you can't skip</H2>
|
||||
<P>
|
||||
MCP authorization is OAuth 2.1, and for remote servers it leans on a few RFCs that older
|
||||
OAuth tutorials don't cover:
|
||||
</P>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>PKCE (RFC 7636)</Strong> on every authorization-code exchange — mandatory in
|
||||
OAuth 2.1, no exceptions for "confidential" clients.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Dynamic Client Registration (RFC 7591)</Strong> — clients like Claude Desktop
|
||||
register themselves at runtime; you can't pre-provision a client_id for every user.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Resource Indicators (RFC 8707)</Strong> — the token has to be bound to the
|
||||
specific MCP server (the <code>resource</code>), so a token minted for one server can't
|
||||
be replayed against another.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Protected-resource metadata</Strong> — your server returns a{' '}
|
||||
<code>WWW-Authenticate</code> header pointing at the authorization server so clients can
|
||||
discover where to get a token.
|
||||
</li>
|
||||
</UL>
|
||||
<P>
|
||||
Get any of these wrong and the symptom is the same: the client either can't complete the
|
||||
handshake, or it silently fails to discover your auth server. This is the single most
|
||||
common reason a "working" MCP server won't install from Claude.
|
||||
</P>
|
||||
|
||||
<H2>Option A — roll your own on generic infra</H2>
|
||||
<P>
|
||||
You can deploy a remote MCP server to <Strong>Cloudflare Workers</Strong> (the most common
|
||||
production choice, edge-global), or to <Strong>Render, Fly, or Cloud Run</Strong> as a
|
||||
normal container. Cloudflare even ships an OAuth provider library for Workers-based MCP
|
||||
servers. This path gives you full control and is the right call if you have engineers and
|
||||
want to own the runtime.
|
||||
</P>
|
||||
<P>
|
||||
The cost is everything around the code: standing up the authorization server (or wiring a
|
||||
third-party IdP correctly for the RFCs above), per-tenant secret storage, TLS, rate
|
||||
limiting, and keeping the transport spec-current as MCP evolves. Budget days, not hours,
|
||||
for the auth layer alone.
|
||||
</P>
|
||||
|
||||
<H2>Option B — a platform that wraps it for you</H2>
|
||||
<P>
|
||||
If you already have a server, tools like <Strong>MintMCP</Strong> take a local STDIO
|
||||
server and expose it as a remote one with OAuth wrapping. If you{' '}
|
||||
<Strong>don't have a server yet</Strong>, that's where BuildMyMCPServer fits: you describe
|
||||
the tool in plain language, it generates the TypeScript MCP server, runs static checks,
|
||||
builds a container, and deploys it behind a full OAuth 2.1 authorization server — PKCE,
|
||||
DCR and Resource Indicators included — with copy-paste install snippets for each client.
|
||||
</P>
|
||||
|
||||
<H2>A practical checklist before you ship</H2>
|
||||
<UL>
|
||||
<li>Transport is Streamable HTTP, served over TLS at a stable public URL.</li>
|
||||
<li>Unauthenticated request returns 401 + a <code>WWW-Authenticate</code> pointing at your AS.</li>
|
||||
<li>Authorization code flow enforces PKCE (S256), exact redirect-URI match, single-use codes.</li>
|
||||
<li>Issued access tokens are audience-bound to the specific server (RFC 8707).</li>
|
||||
<li>Per-caller secrets are encrypted at rest and injected only at runtime, never logged.</li>
|
||||
<li>You've actually installed it from Claude Desktop end-to-end — not just curl'd it.</li>
|
||||
</UL>
|
||||
|
||||
<P>
|
||||
Whichever route you take, test the real install path in a real client early. See the{' '}
|
||||
<Link href="/guides/hosted-mcp-platforms-compared" className="text-[--color-accent] hover:underline">
|
||||
platform comparison
|
||||
</Link>{' '}
|
||||
for which option fits your situation.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'Hosted MCP platforms compared: Cloudflare, Smithery, Composio & generating your own';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'Hosted MCP platforms compared: Cloudflare, Smithery, Composio & more',
|
||||
tag: 'Comparison',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, P, Strong, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/hosted-mcp-platforms-compared';
|
||||
const TITLE =
|
||||
'Hosted MCP platforms compared: Cloudflare, Smithery, Composio & generating your own';
|
||||
const DESCRIPTION =
|
||||
'The MCP hosting landscape splits into four categories — registries, connector platforms, hosting infra, and generators. Here is which one fits which job.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-05-31',
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="There are 14,000+ MCP servers out there and a dozen platforms claiming to host them. They are not competing for the same job. Sort them into four buckets and the choice gets obvious."
|
||||
updated="May 2026"
|
||||
>
|
||||
<H2>1. Registries & directories</H2>
|
||||
<P>
|
||||
<Strong>Smithery</Strong>, <Strong>Glama</Strong> and <Strong>PulseMCP</Strong> are about{' '}
|
||||
<em>discovery</em> — finding and listing existing servers (Glama indexes thousands). Some
|
||||
add light hosting on top, but the core value is the catalog and the traffic. Use them to
|
||||
publish a server people can find, or to find one that already does what you need.
|
||||
</P>
|
||||
|
||||
<H2>2. Connector platforms</H2>
|
||||
<P>
|
||||
<Strong>Composio</Strong>, <Strong>Nango</Strong>, <Strong>Klavis</Strong>,{' '}
|
||||
<Strong>Zapier</Strong> and <Strong>Pipedream</Strong> expose <em>their</em> catalog of
|
||||
hundreds of pre-built SaaS integrations as MCP, with managed auth. If your need is
|
||||
"let my agent touch Gmail / Slack / Salesforce," these are the fastest path —
|
||||
you're buying breadth of pre-built connectors, not building your own logic.
|
||||
</P>
|
||||
|
||||
<H2>3. Hosting infrastructure</H2>
|
||||
<P>
|
||||
<Strong>Cloudflare Workers</Strong> is the default for hosting a remote MCP server in
|
||||
production — edge-global, with an OAuth provider library. <Strong>Vercel</Strong>,{' '}
|
||||
<Strong>Render</Strong> and <Strong>Cloud Run</Strong> host custom Node containers too.{' '}
|
||||
<Strong>MintMCP</Strong> sits slightly higher up: one-click wrap of an existing STDIO
|
||||
server into a remote one with auto-OAuth, and it leads on compliance (SOC 2 Type II,
|
||||
GDPR/HIPAA-formatted audit logs). All of these assume <Strong>you bring the code.</Strong>
|
||||
</P>
|
||||
|
||||
<H2>4. Generators (the gap most lists miss)</H2>
|
||||
<P>
|
||||
The first three categories all assume you already have a server, or that a pre-built
|
||||
connector covers your case. Neither is true when you need a <em>bespoke</em> tool — a
|
||||
wrapper around your own internal API, a niche workflow, a one-off integration nobody has
|
||||
built. That's the generator category: describe the tool, get a custom MCP server hosted
|
||||
for you. It's the youngest and least crowded slice, and it's where{' '}
|
||||
<Strong>BuildMyMCPServer</Strong> plays.
|
||||
</P>
|
||||
|
||||
<H2>So which do you pick?</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Need a popular SaaS connector?</Strong> A connector platform (Composio / Klavis /
|
||||
Zapier) — don't rebuild what they maintain.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Have a server and engineers?</Strong> Host it on Cloudflare Workers; wrap your
|
||||
own OAuth or use MintMCP if you want the compliance posture done for you.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Just browsing for something that exists?</Strong> Smithery or Glama.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Need a custom tool and don't want to write or host a server?</Strong> A generator
|
||||
— describe it, ship it. Export the source later if you outgrow it.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>Where BuildMyMCPServer fits honestly</H2>
|
||||
<P>
|
||||
We're not trying to out-scale Cloudflare's edge or out-catalog Composio. The job we do is
|
||||
the bespoke one: <Strong>prompt → a hosted, OAuth-protected MCP server</Strong>, with
|
||||
install snippets for Claude, Cursor and ChatGPT, an EU/US data-residency choice for teams
|
||||
that care, full TypeScript source export, and a template marketplace to fork from. If your
|
||||
tool is custom and your time is the constraint, that's the wedge. If you need a vetted
|
||||
enterprise SOC 2 host for an existing server today, MintMCP or your own Cloudflare setup is
|
||||
the more honest answer.
|
||||
</P>
|
||||
|
||||
<P>
|
||||
Next:{' '}
|
||||
<Link
|
||||
href="/guides/host-mcp-server-with-oauth"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
what hosting a remote MCP server with OAuth actually involves
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'OAuth 2.1 for MCP servers: PKCE, DCR and RFC 8707 in plain English';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'OAuth 2.1 for MCP servers, in plain English',
|
||||
tag: 'Explainer',
|
||||
});
|
||||
}
|
||||
180
apps/web/app/(marketing)/guides/mcp-oauth-plain-english/page.tsx
Normal file
180
apps/web/app/(marketing)/guides/mcp-oauth-plain-english/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, OL, P, Strong, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/mcp-oauth-plain-english';
|
||||
const TITLE = 'OAuth 2.1 for MCP servers: PKCE, DCR and RFC 8707 in plain English';
|
||||
const DESCRIPTION =
|
||||
'The three RFCs behind MCP authorization — PKCE, Dynamic Client Registration and Resource Indicators — explained without jargon: what each one does, the full flow step by step, and what breaks when you skip one.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1400,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="MCP authorization is OAuth 2.1 plus three RFCs that most OAuth tutorials never mention. This is the concept explainer — what each piece does and why the spec requires it. For the deployment how-to, see the hosting guide."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>Why MCP needs more than classic OAuth</H2>
|
||||
<P>
|
||||
Classic OAuth assumes you know your clients in advance: you register an app in a
|
||||
dashboard, get a <code>client_id</code> and <code>client_secret</code>, and ship them
|
||||
inside your application. MCP breaks both assumptions. The client is someone's Claude
|
||||
Desktop or Cursor install — you will never pre-register it — and it runs on a desktop
|
||||
where a baked-in secret is not a secret. OAuth 2.1 plus three extension RFCs is how the
|
||||
MCP spec resolves this.
|
||||
</P>
|
||||
|
||||
<H2>The two roles: Authorization Server and Resource Server</H2>
|
||||
<P>
|
||||
Every OAuth setup splits into two jobs. The <Strong>Authorization Server (AS)</Strong>{' '}
|
||||
authenticates users and issues tokens. The <Strong>Resource Server (RS)</Strong> — your
|
||||
MCP server — accepts requests only when they carry a valid token. They can be the same
|
||||
deployment or separate services; the protocol only cares that the RS can verify what the
|
||||
AS signs, typically via a published JWKS (the AS's public keys).
|
||||
</P>
|
||||
|
||||
<H2>PKCE (RFC 7636): proof that the token requester started the flow</H2>
|
||||
<P>
|
||||
The authorization-code flow hands the client a one-time code via a browser redirect, and
|
||||
the client exchanges that code for a token. The classic attack is stealing the code
|
||||
in-flight and exchanging it yourself. <Strong>PKCE</Strong> closes this: the client
|
||||
invents a random secret (the verifier), sends only its hash (the challenge) when the flow
|
||||
starts, and must present the original verifier at exchange time. A thief who intercepted
|
||||
the code never saw the verifier, so the code is useless to them.
|
||||
</P>
|
||||
<P>
|
||||
In OAuth 2.1, PKCE is <Strong>mandatory for every client</Strong> — the old exemption for
|
||||
"confidential" server-side clients is gone. If your AS treats PKCE as optional, it is not
|
||||
OAuth 2.1.
|
||||
</P>
|
||||
|
||||
<H2>Dynamic Client Registration (RFC 7591): clients register themselves</H2>
|
||||
<P>
|
||||
When a user adds your MCP server to Claude Desktop, the client calls your AS's
|
||||
registration endpoint at runtime — "here is my name and redirect URI" — and receives a
|
||||
fresh <code>client_id</code> on the spot. No dashboard, no support ticket, no shared
|
||||
credentials between users. <Strong>DCR</Strong> is what makes "paste a URL, connect,
|
||||
done" possible: without it, every user of every MCP client would need you to manually
|
||||
provision app credentials.
|
||||
</P>
|
||||
|
||||
<H2>Resource Indicators (RFC 8707): tokens bound to one server</H2>
|
||||
<P>
|
||||
A user might connect their client to ten different MCP servers. Without{' '}
|
||||
<Strong>Resource Indicators</Strong>, a token minted for server A could be replayed
|
||||
against server B — any server you talk to could impersonate you elsewhere. RFC 8707 has
|
||||
the client name the exact server (the <code>resource</code>) when requesting the token,
|
||||
and the AS bakes that audience into the token. Your MCP server then rejects any token
|
||||
whose audience is not itself. One token, one server, no cross-server replay.
|
||||
</P>
|
||||
|
||||
<H2>The full flow, step by step</H2>
|
||||
<OL>
|
||||
<li>
|
||||
The client sends an unauthenticated request to your MCP server and gets a{' '}
|
||||
<Strong>401</Strong> with a <code>WWW-Authenticate</code> header pointing at the
|
||||
protected-resource metadata — this is how the client discovers your AS.
|
||||
</li>
|
||||
<li>The client registers itself with the AS via DCR and receives a client_id.</li>
|
||||
<li>
|
||||
The client generates a PKCE verifier + challenge and opens the browser to the AS's
|
||||
authorize endpoint, naming your server as the <code>resource</code>.
|
||||
</li>
|
||||
<li>The user signs in and approves; the AS redirects back with a one-time code.</li>
|
||||
<li>
|
||||
The client exchanges code + PKCE verifier for an access token that is
|
||||
audience-bound to your server.
|
||||
</li>
|
||||
<li>
|
||||
Every subsequent MCP request carries the token; your server verifies signature, expiry
|
||||
and audience on each call.
|
||||
</li>
|
||||
</OL>
|
||||
|
||||
<H2>What breaks when you skip a piece</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>No protected-resource metadata / wrong 401</Strong> — clients can't discover
|
||||
your AS. The symptom: the server "works" with curl but silently fails to install from
|
||||
Claude Desktop. This is the most common failure in the wild.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>No DCR</Strong> — the connect flow dead-ends at "unknown client" for anyone but
|
||||
you.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>No PKCE (or PKCE accepted but not enforced)</Strong> — the flow appears to work
|
||||
but intercepted authorization codes become exchangeable. Invisible until exploited.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>No audience check (RFC 8707)</Strong> — tokens for other servers are accepted
|
||||
by yours, and yours are accepted elsewhere. Also invisible until exploited.
|
||||
</li>
|
||||
</UL>
|
||||
<Note>
|
||||
The failure modes split into two families: discovery mistakes are loud (nothing
|
||||
connects), security mistakes are silent (everything connects, including attackers). Test
|
||||
for both — an end-to-end install from a real client proves discovery, but only negative
|
||||
tests (expired token, wrong audience, missing PKCE) prove the security half.
|
||||
</Note>
|
||||
|
||||
<H2>Common implementation mistakes</H2>
|
||||
<UL>
|
||||
<li>Substring-matching redirect URIs instead of exact-match comparison.</li>
|
||||
<li>Allowing authorization codes to be exchanged more than once.</li>
|
||||
<li>Accepting <code>plain</code> PKCE instead of requiring <code>S256</code>.</li>
|
||||
<li>
|
||||
Verifying token signature and expiry but forgetting the audience claim — RFC 8707 only
|
||||
protects you if the RS actually checks it.
|
||||
</li>
|
||||
<li>
|
||||
Long-lived access tokens as a substitute for refresh tokens — shorter access-token
|
||||
lifetime plus refresh is the OAuth 2.1 posture.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>Build it or get it built-in</H2>
|
||||
<P>
|
||||
None of this is exotic, but it is a genuine authorization-server implementation — days of
|
||||
work plus ongoing spec-tracking, and mistakes are security bugs rather than build
|
||||
failures. If you want to own it, the{' '}
|
||||
<Link
|
||||
href="/guides/host-mcp-server-with-oauth"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
hosting guide
|
||||
</Link>{' '}
|
||||
walks through the deployment options. If you'd rather not: every server generated on
|
||||
BuildMyMCPServer ships behind an OAuth 2.1 AS with PKCE, DCR and Resource Indicators
|
||||
already wired — the flow above is what our{' '}
|
||||
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
|
||||
OAuth docs
|
||||
</Link>{' '}
|
||||
implement, and the <Link href="/pricing" className="text-[--color-accent] hover:underline">free tier</Link>{' '}
|
||||
includes it.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'MCP server hosting: pricing & options compared (2026)';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'MCP server hosting: pricing & options compared (2026)',
|
||||
tag: 'Comparison',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, faqJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/mcp-server-hosting-pricing';
|
||||
const TITLE = 'MCP server hosting: pricing & options compared (2026)';
|
||||
const DESCRIPTION =
|
||||
'What hosting a remote MCP server actually costs in 2026 — Cloudflare Workers, Smithery, Composio, MintMCP and prompt-to-server generation, compared on price, effort and lock-in.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
// Pricing FAQ rendered below AND emitted as FAQPage JSON-LD — single source.
|
||||
const PRICING_FAQ = [
|
||||
{
|
||||
q: 'What is the cheapest way to host an MCP server?',
|
||||
a: 'If you can write the code yourself: Cloudflare Workers — the free tier covers 100k requests/day, which is more than most personal MCP servers ever see. If you cannot or do not want to write the code, generator platforms start free (BuildMyMCPServer Hobby: 1 server, 100k tool calls/month at €0).',
|
||||
},
|
||||
{
|
||||
q: 'Do I pay per tool call or per server?',
|
||||
a: 'Both models exist. Composio bills per tool call across its catalog. Cloudflare bills per request/CPU-time. BuildMyMCPServer prices tiers by server count plus a monthly tool-call allowance. Read the overage terms — per-call platforms get expensive at agent-scale traffic.',
|
||||
},
|
||||
{
|
||||
q: 'Is there a free way to get an OAuth-protected remote MCP server?',
|
||||
a: 'Yes, two honest routes: build it yourself on Cloudflare Workers with their OAuth provider library (free tier, your time is the cost), or generate one on a free tier of a hosting generator (BuildMyMCPServer Hobby is €0 for one server with OAuth 2.1 included).',
|
||||
},
|
||||
{
|
||||
q: 'What should enterprises check before picking an MCP host?',
|
||||
a: 'Compliance posture (SOC 2 / HIPAA — MintMCP leads here as of mid-2026), data residency, audit logging, SSO, and an exit path. Ask every vendor: can I export the server code and leave?',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1350,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<JsonLd data={faqJsonLd(PRICING_FAQ)} />
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="Five ways to put an MCP server on the internet, five very different bills. This is the pricing landscape as of July 2026 — including where each option quietly gets expensive, and where the free tiers are genuinely free."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>The five options at a glance</H2>
|
||||
<P>
|
||||
"MCP server hosting" covers products that do very different jobs: raw compute you
|
||||
deploy to, directories that also host, connector catalogs that bill per call, wrappers
|
||||
that productionize a server you already wrote, and generators that write and host the
|
||||
server for you. Comparing them on price only makes sense once you know which job you are
|
||||
buying.
|
||||
</P>
|
||||
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<th>What you get</th>
|
||||
<th>Entry price</th>
|
||||
<th>Paid from</th>
|
||||
<th>You bring</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Cloudflare Workers</Strong>
|
||||
</td>
|
||||
<td>Edge compute + OAuth provider library; you build and deploy</td>
|
||||
<td>Free — 100k requests/day</td>
|
||||
<td>$5/mo</td>
|
||||
<td>All the code, OAuth wiring, upkeep</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Smithery</Strong>
|
||||
</td>
|
||||
<td>Registry of 6,000+ servers, optional hosting of listed servers</td>
|
||||
<td>Free to browse/publish</td>
|
||||
<td>Paid tiers for hosting/usage</td>
|
||||
<td>An existing server (or pick one from the catalog)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Composio</Strong>
|
||||
</td>
|
||||
<td>1,000+ pre-built SaaS integrations exposed as MCP, managed auth</td>
|
||||
<td>Free — 20k tool calls/mo</td>
|
||||
<td>$29/mo (200k calls)</td>
|
||||
<td>Nothing — but only their catalog, per-call billing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>MintMCP</Strong>
|
||||
</td>
|
||||
<td>Wraps your existing STDIO server into a remote OAuth deployment; SOC 2 Type II</td>
|
||||
<td>Custom / enterprise pricing</td>
|
||||
<td>Contact sales</td>
|
||||
<td>A working server; budget for enterprise pricing</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>BuildMyMCPServer</Strong>
|
||||
</td>
|
||||
<td>Generates the TypeScript server from a prompt, hosts it behind OAuth 2.1</td>
|
||||
<td>Free — 1 server, 100k tool calls/mo</td>
|
||||
<td>€49/mo (5 servers, 1M calls)</td>
|
||||
<td>A description of the tool. That's it.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<Note>
|
||||
Competitor prices are as of mid-2026 and change often — treat the entry-tier shapes as the
|
||||
durable signal, not the exact numbers. Sources: public pricing pages of each vendor.
|
||||
</Note>
|
||||
|
||||
<H2>Cloudflare Workers: cheapest if your time is free</H2>
|
||||
<P>
|
||||
The free tier (100k requests/day, KV, D1, Durable Objects allowances) comfortably runs a
|
||||
personal or small-team MCP server at <Strong>€0 forever</Strong>, and the $5/mo paid plan
|
||||
covers almost anything below serious production traffic. The catch is that the price tag
|
||||
measures compute, not effort: you write the server, wire the OAuth provider library,
|
||||
handle token refresh edge cases, and own every upgrade when the MCP spec moves. For a
|
||||
team with engineers who enjoy that work, it is the best deal on this page.
|
||||
</P>
|
||||
|
||||
<H2>Smithery: pay for distribution, not development</H2>
|
||||
<P>
|
||||
Smithery is primarily a registry — thousands of community servers, CLI install, and
|
||||
hosting for servers published there, with OAuth handled for hosted listings. Browsing and
|
||||
publishing are free; hosted execution moves to paid tiers with usage. It answers
|
||||
"where do I find or distribute a server" more than "who builds mine" —
|
||||
if the tool you need already exists in the catalog, this can be the fastest free path of
|
||||
all.
|
||||
</P>
|
||||
|
||||
<H2>Composio: per-call pricing for a pre-built catalog</H2>
|
||||
<P>
|
||||
Composio's model is different: you are not hosting <em>your</em> server, you are
|
||||
calling <em>their</em> catalog of 1,000+ integrations with managed auth. Free covers 20k
|
||||
tool calls/month; $29/mo buys 200k, $229/mo buys 2M, with per-call overage beyond. For
|
||||
mainstream SaaS (Gmail, Slack, Salesforce) it is excellent value. The pricing risk is
|
||||
agent-scale traffic — an autonomous agent hammering tools burns per-call budgets fast —
|
||||
and the structural limit is the catalog: your internal API is not in it. More in our{' '}
|
||||
<Link href="/guides/composio-alternative" className="text-[--color-accent] hover:underline">
|
||||
Composio alternative guide
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
|
||||
<H2>MintMCP: compliance has a price tag</H2>
|
||||
<P>
|
||||
MintMCP wraps an existing STDIO server into a production remote deployment — OAuth
|
||||
brokering, SSO, SCIM, audit trails, SOC 2 Type II, HIPAA-aligned controls. Pricing is
|
||||
custom/enterprise as of mid-2026. If your buyer is a compliance team, this is the honest
|
||||
shortlist leader; nothing else in this table carries that certification set. If you are a
|
||||
solo developer, it is not aimed at you.
|
||||
</P>
|
||||
|
||||
<H2>BuildMyMCPServer: pay for the whole job, skip the build</H2>
|
||||
<P>
|
||||
Our own slot in this table, stated plainly: you describe the tool in natural language, the
|
||||
platform generates the TypeScript server, runs static checks, builds a container and
|
||||
deploys it behind a full OAuth 2.1 authorization server (PKCE, Dynamic Client
|
||||
Registration, Resource Indicators). <Strong>Hobby is €0</Strong> — one server, 100k tool
|
||||
calls/month. <Strong>Pro is €49/mo</Strong> for 5 servers and 1M calls;{' '}
|
||||
<Strong>Team €199/mo</Strong> adds audit logging at 25 servers/10M calls; Enterprise is
|
||||
custom. Full source export on every tier, so the exit path is real. What we do not have:
|
||||
SOC 2 (we are a young product), a giant connector catalog, or edge PoPs on six continents.
|
||||
See{' '}
|
||||
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
||||
full pricing
|
||||
</Link>{' '}
|
||||
and the{' '}
|
||||
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
||||
template gallery
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
|
||||
<H2>Choosing by scenario</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Solo dev, comfortable writing TypeScript, hobby traffic:</Strong> Cloudflare
|
||||
Workers free tier. Unbeatable if you enjoy the build.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Need Gmail/Slack/Notion-class connectors this afternoon:</Strong> Composio free
|
||||
tier, watch the per-call meter as agents scale.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Have a server, need enterprise compliance sign-off:</Strong> MintMCP, budget for
|
||||
enterprise pricing.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Want to publish or discover community servers:</Strong> Smithery.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Need a bespoke tool hosted with OAuth, no code, this hour:</Strong> a generator
|
||||
— that is the job{' '}
|
||||
<Link href="/" className="text-[--color-accent] hover:underline">
|
||||
BuildMyMCPServer
|
||||
</Link>{' '}
|
||||
exists for.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>Pricing FAQ</H2>
|
||||
{PRICING_FAQ.map((f) => (
|
||||
<div key={f.q} className="mt-5">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight text-[--color-fg]">{f.q}</h3>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<P>
|
||||
Related:{' '}
|
||||
<Link
|
||||
href="/guides/hosted-mcp-platforms-compared"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
the four categories of MCP platform, explained
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link
|
||||
href="/guides/host-mcp-server-with-oauth"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
what hosting with OAuth actually involves
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'MCP Server ohne Code erstellen und hosten (2026)';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'MCP Server ohne Code erstellen und hosten',
|
||||
tag: 'Anleitung',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { StaticCodeBlock } from '@/components/static-code-block';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, OL, P, Strong, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/mcp-server-ohne-code-erstellen';
|
||||
const TITLE = 'MCP Server ohne Code erstellen und hosten (2026)';
|
||||
const DESCRIPTION =
|
||||
'Wie Sie ohne Programmierkenntnisse einen eigenen MCP Server erstellen und hosten: Tool auf Deutsch oder Englisch beschreiben, generierten TypeScript-Server mit OAuth 2.1 deployen, Install-Snippet in Claude, Cursor oder ChatGPT einfügen.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1250,
|
||||
inLanguage: 'de',
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="MCP verbindet KI-Assistenten wie Claude, Cursor und ChatGPT mit Ihren eigenen Tools und Daten. Dieser Guide zeigt den Weg vom Satz in natürlicher Sprache zum gehosteten, OAuth-geschützten Server — ohne eine Zeile Code."
|
||||
updated="Juli 2026"
|
||||
>
|
||||
<H2>Was ist MCP — in zwei Absätzen</H2>
|
||||
<P>
|
||||
Das <Strong>Model Context Protocol (MCP)</Strong> ist ein offener Standard von Anthropic,
|
||||
der KI-Assistenten mit externen Tools, Datenbanken und APIs verbindet. Statt für jeden
|
||||
Assistenten eine eigene Integration zu bauen, stellen Sie einen MCP Server bereit — und
|
||||
jeder kompatible Client (Claude Desktop, Cursor, ChatGPT, VS Code Copilot, Continue.dev)
|
||||
kann ihn nutzen.
|
||||
</P>
|
||||
<P>
|
||||
Ein MCP Server stellt <Strong>Tools</Strong> bereit: klar definierte Funktionen wie
|
||||
„durchsuche unser Notion-Workspace" oder „lies Bestellungen aus der Datenbank". Die KI
|
||||
entscheidet im Gespräch, wann sie ein Tool aufruft; der Server führt aus und liefert das
|
||||
Ergebnis zurück in den Chat.
|
||||
</P>
|
||||
|
||||
<H2>Warum „ohne Code" bisher nicht selbstverständlich war</H2>
|
||||
<P>
|
||||
Einen lokalen Test-Server bekommt man mit dem MCP-SDK schnell hin — wenn man
|
||||
programmieren kann. Ein <Strong>produktiver, erreichbarer</Strong> Server ist ein anderes
|
||||
Kaliber: Er braucht den Streamable-HTTP-Transport, TLS, eine OAuth-2.1-Autorisierung
|
||||
(damit nicht das ganze Internet Ihre Tools aufrufen kann), sichere Verwahrung Ihrer
|
||||
API-Schlüssel und Hosting, das nicht nach zwei Wochen umfällt. Genau diese Schicht können
|
||||
Sie heute generieren lassen, statt sie zu bauen.
|
||||
</P>
|
||||
|
||||
<H2>Der Weg: von der Beschreibung zum laufenden Server</H2>
|
||||
<OL>
|
||||
<li>
|
||||
<Strong>Tool beschreiben — auf Deutsch oder Englisch.</Strong> Ein präziser Satz
|
||||
genügt: welche Aufgabe, welche Tools, welche Zugangsdaten. Beispiel unten.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Spezifikation prüfen.</Strong> Die Plattform (in unserem Fall
|
||||
BuildMyMCPServer) analysiert die Beschreibung und schlägt die Tool-Definitionen vor —
|
||||
Namen, Parameter, benötigte Secrets. Hier korrigieren Sie, bevor etwas gebaut wird.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Generieren und deployen lassen.</Strong> Daraus entsteht ein
|
||||
TypeScript-MCP-Server, der statisch geprüft, in ein Docker-Image gebaut und auf einer
|
||||
eigenen Subdomain deployt wird — hinter einem OAuth-2.1-Authorization-Server mit PKCE.
|
||||
Typische Dauer: 45–90 Sekunden.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Secrets eintragen.</Strong> API-Schlüssel (z. B. Ihr Notion-Token) werden
|
||||
AES-256-GCM-verschlüsselt gespeichert und nur zur Laufzeit als Umgebungsvariablen in
|
||||
den Container injiziert — sie landen nie im Code und nie in Logs.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Install-Snippet in den Client kopieren.</Strong> Für Claude Desktop, Cursor
|
||||
und ChatGPT gibt es fertige Snippets; beim ersten Zugriff läuft der OAuth-Flow im
|
||||
Browser.
|
||||
</li>
|
||||
</OL>
|
||||
|
||||
<H2>Ein konkretes Beispiel</H2>
|
||||
<P>So sieht eine ausreichende Beschreibung aus — Deutsch funktioniert:</P>
|
||||
<StaticCodeBlock
|
||||
label="Prompt"
|
||||
code={`Erstelle einen MCP Server, der unser Notion-Workspace durchsucht.
|
||||
Tools: search_pages, get_page_content.
|
||||
Auth: NOTION_API_KEY.`}
|
||||
/>
|
||||
<P>Und so das Ergebnis im Client — ein Eintrag in der Konfiguration von Claude Desktop:</P>
|
||||
<StaticCodeBlock
|
||||
label="claude_desktop_config.json"
|
||||
code={`{
|
||||
"mcpServers": {
|
||||
"notion": {
|
||||
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
|
||||
"auth": "oauth2"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
||||
<H2>Was Sie trotzdem verstehen sollten</H2>
|
||||
<P>
|
||||
„Ohne Code" heisst nicht „ohne Verantwortung". Drei Dinge bleiben Ihre Entscheidung:
|
||||
</P>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Rechteumfang der Schlüssel.</Strong> Geben Sie dem Server nur die Rechte, die
|
||||
die Tools brauchen — ein Read-only-Schlüssel, wo die Quelle das anbietet. Die KI wird
|
||||
jedes Tool nutzen, das existiert.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Welche Tools existieren.</Strong> Lesende Tools zuerst; schreibende Tools nur,
|
||||
wenn der Anwendungsfall sie wirklich verlangt. Mehr dazu in unserer{' '}
|
||||
<Link
|
||||
href="/guides/mcp-server-security-checklist"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
Security-Checkliste
|
||||
</Link>{' '}
|
||||
(Englisch).
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Wem Sie den Betrieb anvertrauen.</Strong> Prüfen Sie die Sicherheitsangaben
|
||||
des Anbieters auf Konkretes: Verschlüsselung, Container-Isolation, Quellcode-Export.
|
||||
Bei uns steht das auf der <Link href="/security" className="text-[--color-accent] hover:underline">Security-Seite</Link>{' '}
|
||||
— und jeden generierten Server können Sie als TypeScript-Quellcode exportieren und
|
||||
selbst hosten. Kein Lock-in.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>Kosten</H2>
|
||||
<P>
|
||||
Der Einstieg ist kostenlos: Der Hobby-Plan umfasst einen Server mit 100 000
|
||||
Tool-Aufrufen pro Monat auf einer BuildMyMCP-Subdomain. Bezahlpläne skalieren über
|
||||
Server-Anzahl und Aufruf-Volumen — Details auf der{' '}
|
||||
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
||||
Preisseite
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
|
||||
<Note>
|
||||
Für die technischen Hintergründe auf Englisch:{' '}
|
||||
<Link
|
||||
href="/guides/mcp-transports-explained"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
MCP-Transporte erklärt
|
||||
</Link>{' '}
|
||||
(warum Streamable HTTP der Standard ist) und{' '}
|
||||
<Link
|
||||
href="/guides/mcp-oauth-plain-english"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
OAuth 2.1 für MCP Server
|
||||
</Link>{' '}
|
||||
(was PKCE, DCR und RFC 8707 leisten).
|
||||
</Note>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'MCP server security checklist: secrets, isolation, auth';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'MCP server security checklist: secrets, isolation, auth',
|
||||
tag: 'Checklist',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, P, Strong, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/mcp-server-security-checklist';
|
||||
const TITLE = 'MCP server security checklist: secrets, isolation, auth';
|
||||
const DESCRIPTION =
|
||||
'A practical security checklist for production MCP servers: transport auth, secret storage and injection, container isolation, least-privilege tool design, safe logging, and the prompt-injection surface of tool descriptions.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1300,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="An MCP server is an API that an AI calls with credentials you gave it. That combination — machine-driven calls, real secrets, natural-language control — has its own failure modes. Here is the checklist we hold our own platform to."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>1. Transport and authentication</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>TLS everywhere.</Strong> A remote MCP server speaks Streamable HTTP over HTTPS
|
||||
at a stable URL — no plaintext fallback, no self-signed shortcuts in production.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Every request authenticated before it reaches tool code.</Strong> OAuth 2.1
|
||||
with PKCE, Dynamic Client Registration and audience-bound tokens (RFC 8707) is the MCP
|
||||
standard — the details are in{' '}
|
||||
<Link
|
||||
href="/guides/mcp-oauth-plain-english"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
our plain-English OAuth explainer
|
||||
</Link>
|
||||
. The important checklist item: an unauthenticated request must be rejected by the
|
||||
gateway, not by convention inside your tool logic.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Verify audience, not just signature.</Strong> A token minted for someone else's
|
||||
server must not work on yours. Signature + expiry + audience — all three, every call.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>2. Secret storage and injection</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Encrypted at rest.</Strong> API keys your server needs (Notion tokens, database
|
||||
DSNs, Stripe keys) belong in a store that is encrypted with a real scheme —
|
||||
AES-256-GCM, not base64, not "it's an internal database".
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Injected at runtime, never baked in.</Strong> Secrets should enter the process
|
||||
as environment variables at container start — never committed into the generated code,
|
||||
the image, or the template you share.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Never echoed back.</Strong> No tool response, error message, build log or
|
||||
debug output should ever contain a secret value. Test this deliberately: ask the AI
|
||||
client to print its configuration and confirm the secret does not appear.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Scoped upstream credentials.</Strong> If the upstream API offers read-only or
|
||||
resource-scoped keys, use them. The MCP server can only leak the power you gave it.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>3. Isolation between servers</H2>
|
||||
<P>
|
||||
If you run more than one MCP server — or host servers for more than one user — the
|
||||
blast radius question dominates: what does a compromised or simply buggy server reach?
|
||||
</P>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>One server, one container.</Strong> Process-level separation is not enough
|
||||
when different trust domains share a host. Each server should run in its own container
|
||||
with its own secrets, so a bug in one cannot read another's environment.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>No host mounts, no docker socket.</Strong> A tool-serving container has no
|
||||
business seeing the host filesystem or the container runtime.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Resource limits.</Strong> Memory and CPU caps per container turn a runaway
|
||||
loop into a restart instead of a host outage.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>4. Least-privilege tool design</H2>
|
||||
<P>
|
||||
The cheapest security control in MCP is deciding what tools exist at all. The AI will
|
||||
eventually call every tool you expose, with every argument shape it can think of.
|
||||
</P>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Read-only by default.</Strong> Ship <code>search</code> and <code>get</code>{' '}
|
||||
tools first; add write tools only when the use case demands them, and separately.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Narrow arguments.</Strong> A <code>query_orders(customer_id)</code> tool is
|
||||
auditable; a <code>run_sql(sql)</code> tool is an incident report with a delay timer.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Validate inputs inside the tool.</Strong> Tool schemas are hints to the model,
|
||||
not enforcement. Your handler must validate as if the input came from the public
|
||||
internet — because via the model, it did.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>5. Logging without leaking</H2>
|
||||
<UL>
|
||||
<li>
|
||||
Log <Strong>that</Strong> a tool was called, its latency and result status — the
|
||||
metrics you need for operations.
|
||||
</li>
|
||||
<li>
|
||||
Be deliberate about logging <Strong>arguments and results</Strong>: they routinely
|
||||
contain customer data and occasionally contain secrets a model pasted into a query.
|
||||
Redact or hash by default.
|
||||
</li>
|
||||
<li>
|
||||
Keep an <Strong>audit trail</Strong> of who connected which client and when — OAuth
|
||||
gives you the identity for free; keep it attached to the call records.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>6. The prompt-injection surface</H2>
|
||||
<P>
|
||||
Two MCP-specific angles most checklists miss. First:{' '}
|
||||
<Strong>tool descriptions are instructions to the model</Strong>. If you install a
|
||||
third-party MCP server, its tool descriptions enter your AI's context — a malicious
|
||||
description can tell the model to exfiltrate data through another tool's arguments.
|
||||
Install servers the way you install browser extensions: from sources you trust, reading
|
||||
what they expose.
|
||||
</P>
|
||||
<P>
|
||||
Second: <Strong>tool results are untrusted input to the model.</Strong> If your tool
|
||||
returns content from the outside world (web pages, tickets, emails), that content can
|
||||
contain instructions the model may follow. You cannot fully solve this server-side, but
|
||||
you can avoid amplifying it: return structured data instead of raw HTML where possible,
|
||||
and never grant one server both broad read and broad write power — that pairing is what
|
||||
turns an injected instruction into an actual exfiltration.
|
||||
</P>
|
||||
|
||||
<H2>What a checklist can't fix</H2>
|
||||
<Note>
|
||||
Honesty section: no checklist makes an over-privileged design safe. If a server holds an
|
||||
admin API key and exposes a write-anything tool, perfect transport security just means
|
||||
the mistake is encrypted in flight. Prompt injection remains an open research problem —
|
||||
the mitigations above reduce blast radius, they do not eliminate the class. And a hosted
|
||||
platform (ours included) means trusting the platform's own isolation and encryption;
|
||||
read the vendor's{' '}
|
||||
<Link href="/security" className="text-[--color-accent] hover:underline">
|
||||
security page
|
||||
</Link>{' '}
|
||||
and hold them to specifics, not adjectives.
|
||||
</Note>
|
||||
|
||||
<H2>How BuildMyMCPServer maps to this list</H2>
|
||||
<P>
|
||||
For transparency about our own posture: generated servers run one-per-container behind an
|
||||
OAuth 2.1 authorization server; customer secrets are AES-256-GCM encrypted at rest and
|
||||
injected as environment variables at runtime, never logged or echoed back; and every
|
||||
server's TypeScript source can be exported for review. Templates carry the spec and code,
|
||||
never the author's credentials — you add your own on{' '}
|
||||
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
||||
fork
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'MCP transports explained: stdio vs SSE vs Streamable HTTP';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'MCP transports explained: stdio vs SSE vs Streamable HTTP',
|
||||
tag: 'Explainer',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { StaticCodeBlock } from '@/components/static-code-block';
|
||||
import { articleJsonLd, breadcrumbJsonLd, faqJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/mcp-transports-explained';
|
||||
const TITLE = 'MCP transports explained: stdio vs SSE vs Streamable HTTP';
|
||||
const DESCRIPTION =
|
||||
'What each MCP transport actually is, why the spec deprecated HTTP+SSE in favor of Streamable HTTP, and which transport to pick for local tools, remote servers and serverless deployments.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
const TRANSPORT_FAQ = [
|
||||
{
|
||||
q: 'Is SSE deprecated in MCP?',
|
||||
a: 'Yes. The HTTP+SSE transport from protocol version 2024-11-05 was replaced by Streamable HTTP in spec revision 2025-03-26. Servers may keep SSE endpoints for backward compatibility, but client support is degrading and platforms have been removing it through 2026.',
|
||||
},
|
||||
{
|
||||
q: 'What is the difference between Streamable HTTP and SSE in MCP?',
|
||||
a: 'The old HTTP+SSE transport needed two endpoints — a long-lived SSE stream for server-to-client messages and a separate POST endpoint for client-to-server messages. Streamable HTTP collapses this into one MCP endpoint that accepts HTTP POST and can optionally upgrade a response to an SSE stream when the server needs to push multiple messages.',
|
||||
},
|
||||
{
|
||||
q: 'When should I use stdio instead of HTTP?',
|
||||
a: 'Use stdio when the server runs on the same machine as the client — local dev tools, filesystem access, anything personal. The client spawns the server as a subprocess; there is no network surface and no auth to build. The moment more than one person or machine needs the server, you need Streamable HTTP.',
|
||||
},
|
||||
{
|
||||
q: 'Does Streamable HTTP require SSE?',
|
||||
a: 'No. SSE is optional within Streamable HTTP. A server can answer every request with a plain JSON response and never open a stream. Streams are only needed when the server wants to send progress notifications or multiple messages for one request.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1350,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<JsonLd data={faqJsonLd(TRANSPORT_FAQ)} />
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="MCP has shipped three transports in under two years. Two are current, one is on its way out. Here is what each one actually does, why the spec moved, and how to choose."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>The three transports at a glance</H2>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Transport</th>
|
||||
<th>Status</th>
|
||||
<th>Endpoints</th>
|
||||
<th>Best for</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>stdio</td>
|
||||
<td>Current</td>
|
||||
<td>None — subprocess pipes</td>
|
||||
<td>Local, single-user tools</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HTTP+SSE</td>
|
||||
<td>Deprecated (2025-03-26)</td>
|
||||
<td>Two: SSE stream + POST</td>
|
||||
<td>Legacy remote servers only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Streamable HTTP</td>
|
||||
<td>Current standard for remote</td>
|
||||
<td>One MCP endpoint (POST/GET)</td>
|
||||
<td>Every remote deployment</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<H2>stdio: the local transport</H2>
|
||||
<P>
|
||||
With <Strong>stdio</Strong>, the MCP client launches your server as a subprocess and
|
||||
exchanges JSON-RPC messages over stdin and stdout. There is no port, no TLS, no
|
||||
authentication — the security boundary is your operating system's process model. That is
|
||||
exactly right for tools that live on your own machine: filesystem helpers, local database
|
||||
access, developer utilities.
|
||||
</P>
|
||||
<P>
|
||||
The limitation is structural. A stdio server is bound to one machine and one client
|
||||
process. You cannot share it with a teammate, install it on a phone, or point ChatGPT's
|
||||
web app at it. It also means every user has to install a runtime (Node, Python, Docker)
|
||||
and keep the server updated themselves.
|
||||
</P>
|
||||
|
||||
<H2>HTTP+SSE: the deprecated remote transport</H2>
|
||||
<P>
|
||||
The first remote transport (protocol version 2024-11-05) paired two endpoints: the client
|
||||
opened a long-lived <Strong>Server-Sent Events</Strong> stream to receive messages, and
|
||||
sent its own messages to a separate HTTP POST endpoint the server advertised over that
|
||||
stream.
|
||||
</P>
|
||||
<P>This design turned out to be hostile to real infrastructure:</P>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Load balancers</Strong> had to pin the SSE stream and the POST endpoint to the
|
||||
same backend instance, defeating horizontal scaling.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Serverless platforms</Strong> bill and time out on connection duration; a
|
||||
permanently open SSE stream is the pathological case.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Proxies and firewalls</Strong> routinely buffer or kill long-lived streams,
|
||||
producing connections that look healthy but deliver nothing.
|
||||
</li>
|
||||
</UL>
|
||||
<P>
|
||||
Spec revision <Strong>2025-03-26</Strong> replaced HTTP+SSE with Streamable HTTP. The old
|
||||
transport still works where both sides keep supporting it, but the direction is one-way:
|
||||
platforms have been announcing removal dates through 2026 — Atlassian's Rovo MCP server,
|
||||
for example, announced an HTTP+SSE cutoff of June 30, 2026. New servers should not ship
|
||||
it.
|
||||
</P>
|
||||
|
||||
<H2>Streamable HTTP: the current standard</H2>
|
||||
<P>
|
||||
<Strong>Streamable HTTP</Strong> collapses everything onto a single MCP endpoint. The
|
||||
client sends JSON-RPC messages as ordinary HTTP POST requests. For a simple request, the
|
||||
server answers with a plain JSON response — request in, response out, connection closed.
|
||||
When the server needs to push several messages for one request (progress updates,
|
||||
notifications), it can upgrade that specific response to an SSE stream. Streaming becomes
|
||||
an option per response, not a mandatory architecture.
|
||||
</P>
|
||||
<UL>
|
||||
<li>Stateless requests by default — load balancers and serverless platforms just work.</li>
|
||||
<li>One URL to configure, secure and monitor instead of two coupled endpoints.</li>
|
||||
<li>
|
||||
Sessions are explicit (an <code>Mcp-Session-Id</code> header) instead of implied by a
|
||||
held-open socket, so a server can resume or reject them deliberately.
|
||||
</li>
|
||||
<li>Standard HTTP auth applies — which is what makes OAuth 2.1 integration clean.</li>
|
||||
</UL>
|
||||
<P>A remote server entry in a client config is now just a URL:</P>
|
||||
<StaticCodeBlock
|
||||
label="claude_desktop_config.json"
|
||||
code={`{
|
||||
"mcpServers": {
|
||||
"my-tool": {
|
||||
"url": "https://my-tool.mcp.buildmymcpserver.com/mcp",
|
||||
"auth": "oauth2"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
||||
<H2>Choosing a transport</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>Only you, on your machine</Strong> → stdio. Zero infrastructure, strongest
|
||||
isolation.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Anyone else, any other machine, any hosted client</Strong> → Streamable HTTP
|
||||
over TLS, with OAuth 2.1 in front of it. This is the only current answer for servers
|
||||
that Claude Desktop, Cursor and ChatGPT install over the internet.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Existing HTTP+SSE server</Strong> → migrate. The usual path is to serve the new
|
||||
single MCP endpoint alongside the legacy pair during a transition window, then drop the
|
||||
legacy endpoints once your clients are confirmed on the new transport.
|
||||
</li>
|
||||
</UL>
|
||||
<Note>
|
||||
Transport is only half of a production remote server — the other half is authorization.
|
||||
OAuth 2.1 with PKCE, Dynamic Client Registration and Resource Indicators is what makes a
|
||||
Streamable HTTP server actually installable from real clients. That's covered in{' '}
|
||||
<Link
|
||||
href="/guides/host-mcp-server-with-oauth"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
how to host a remote MCP server with OAuth
|
||||
</Link>
|
||||
.
|
||||
</Note>
|
||||
|
||||
<H2>FAQ</H2>
|
||||
{TRANSPORT_FAQ.map((f) => (
|
||||
<div key={f.q} className="mt-5">
|
||||
<h3 className="text-[15px] font-semibold tracking-tight text-[--color-fg]">{f.q}</h3>
|
||||
<p className="mt-1.5 text-[14px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'MintMCP alternative: generate and host a custom MCP server';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'MintMCP alternative: generate and host a custom MCP server',
|
||||
tag: 'Alternative',
|
||||
});
|
||||
}
|
||||
103
apps/web/app/(marketing)/guides/mintmcp-alternative/page.tsx
Normal file
103
apps/web/app/(marketing)/guides/mintmcp-alternative/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, P, Strong, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/mintmcp-alternative';
|
||||
const TITLE = 'MintMCP alternative: generate and host a custom MCP server';
|
||||
const DESCRIPTION =
|
||||
'MintMCP wraps an existing STDIO server into a remote one with OAuth. If you do not have a server yet, here is the generate-from-a-prompt alternative — and where MintMCP still wins.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-05-31',
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="MintMCP and BuildMyMCPServer both get you to a hosted, OAuth-protected MCP server — but they start from opposite ends. The right pick depends entirely on whether you already have server code."
|
||||
updated="May 2026"
|
||||
>
|
||||
<H2>What MintMCP does well</H2>
|
||||
<P>
|
||||
MintMCP takes a local <Strong>STDIO-based MCP server you already wrote</Strong> and turns
|
||||
it into a production remote deployment — one-click, with automatic OAuth wrapping. Its
|
||||
headline strength is <Strong>compliance</Strong>: SOC 2 Type II, with audit logs in SOC 2,
|
||||
HIPAA and GDPR-friendly formats. For an enterprise that already has a server and needs the
|
||||
certifications signed off, that's a strong, honest fit.
|
||||
</P>
|
||||
|
||||
<H2>Where it leaves a gap</H2>
|
||||
<P>
|
||||
The model assumes the hard part — designing and writing the server — is already done. If
|
||||
you're starting from <em>"I need a tool that does X"</em> and there's no code
|
||||
yet, a wrapper doesn't help. You still have to learn the MCP SDK, write and test the tool
|
||||
logic, then bring it over.
|
||||
</P>
|
||||
|
||||
<H2>The alternative: start from the prompt</H2>
|
||||
<P>
|
||||
<Strong>BuildMyMCPServer</Strong> covers the step before the wrap. You describe the tool in
|
||||
plain language; it generates the TypeScript MCP server, runs static checks against banned
|
||||
patterns, builds a container, and deploys it behind a full OAuth 2.1 authorization server
|
||||
(PKCE, Dynamic Client Registration, Resource Indicators). You get copy-paste install
|
||||
snippets for Claude Desktop, Cursor and ChatGPT, and the full source to export whenever you
|
||||
want.
|
||||
</P>
|
||||
|
||||
<H2>Pick by your starting point</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>You have a working STDIO server + need SOC 2/HIPAA today</Strong> → MintMCP is
|
||||
the more honest fit. We don't claim those certifications.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>You have an idea, not a server</Strong> → generate it here, ship in minutes, and
|
||||
export the TypeScript if you later move it onto your own infra.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>You're an agency building one-off tools for clients repeatedly</Strong> →
|
||||
generation + a fork-able template marketplace removes the per-client boilerplate.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>You're in the EU/DACH and care where prompts go</Strong> → we expose the provider
|
||||
and offer a data-residency choice rather than defaulting everything to one region.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>What's the same either way</H2>
|
||||
<P>
|
||||
Both deliver a remote, OAuth-protected MCP server at a stable URL that real clients can
|
||||
install — neither leaves you hand-rolling the auth handshake. The difference is purely{' '}
|
||||
<Strong>where you start</Strong>: with code, or with a sentence.
|
||||
</P>
|
||||
|
||||
<P>
|
||||
More on the landscape:{' '}
|
||||
<Link
|
||||
href="/guides/hosted-mcp-platforms-compared"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
hosted MCP platforms compared
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
apps/web/app/(marketing)/guides/page.tsx
Normal file
58
apps/web/app/(marketing)/guides/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articlesNewestFirst } from '@/lib/articles';
|
||||
import { breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
title: 'MCP guides',
|
||||
description:
|
||||
'Practical guides on hosting, securing and shipping Model Context Protocol (MCP) servers — OAuth 2.1, remote transport, platform comparisons.',
|
||||
path: '/guides',
|
||||
});
|
||||
|
||||
export default function GuidesIndex() {
|
||||
const guides = articlesNewestFirst();
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-14">
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
])}
|
||||
/>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight text-[--color-fg]">MCP guides</h1>
|
||||
<p className="mt-2 text-[14.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
Hosting, auth and shipping for Model Context Protocol servers — written for people building
|
||||
real tools, not demos.
|
||||
</p>
|
||||
<div className="mt-8 space-y-3">
|
||||
{guides.map((g) => (
|
||||
<Link
|
||||
key={g.slug}
|
||||
href={`/guides/${g.slug}`}
|
||||
className="block rounded-lg border border-[--color-border] p-4 transition-colors hover:bg-[--color-bg-subtle]"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
{g.tag}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-[--color-fg-subtle]">
|
||||
{new Date(g.dateModified ?? g.datePublished).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-1 text-[16px] font-semibold tracking-tight text-[--color-fg]">
|
||||
{g.title}
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-[--color-fg-muted]">
|
||||
{g.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'Wrap any REST API as an MCP server';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'Wrap any REST API as an MCP server',
|
||||
tag: 'Guide',
|
||||
});
|
||||
}
|
||||
179
apps/web/app/(marketing)/guides/rest-api-to-mcp-server/page.tsx
Normal file
179
apps/web/app/(marketing)/guides/rest-api-to-mcp-server/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { StaticCodeBlock } from '@/components/static-code-block';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/rest-api-to-mcp-server';
|
||||
const TITLE = 'Wrap any REST API as an MCP server';
|
||||
const DESCRIPTION =
|
||||
'How to turn a REST API into an MCP server your AI clients can use: designing the tool surface (fewer, better tools beat 1:1 endpoint mapping), handling auth headers with encrypted secrets, and respecting upstream rate limits.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
const BAD_PROMPT = `Create an MCP server for our API.
|
||||
Endpoints: GET /users, GET /users/:id, POST /users, PUT /users/:id,
|
||||
DELETE /users/:id, GET /orders, GET /orders/:id, POST /orders,
|
||||
GET /invoices, GET /invoices/:id, POST /invoices/:id/send ...`;
|
||||
|
||||
const GOOD_PROMPT = `Create an MCP server for our billing API (https://api.example.com).
|
||||
Tools:
|
||||
- find_customer: search customers by name or email, return id, plan, status
|
||||
- get_open_invoices: list unpaid invoices for a customer id, with amounts
|
||||
- send_invoice_reminder: send the dunning email for one invoice id
|
||||
Auth: BILLING_API_KEY sent as "Authorization: Bearer" header.
|
||||
Read-heavy; send_invoice_reminder is the only write action.`;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1400,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="Every REST API is one wrapper away from being usable by Claude, Cursor or ChatGPT. The wrapper is an MCP server — and the difference between a useful one and a frustrating one is decided before any code exists, in how you design the tool surface."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>The core mistake: 1:1 endpoint mapping</H2>
|
||||
<P>
|
||||
The obvious approach — one MCP tool per REST endpoint — produces a bad server. An AI
|
||||
assistant choosing between 30 near-identical tools burns context on the choice, chains
|
||||
three calls where one would do, and picks wrong often enough to erode trust. The
|
||||
assistant is not a REST client; it does not want your resource model, it wants{' '}
|
||||
<Strong>tasks</Strong>.
|
||||
</P>
|
||||
<StaticCodeBlock code={BAD_PROMPT} label="what not to do" />
|
||||
<P>
|
||||
Design the tool surface the way you would design CLI commands for a colleague:
|
||||
task-shaped, few, with the joins already done.
|
||||
</P>
|
||||
<StaticCodeBlock code={GOOD_PROMPT} label="the same API, task-shaped" />
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>3–7 tools</Strong> is the sweet spot for a single-purpose server. More than ten
|
||||
and selection quality drops.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Fold lookups into the tool.</Strong> If sending a reminder needs a customer id,
|
||||
let <Strong>find_customer</Strong> exist — but do not expose the four intermediate
|
||||
endpoints the API needs internally.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Return shaped results, not raw payloads.</Strong> “id, plan, status” beats the
|
||||
full 80-field customer object; the model reads every byte you return.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Separate reads from writes</Strong> and say so in the description — clients
|
||||
like ChatGPT restrict write tools on individual plans (
|
||||
<Link href="/guides/chatgpt-mcp-connector" className="text-[--color-accent] hover:underline">
|
||||
details here
|
||||
</Link>
|
||||
), and a clean read/write split keeps the read tools usable everywhere.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>Auth: the API key never goes in the prompt</H2>
|
||||
<P>
|
||||
The wrapper needs your API's credential, and there is exactly one right place for it: an{' '}
|
||||
<Strong>encrypted secret</Strong>, referenced by name. In the prompt above,{' '}
|
||||
<Strong>BILLING_API_KEY</Strong> is a name, not a value. You provide the value separately
|
||||
in the dashboard; it is encrypted with AES-256-GCM at rest and injected into the server's
|
||||
container as an environment variable at runtime — never logged, never echoed back, never
|
||||
part of the generated source.
|
||||
</P>
|
||||
<P>
|
||||
The second auth layer is between the AI client and your MCP server: every deployed server
|
||||
sits behind OAuth 2.1 (PKCE, Dynamic Client Registration, Resource Indicators), so your
|
||||
wrapped API is not one guessable URL away from the public internet. How that handshake
|
||||
works:{' '}
|
||||
<Link href="/docs/oauth" className="text-[--color-accent] hover:underline">
|
||||
OAuth docs
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
<Note>
|
||||
Start read-only. A read-only wrapper cannot damage anything while you learn how the
|
||||
assistant actually uses the tools; add the one or two write actions after a week of
|
||||
watching the call logs.
|
||||
</Note>
|
||||
|
||||
<H2>Rate limits: yours and theirs</H2>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Layer</th>
|
||||
<th>What limits it</th>
|
||||
<th>What to do</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Client → MCP server</td>
|
||||
<td>OAuth gate before your container; plan quotas (free tier: 100k calls/mo)</td>
|
||||
<td>Nothing — enforced for you.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>MCP server → upstream API</td>
|
||||
<td>The upstream's own rate limits</td>
|
||||
<td>
|
||||
Tell the generator: “respect a limit of N req/s; on 429, back off and surface the
|
||||
error”. Shaped tools help here too — one task call instead of five endpoint calls.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Model behavior</td>
|
||||
<td>Assistants retry failed calls</td>
|
||||
<td>
|
||||
Return clear error messages (“rate limited, retry in 30s”) — models read them and
|
||||
actually wait.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<H2>From prompt to installed tool, end to end</H2>
|
||||
<P>
|
||||
With the prompt written, the rest is mechanical:{' '}
|
||||
<Link href="/guides/create-mcp-server-without-code" className="text-[--color-accent] hover:underline">
|
||||
generation, static checks, container build and deploy
|
||||
</Link>{' '}
|
||||
take 45–90 seconds, and the dashboard gives you install snippets for{' '}
|
||||
<Link href="/guides/claude-desktop-mcp-setup" className="text-[--color-accent] hover:underline">
|
||||
Claude Desktop
|
||||
</Link>
|
||||
, Cursor and ChatGPT. The generated TypeScript is exportable — if your wrapper outgrows
|
||||
prompt-editing (complex retries, multi-step workflows), take the source and continue by
|
||||
hand with the boilerplate already written.
|
||||
</P>
|
||||
<P>
|
||||
If your API resembles something common — Notion, GitHub, Stripe, PostgreSQL — check{' '}
|
||||
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
||||
the templates
|
||||
</Link>{' '}
|
||||
first: forking a working server and swapping in your credential is faster than writing
|
||||
any prompt. Otherwise, the{' '}
|
||||
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
||||
free tier
|
||||
</Link>{' '}
|
||||
covers one server — enough to wrap the API you use most and find out what your assistant
|
||||
does with it.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { articleOgImage, OG_SIZE } from '@/lib/og-article';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const alt = 'Smithery alternative: when you need hosting, not a directory';
|
||||
export const size = OG_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return articleOgImage({
|
||||
title: 'Smithery alternative: when you need hosting, not a directory',
|
||||
tag: 'Alternative',
|
||||
});
|
||||
}
|
||||
196
apps/web/app/(marketing)/guides/smithery-alternative/page.tsx
Normal file
196
apps/web/app/(marketing)/guides/smithery-alternative/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { JsonLd } from '@/components/json-ld';
|
||||
import { articleJsonLd, breadcrumbJsonLd, pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
import { ArticleShell, H2, Note, P, Strong, Table, UL } from '../article-shell';
|
||||
|
||||
const PATH = '/guides/smithery-alternative';
|
||||
const TITLE = 'Smithery alternative: when you need hosting, not a directory';
|
||||
const DESCRIPTION =
|
||||
'Smithery is the best-known MCP registry — thousands of servers, CLI install, hosting for listed servers. The gap: it assumes the server exists. Here is the route when it does not, and where Smithery clearly wins.';
|
||||
|
||||
export const metadata = pageMetadata({ title: TITLE, description: DESCRIPTION, path: PATH });
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<JsonLd
|
||||
data={articleJsonLd({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: PATH,
|
||||
datePublished: '2026-07-08',
|
||||
authorName: 'Marco Sadjadi',
|
||||
wordCount: 1150,
|
||||
})}
|
||||
/>
|
||||
<JsonLd
|
||||
data={breadcrumbJsonLd([
|
||||
{ name: 'Home', path: '/' },
|
||||
{ name: 'Guides', path: '/guides' },
|
||||
{ name: TITLE, path: PATH },
|
||||
])}
|
||||
/>
|
||||
<ArticleShell
|
||||
title={TITLE}
|
||||
subtitle="Registry and generator solve different halves of the same problem. Smithery answers 'where do I find or publish an MCP server?' A generator answers 'who builds and hosts mine?' Picking the wrong one wastes an afternoon; here is how to tell them apart in two minutes."
|
||||
updated="July 2026"
|
||||
>
|
||||
<H2>What Smithery does well</H2>
|
||||
<P>
|
||||
Smithery is the closest thing MCP has to a package index. As of mid-2026 it lists{' '}
|
||||
<Strong>thousands of community-built servers</Strong> (their catalog crossed 6,000+ some
|
||||
time ago), searchable by category, installable via CLI, and — for servers published there
|
||||
— runnable as hosted remote endpoints with OAuth handled by the platform. Its Toolbox
|
||||
meta-server can even route an agent dynamically across registry servers so you don't
|
||||
wire each one by hand. Browsing and publishing are free; hosted execution and higher
|
||||
usage sit behind paid tiers.
|
||||
</P>
|
||||
<P>
|
||||
Two jobs it does better than anyone: <Strong>discovery</Strong> ("does a server for X
|
||||
already exist?") and <Strong>distribution</Strong> ("let people find and run the
|
||||
server I wrote"). If either is your actual need, stop reading and use Smithery.
|
||||
</P>
|
||||
|
||||
<H2>The assumption baked into a registry</H2>
|
||||
<P>
|
||||
Every path through Smithery starts from an existing server: one you found in the catalog,
|
||||
or one you wrote and published. The moment your need is a tool that{' '}
|
||||
<em>nobody has built</em> — a wrapper around your internal API, a scoped read-only view of
|
||||
your own database, a workflow specific to your team — the registry has nothing to list.
|
||||
You are back to the MCP SDK, TypeScript, container images and OAuth wiring before
|
||||
Smithery can help you host or distribute anything.
|
||||
</P>
|
||||
|
||||
<H2>The alternative: generate, then host</H2>
|
||||
<P>
|
||||
<Strong>BuildMyMCPServer</Strong> replaces the "write it first" step. Describe
|
||||
the tool in natural language; the platform generates the TypeScript MCP server, runs
|
||||
static checks, builds an isolated Docker container and deploys it behind a full OAuth 2.1
|
||||
authorization server (PKCE, Dynamic Client Registration, Resource Indicators) at a public
|
||||
Streamable HTTP URL. Claude Desktop, Cursor and ChatGPT connect with a copy-paste
|
||||
snippet. The full source stays exportable — if you later want to publish the server on
|
||||
Smithery or self-host it, you can take the code and go.
|
||||
</P>
|
||||
|
||||
<H2>Side by side</H2>
|
||||
<Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Smithery</th>
|
||||
<th>BuildMyMCPServer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Core job</Strong>
|
||||
</td>
|
||||
<td>Find, publish and run existing servers</td>
|
||||
<td>Create and host a server that doesn't exist yet</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Starting point</Strong>
|
||||
</td>
|
||||
<td>A server (yours or the catalog's)</td>
|
||||
<td>A sentence describing the tool</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Catalog size</Strong>
|
||||
</td>
|
||||
<td>Thousands of community servers</td>
|
||||
<td>Small first-party template gallery</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Hosting & auth</Strong>
|
||||
</td>
|
||||
<td>Hosted endpoints with OAuth for listed servers</td>
|
||||
<td>Every server deployed behind OAuth 2.1, isolated container</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Pricing shape</Strong>
|
||||
</td>
|
||||
<td>Free registry; paid hosting/usage tiers</td>
|
||||
<td>Free: 1 server, 100k calls/mo; Pro €49/mo</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Strong>Exit path</Strong>
|
||||
</td>
|
||||
<td>Your code was always yours</td>
|
||||
<td>Full TypeScript source export</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<Note>
|
||||
Smithery details are as of mid-2026 from public materials; check their site for current
|
||||
catalog size and tier pricing.
|
||||
</Note>
|
||||
|
||||
<H2>Decision rule</H2>
|
||||
<UL>
|
||||
<li>
|
||||
<Strong>The tool might already exist</Strong> → search Smithery first. Genuinely — five
|
||||
minutes there can save the whole build.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>You wrote a server and want users</Strong> → publish on Smithery; that is its
|
||||
home turf.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>The tool is bespoke to your company</Strong> → generate it. A registry cannot
|
||||
list what only you need.
|
||||
</li>
|
||||
<li>
|
||||
<Strong>Long-term:</Strong> the routes compose — generate the bespoke server, export
|
||||
the source, publish it wherever distribution helps.
|
||||
</li>
|
||||
</UL>
|
||||
|
||||
<H2>Honest caveats</H2>
|
||||
<P>
|
||||
We are the young product in this comparison: no SOC 2, a small template gallery next to a
|
||||
registry of thousands, and a generator that is bounded by what a prompt can specify —
|
||||
clear inputs, outputs and API calls. What we ask you to test is the part that matters
|
||||
when nothing in any catalog fits: prompt to hosted, OAuth-protected server in about a
|
||||
minute, on the{' '}
|
||||
<Link href="/pricing" className="text-[--color-accent] hover:underline">
|
||||
free tier
|
||||
</Link>
|
||||
, starting from scratch or from a{' '}
|
||||
<Link href="/templates" className="text-[--color-accent] hover:underline">
|
||||
template
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
|
||||
<P>
|
||||
Related:{' '}
|
||||
<Link
|
||||
href="/guides/mcp-server-hosting-pricing"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
MCP hosting pricing compared
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link
|
||||
href="/guides/hosted-mcp-platforms-compared"
|
||||
className="text-[--color-accent] hover:underline"
|
||||
>
|
||||
registries vs connectors vs infra vs generators
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link href="/guides/composio-alternative" className="text-[--color-accent] hover:underline">
|
||||
Composio alternative
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
</ArticleShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>© {new Date().getFullYear()} BuildMyMCPServer</div>
|
||||
<FooterColumn
|
||||
title="Product"
|
||||
links={[
|
||||
{ href: '/templates', label: 'Templates' },
|
||||
{ href: '/pricing', label: 'Pricing' },
|
||||
{ href: '/changelog', label: 'Changelog' },
|
||||
{ href: '/security', label: 'Security' },
|
||||
]}
|
||||
/>
|
||||
<FooterColumn
|
||||
title="Resources"
|
||||
links={[
|
||||
{ href: '/docs', label: 'Docs' },
|
||||
{ href: '/guides', label: 'Guides' },
|
||||
{ href: '/docs/faq', label: 'FAQ' },
|
||||
{ href: '/contact', label: 'Contact' },
|
||||
]}
|
||||
/>
|
||||
<FooterColumn
|
||||
title="Legal"
|
||||
links={[
|
||||
{ href: '/privacy', label: 'Privacy' },
|
||||
{ href: '/terms', label: 'Terms' },
|
||||
{ href: '/agb', label: 'AGB' },
|
||||
{ href: '/impressum', label: 'Impressum' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-10 border-t border-[--color-border] pt-6 text-[12px] text-[--color-fg-subtle]">
|
||||
© {new Date().getFullYear()} BuildMyMCPServer
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
54
apps/web/app/feed.xml/route.ts
Normal file
54
apps/web/app/feed.xml/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { articlesNewestFirst } from '@/lib/articles';
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from '@/lib/seo';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function GET(): Response {
|
||||
const articles = articlesNewestFirst();
|
||||
const lastBuildDate = new Date(
|
||||
articles[0]?.dateModified ?? articles[0]?.datePublished ?? '2026-05-31',
|
||||
).toUTCString();
|
||||
|
||||
const items = articles
|
||||
.map((a) => {
|
||||
const url = `${SITE_URL}/guides/${a.slug}`;
|
||||
return ` <item>
|
||||
<title>${escapeXml(a.title)}</title>
|
||||
<link>${url}</link>
|
||||
<guid isPermaLink="true">${url}</guid>
|
||||
<description>${escapeXml(a.description)}</description>
|
||||
<pubDate>${new Date(a.datePublished).toUTCString()}</pubDate>
|
||||
</item>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escapeXml(`${SITE_NAME} — MCP guides`)}</title>
|
||||
<link>${SITE_URL}/guides</link>
|
||||
<atom:link href="${SITE_URL}/feed.xml" rel="self" type="application/rss+xml"/>
|
||||
<description>${escapeXml(SITE_DESCRIPTION)}</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'content-type': 'application/rss+xml; charset=utf-8',
|
||||
'cache-control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
34
apps/web/app/templates/[slug]/collapsible-code.tsx
Normal file
34
apps/web/app/templates/[slug]/collapsible-code.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
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}`,
|
||||
});
|
||||
}, [params.slug]);
|
||||
|
||||
function useTemplate() {
|
||||
if (!template) return;
|
||||
router.push(`/servers/new?template=${template.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 server’s 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>
|
||||
|
||||
@@ -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'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'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} />;
|
||||
}
|
||||
|
||||
395
apps/web/app/templates/templates-browser.tsx
Normal file
395
apps/web/app/templates/templates-browser.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, Play, Volume2, VolumeX } from 'lucide-react';
|
||||
import { ExternalLink, Pause, Play, Volume2, VolumeX } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
@@ -15,30 +15,47 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
*
|
||||
* 2. Browser blocks autoplay but allows playback on user gesture —
|
||||
* the play overlay sits over the poster, the user clicks, we
|
||||
* call `.load()` first to reset the resource-selection state
|
||||
* (some Chrome builds park the element at networkState=2/
|
||||
* readyState=0 forever if the original autoplay was blocked
|
||||
* before the source ever fetched) and then `.play()` with the
|
||||
* user gesture in scope. Plays.
|
||||
* call `.load()` first to reset the resource-selection state and
|
||||
* then `.play()` with the user gesture in scope.
|
||||
*
|
||||
* 3. Browser refuses to play even after the gesture — extension-
|
||||
* sandboxed contexts, hardware-decoder failures, etc. We catch
|
||||
* the promise rejection, surface a small "open video in a new
|
||||
* tab" link so the visitor isn't completely stuck.
|
||||
* 3. Browser refuses to play even after the gesture — we catch the
|
||||
* promise rejection and surface a small "open video in a new tab"
|
||||
* link so the visitor isn't completely stuck.
|
||||
*
|
||||
* Controls: a deliberately subtle bottom bar (play/pause, elapsed time,
|
||||
* a seek slider, mute) that stays out of the way — it only fades in on
|
||||
* hover (desktop) or tap (touch) and auto-hides ~2.8s after the last
|
||||
* interaction while playing; while paused it stays put. The seek slider
|
||||
* is a real <input type=range> (keyboard + drag + touch, accessible)
|
||||
* laid invisibly over a custom-drawn track so the look matches the rest
|
||||
* of the page rather than the chrome of a native control bar.
|
||||
*
|
||||
* Source order: MP4 only. We previously offered WebM (VP9) first as a
|
||||
* size win, but Chrome will pick WebM if listed first, and if that
|
||||
* decode fails it does NOT fall back to MP4 — it just sits in an
|
||||
* unloaded state. Single MP4 source (Main profile / yuv420p / TV-
|
||||
* range / faststart) plays everywhere and the file is 2.6 MB which is
|
||||
* close enough to the WebM that the extra HTTP source-juggling isn't
|
||||
* worth the failure surface.
|
||||
* decode fails it does NOT fall back to MP4 — it just sits unloaded.
|
||||
*/
|
||||
|
||||
const CONTROLS_HIDE_MS = 2800;
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [controlsVisible, setControlsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
@@ -48,22 +65,70 @@ export function HeroVideo() {
|
||||
setPlayFailed(false);
|
||||
};
|
||||
const onPause = () => setPlaying(false);
|
||||
const onTime = () => setCurrentTime(v.currentTime);
|
||||
const onMeta = () => setDuration(Number.isFinite(v.duration) ? v.duration : 0);
|
||||
v.addEventListener('play', onPlay);
|
||||
v.addEventListener('pause', onPause);
|
||||
// Best-effort autoplay attempt — silently fail on browsers that
|
||||
// block it; the overlay is the user's escape hatch.
|
||||
v.play().catch(() => undefined);
|
||||
v.addEventListener('timeupdate', onTime);
|
||||
v.addEventListener('loadedmetadata', onMeta);
|
||||
v.addEventListener('durationchange', onMeta);
|
||||
// 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);
|
||||
v.removeEventListener('loadedmetadata', onMeta);
|
||||
v.removeEventListener('durationchange', onMeta);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clear any pending hide timer on unmount.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => setControlsVisible(false), CONTROLS_HIDE_MS);
|
||||
}, []);
|
||||
|
||||
const revealControls = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
scheduleHide();
|
||||
}, [scheduleHide]);
|
||||
|
||||
const hideControlsNow = useCallback(() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
setControlsVisible(false);
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
const v = videoRef.current;
|
||||
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
|
||||
@@ -74,10 +139,19 @@ export function HeroVideo() {
|
||||
setPlayFailed(true);
|
||||
}
|
||||
} else {
|
||||
userPausedRef.current = true;
|
||||
v.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click anywhere on the frame toggles playback (YouTube/Vimeo pattern) and
|
||||
// reveals the controls — on touch, where there's no hover, this is how the
|
||||
// bar surfaces.
|
||||
const onVideoClick = useCallback(() => {
|
||||
revealControls();
|
||||
void togglePlay();
|
||||
}, [revealControls, togglePlay]);
|
||||
|
||||
const toggleMute = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const v = videoRef.current;
|
||||
@@ -85,27 +159,42 @@ export function HeroVideo() {
|
||||
const next = !v.muted;
|
||||
v.muted = next;
|
||||
setMuted(next);
|
||||
// Restart from frame 0 whenever the audio state toggles. Visitors who
|
||||
// unmute want to hear the opening, not whatever moment the video was
|
||||
// already at; visitors who re-mute also expect a clean restart so the
|
||||
// animation lines up with the silent loop again.
|
||||
// Restart from frame 0 whenever the audio state toggles, so the narration
|
||||
// and the silent loop both line up with the animation from the top.
|
||||
v.currentTime = 0;
|
||||
if (v.paused) {
|
||||
v.play().catch(() => undefined);
|
||||
}
|
||||
if (v.paused) v.play().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const onSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const t = Number(e.currentTarget.value);
|
||||
if (!Number.isFinite(t)) return;
|
||||
v.currentTime = t;
|
||||
setCurrentTime(t);
|
||||
}, []);
|
||||
|
||||
const pct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||
// Bar shows on hover/tap; while paused it stays up so the scrubber is reachable.
|
||||
const barShown = controlsVisible || !playing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
onPointerEnter={revealControls}
|
||||
onPointerMove={revealControls}
|
||||
onPointerLeave={() => {
|
||||
if (playing) hideControlsNow();
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
poster="/videos/hero-poster.jpg"
|
||||
onClick={togglePlay}
|
||||
onClick={onVideoClick}
|
||||
className="size-full cursor-pointer object-cover"
|
||||
aria-label="Animation: a prompt becomes a live MCP server, with secrets staying isolated from the AI pipeline"
|
||||
>
|
||||
@@ -114,26 +203,23 @@ export function HeroVideo() {
|
||||
</video>
|
||||
|
||||
{/* PLAY overlay — visible while paused. Full-frame so clicking
|
||||
anywhere starts playback (YouTube / Vimeo pattern). */}
|
||||
anywhere starts playback. */}
|
||||
{!playing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
onClick={onVideoClick}
|
||||
aria-label="Play video"
|
||||
className="group absolute inset-0 z-10 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'color-mix(in oklab, var(--color-bg) 30%, transparent)',
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg) 30%, transparent)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex size-20 items-center justify-center rounded-full border backdrop-blur transition-transform duration-200 ease-out group-hover:scale-110"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'color-mix(in oklab, var(--color-bg-elevated) 80%, transparent)',
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 80%, transparent)',
|
||||
borderColor: 'var(--color-accent)',
|
||||
boxShadow:
|
||||
'0 0 32px rgba(99, 102, 241, 0.45), 0 12px 40px rgba(0,0,0,0.55)',
|
||||
boxShadow: '0 0 32px rgba(99, 102, 241, 0.45), 0 12px 40px rgba(0,0,0,0.55)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
@@ -143,10 +229,8 @@ export function HeroVideo() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fallback escape hatch — surfaces only if .play() rejects even
|
||||
after a user gesture (extension sandbox, hardware-decoder
|
||||
failures, etc.). One-line link to the raw MP4 so the visitor
|
||||
isn't trapped staring at a poster forever. */}
|
||||
{/* Fallback escape hatch — surfaces only if .play() rejects even after a
|
||||
user gesture (extension sandbox, hardware-decoder failures, etc.). */}
|
||||
{playFailed && !playing && (
|
||||
<a
|
||||
href="/videos/hero.mp4"
|
||||
@@ -154,8 +238,7 @@ export function HeroVideo() {
|
||||
rel="noopener noreferrer"
|
||||
className="absolute inset-x-0 bottom-20 z-20 mx-auto flex w-fit items-center gap-2 rounded-md border px-3 py-2 text-[12px] backdrop-blur hover:text-[--color-fg]"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'color-mix(in oklab, var(--color-bg-elevated) 85%, transparent)',
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 85%, transparent)',
|
||||
borderColor: 'var(--color-border-strong)',
|
||||
color: 'var(--color-fg-muted)',
|
||||
}}
|
||||
@@ -165,22 +248,93 @@ export function HeroVideo() {
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Mute toggle — always visible, top of z-stack. */}
|
||||
{/* Controls bar — subtle, reveals on hover/tap, auto-hides while playing.
|
||||
pointer-events-none when hidden so it never swallows a frame click. */}
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 z-30 px-4 pb-3 pt-10 transition-[opacity,transform] duration-200 ease-out ${
|
||||
barShown ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, color-mix(in oklab, var(--color-bg) 72%, transparent), transparent)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
revealControls();
|
||||
void togglePlay();
|
||||
}}
|
||||
aria-label={playing ? 'Pause video' : 'Play video'}
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-fg-muted)',
|
||||
}}
|
||||
>
|
||||
{playing ? (
|
||||
<Pause size={16} fill="currentColor" />
|
||||
) : (
|
||||
<Play size={16} fill="currentColor" className="translate-x-px" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="mono shrink-0 text-[11px] tabular-nums"
|
||||
style={{ color: 'var(--color-fg-muted)' }}
|
||||
>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
{/* Seek: custom-drawn rail + fill + thumb, with a transparent native
|
||||
range on top carrying all the interaction (drag, touch, keyboard). */}
|
||||
<div className="group/seek relative flex h-4 flex-1 items-center">
|
||||
<div
|
||||
className="h-1 w-full overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: 'color-mix(in oklab, var(--color-fg) 22%, transparent)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${pct}%`, backgroundColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute size-3 -translate-x-1/2 rounded-full opacity-0 shadow transition-opacity duration-150 group-hover/seek:opacity-100 group-focus-within/seek:opacity-100"
|
||||
style={{ left: `${pct}%`, backgroundColor: 'var(--color-accent)' }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step="any"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={onSeek}
|
||||
onFocus={revealControls}
|
||||
aria-label="Seek video"
|
||||
className="absolute inset-0 size-full cursor-pointer appearance-none bg-transparent opacity-0 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
aria-label={muted ? 'Unmute video' : 'Mute video'}
|
||||
aria-pressed={!muted}
|
||||
className="absolute bottom-4 right-4 z-30 inline-flex size-10 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: muted ? 'var(--color-fg-muted)' : 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
{muted ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
{muted ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 don’t have access to that.',
|
||||
not_found: 'We couldn’t find that.',
|
||||
org_not_found: 'We couldn’t find your workspace.',
|
||||
user_not_found: 'Account not found.',
|
||||
invalid_input: 'Some details weren’t valid — please check and try again.',
|
||||
invalid_id: 'That link looks invalid.',
|
||||
invalid_query: 'That request wasn’t valid.',
|
||||
rate_limited: 'You’ve hit a rate limit — wait a moment and try again.',
|
||||
plan_limit_reached: 'You’ve reached your plan’s 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 aren’t configured yet.',
|
||||
no_active_subscription: 'No active subscription found.',
|
||||
checkout_failed: 'Checkout couldn’t start — please try again in a moment.',
|
||||
google_oauth_not_configured: 'Google sign-in isn’t available right now.',
|
||||
taken_down: 'This template was taken down and can’t be used.',
|
||||
};
|
||||
|
||||
function statusFallback(status: number): string {
|
||||
if (status === 400) return 'That request wasn’t 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 don’t have access to that.';
|
||||
if (status === 404) return 'We couldn’t 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
141
apps/web/lib/articles.ts
Normal 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
118
apps/web/lib/og-article.tsx
Normal 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
91
apps/web/lib/pricing.ts
Normal 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 · ~10–20s 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',
|
||||
},
|
||||
];
|
||||
@@ -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.
|
||||
|
||||
114
apps/web/lib/templates-server.ts
Normal file
114
apps/web/lib/templates-server.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bmm/types": "workspace:*",
|
||||
"@stripe/react-stripe-js": "^6.4.0",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
"clsx": "2.1.1",
|
||||
"framer-motion": "11.18.2",
|
||||
"geist": "1.3.1",
|
||||
|
||||
91
apps/web/public/llms-full.txt
Normal file
91
apps/web/public/llms-full.txt
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +54,36 @@ services:
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
# Restricted Docker API gateway for the control plane. The API only needs to
|
||||
# stop/remove generated containers (`docker rm -f`), so it talks to this proxy
|
||||
# — which exposes ONLY the containers endpoints + write methods — instead of
|
||||
# mounting the raw root-equivalent /var/run/docker.sock. A compromised API can
|
||||
# no longer build images, create privileged containers, exec, or mount host
|
||||
# paths. (INF-003) NOTE: the generator still mounts the raw socket because it
|
||||
# legitimately builds+runs containers (an inherently privileged operation);
|
||||
# that residual is tracked in the audit backlog (rootless buildkit / build VM).
|
||||
docker-socket-proxy:
|
||||
image: tecnativa/docker-socket-proxy:0.2.0
|
||||
container_name: bmm-docker-proxy
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONTAINERS: 1
|
||||
POST: 1
|
||||
# everything else stays at the image default (0 = blocked)
|
||||
IMAGES: 0
|
||||
BUILD: 0
|
||||
NETWORKS: 0
|
||||
VOLUMES: 0
|
||||
EXEC: 0
|
||||
INFO: 0
|
||||
AUTH: 0
|
||||
SECRETS: 0
|
||||
SWARM: 0
|
||||
SYSTEM: 0
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks: [bmm-network]
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
@@ -61,10 +91,13 @@ services:
|
||||
container_name: bmm-api
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
environment:
|
||||
# Route docker CLI calls through the restricted proxy instead of a raw
|
||||
# socket mount. (INF-003)
|
||||
DOCKER_HOST: tcp://docker-socket-proxy:2375
|
||||
ports:
|
||||
- "127.0.0.1:${API_PORT:-4000}:4000"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- bmm_keys:/app/apps/api/keys
|
||||
# Per-runner nginx snippets — written by the generator, deleted by the
|
||||
# api when a server is removed. The host-side systemd watcher combines
|
||||
@@ -79,6 +112,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
docker-socket-proxy:
|
||||
condition: service_started
|
||||
|
||||
web:
|
||||
build:
|
||||
@@ -86,6 +121,9 @@ services:
|
||||
dockerfile: apps/web/Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production}
|
||||
# Publishable (not secret) — baked into the client bundle for embedded
|
||||
# checkout. Default empty so the build never fails when it's unset.
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}
|
||||
container_name: bmm-web
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
@@ -109,6 +147,12 @@ services:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-bmm}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5440}/${POSTGRES_DB:-bmm}
|
||||
REDIS_URL: redis://127.0.0.1:${REDIS_PORT:-6390}
|
||||
volumes:
|
||||
# SECURITY (INF-003): the generator mounts the RAW docker socket because it
|
||||
# builds images and runs containers — inherently root-equivalent on this
|
||||
# host, and a socket-proxy can't filter that (container-create with host
|
||||
# binds is the dangerous primitive it legitimately needs). It is NOT
|
||||
# internet-facing (driven only by the Redis build queue). Real remediation
|
||||
# = rootless buildkit or a dedicated build VM; tracked in the audit backlog.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- bmm_build_context:/app/build-context
|
||||
# Same runner-map mount as the api — generator drops the snippet on
|
||||
|
||||
@@ -21,6 +21,14 @@ server {
|
||||
|
||||
client_max_body_size 12M;
|
||||
|
||||
# Security headers (INF-002). Cloudflare sits in front — if it also injects
|
||||
# HSTS, drop that line here to avoid duplication. CSP intentionally omitted
|
||||
# for now (a wrong policy breaks Next/Tailwind inline) — track separately.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4001;
|
||||
proxy_http_version 1.1;
|
||||
@@ -48,6 +56,13 @@ server {
|
||||
|
||||
client_max_body_size 12M;
|
||||
|
||||
# Security headers (INF-002). nosniff matters for the JSON API; XFO/HSTS are
|
||||
# belt-and-suspenders for any HTML the API might ever serve.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade
|
||||
# headers and a long read timeout; buffering off so frames are not held.
|
||||
location /v1/builds/ {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
eq,
|
||||
gt,
|
||||
isNull,
|
||||
lt,
|
||||
sql,
|
||||
magicLinks,
|
||||
memberships,
|
||||
organizations,
|
||||
@@ -272,12 +274,18 @@ export async function consumeSmsCode(
|
||||
.orderBy(desc(smsCodes.createdAt))
|
||||
.limit(1);
|
||||
if (!row || row.consumedAt) throw new Error('invalid_or_expired_code');
|
||||
if (row.attempts >= SMS_MAX_ATTEMPTS) throw new Error('too_many_attempts');
|
||||
if (sha256(`${phone}:${code}`) !== row.codeHash) {
|
||||
await db
|
||||
// Atomically claim one guess attempt. The increment is gated on
|
||||
// `attempts < MAX` inside the same UPDATE, so the DB row-lock serialises
|
||||
// concurrent verifies and at most MAX increments ever succeed for a code.
|
||||
// The previous read-then-write (`row.attempts + 1`) let N parallel requests
|
||||
// all pass a stale `attempts` read and brute-force the 6-digit code. (AUTH-001)
|
||||
const slot = await db
|
||||
.update(smsCodes)
|
||||
.set({ attempts: row.attempts + 1 })
|
||||
.where(eq(smsCodes.id, row.id));
|
||||
.set({ attempts: sql`${smsCodes.attempts} + 1` })
|
||||
.where(and(eq(smsCodes.id, row.id), lt(smsCodes.attempts, SMS_MAX_ATTEMPTS)))
|
||||
.returning({ attempts: smsCodes.attempts });
|
||||
if (slot.length === 0) throw new Error('too_many_attempts');
|
||||
if (sha256(`${phone}:${code}`) !== row.codeHash) {
|
||||
throw new Error('invalid_code');
|
||||
}
|
||||
await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id));
|
||||
@@ -344,10 +352,16 @@ export async function getSession(
|
||||
.where(eq(sessions.tokenHash, hash))
|
||||
.limit(1);
|
||||
if (!row || row.expiresAt < new Date()) return null;
|
||||
// Deterministic primary-org selection — must match the login flows
|
||||
// (consumeMagicLink / loginWithPassword order by oldest membership). Without
|
||||
// this orderBy, a user with >1 membership (once org-invites land) would get a
|
||||
// nondeterministic org per request, silently scoping their reads/writes to a
|
||||
// different tenant than they logged in as. (SRV-001)
|
||||
const [membership] = await db
|
||||
.select({ orgId: memberships.orgId, role: memberships.role })
|
||||
.from(memberships)
|
||||
.where(eq(memberships.userId, row.userId))
|
||||
.orderBy(memberships.createdAt)
|
||||
.limit(1);
|
||||
if (!membership) return null;
|
||||
return {
|
||||
|
||||
@@ -68,6 +68,33 @@ export const PromptSpec = z.object({
|
||||
});
|
||||
export type PromptSpec = z.infer<typeof PromptSpec>;
|
||||
|
||||
// Dependency specifiers come from untrusted LLM output and are merged into the
|
||||
// build's package.json, then `npm install`-ed inside `docker build` on the
|
||||
// SHARED host. Restrict to npm-registry semver ranges only: no `git+`, `http(s):`,
|
||||
// `file:` or tarball-URL specifiers (which fetch/checkout+run arbitrary code at
|
||||
// install time), and valid (optionally-scoped) npm package names. Combined with
|
||||
// `--ignore-scripts` in the runner Dockerfile this closes the build-time RCE. (GEN-001)
|
||||
const DepName = z
|
||||
.string()
|
||||
.max(214)
|
||||
.regex(/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i, 'invalid npm package name');
|
||||
const DepRange = z
|
||||
.string()
|
||||
.max(64)
|
||||
.regex(/^([\^~]?\d+(\.\d+){0,2}(-[\w.]+)?|\*|latest)$/, 'must be a plain semver range');
|
||||
export const DependencyMap = z.record(DepName, DepRange);
|
||||
|
||||
// Secret KEYS become `-e KEY=VALUE` docker run args and the runtime env of the
|
||||
// tenant container. Constrain to UPPER_SNAKE_CASE (matches requiredSecrets) and
|
||||
// reject names that could hijack the Node runtime/loader if the container
|
||||
// hardening ever regressed. Values stay free-form. (GEN-003)
|
||||
const RESERVED_ENV = new Set(['PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_ENV']);
|
||||
const SecretKey = z
|
||||
.string()
|
||||
.regex(/^[A-Z][A-Z0-9_]*$/, 'UPPER_SNAKE_CASE env var name required')
|
||||
.refine((k) => !RESERVED_ENV.has(k) && !k.startsWith('NODE_'), 'reserved env var name');
|
||||
export const SecretMap = z.record(SecretKey, z.string());
|
||||
|
||||
export const GeneratorSpec = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
description: z.string().max(2000).optional(),
|
||||
@@ -76,7 +103,7 @@ export const GeneratorSpec = z.object({
|
||||
prompts: z.array(PromptSpec).max(50).default([]),
|
||||
requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]),
|
||||
scopes: z.array(z.string()).max(50).default([]),
|
||||
dependencies: z.record(z.string(), z.string()).default({}),
|
||||
dependencies: DependencyMap.default({}),
|
||||
});
|
||||
export type GeneratorSpec = z.infer<typeof GeneratorSpec>;
|
||||
|
||||
@@ -136,7 +163,7 @@ export const CreateServerInput = z.object({
|
||||
.max(64)
|
||||
.regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'),
|
||||
prompt: z.string().min(10).max(8000),
|
||||
secrets: z.record(z.string(), z.string()).default({}),
|
||||
secrets: SecretMap.default({}),
|
||||
previewId: z.string().min(1).max(64).optional(),
|
||||
specEdit: SpecEdit.optional(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
@@ -169,7 +196,7 @@ export type PreviewResult = z.infer<typeof PreviewResult>;
|
||||
|
||||
export const IterateServerInput = z.object({
|
||||
prompt: z.string().min(10).max(8000),
|
||||
secrets: z.record(z.string(), z.string()).default({}),
|
||||
secrets: SecretMap.default({}),
|
||||
});
|
||||
export type IterateServerInput = z.infer<typeof IterateServerInput>;
|
||||
|
||||
@@ -185,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;
|
||||
}
|
||||
|
||||
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@@ -137,6 +137,12 @@ importers:
|
||||
'@bmm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
'@stripe/react-stripe-js':
|
||||
specifier: ^6.4.0
|
||||
version: 6.4.0(@stripe/stripe-js@9.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@stripe/stripe-js':
|
||||
specifier: ^9.7.0
|
||||
version: 9.7.0
|
||||
clsx:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
@@ -1244,6 +1250,17 @@ packages:
|
||||
'@remotion/studio@4.0.220':
|
||||
resolution: {integrity: sha512-97sb8ta+4cRmsntAWPgyNkAGo3i9/q/j+jLBvhbUF0njC+rFs3QVb/qx27mEI8/Avw3har7Nu7BR62eH8TNfcg==}
|
||||
|
||||
'@stripe/react-stripe-js@6.4.0':
|
||||
resolution: {integrity: sha512-5cAf7GAqf8VHPoPLVnJQ2MHtDWLdiEPs5GW/loU7iDEue3xf620v11skrk5HICUraDOhODPYpttgFC5N579NxA==}
|
||||
peerDependencies:
|
||||
'@stripe/stripe-js': '>=9.5.0 <10.0.0'
|
||||
react: '>=16.8.0 <20.0.0'
|
||||
react-dom: '>=16.8.0 <20.0.0'
|
||||
|
||||
'@stripe/stripe-js@9.7.0':
|
||||
resolution: {integrity: sha512-r1ElolvWXM4aYnZZVHvKW3EDL8JcwEuIgTuWxlB5lvC+YsvjkQ0gX35x9d8dTDubX395fViLVqkaolVs1PmIQQ==}
|
||||
engines: {node: '>=12.16'}
|
||||
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
@@ -2103,6 +2120,9 @@ packages:
|
||||
jose@6.2.3:
|
||||
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
@@ -2217,6 +2237,10 @@ packages:
|
||||
lodash.sortby@4.7.0:
|
||||
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lru-cache@6.0.0:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2471,6 +2495,9 @@ packages:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -2502,6 +2529,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.0.0
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-refresh@0.9.0:
|
||||
resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3703,6 +3733,15 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@stripe/react-stripe-js@6.4.0(@stripe/stripe-js@9.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@stripe/stripe-js': 9.7.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
'@stripe/stripe-js@9.7.0': {}
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
@@ -4607,6 +4646,8 @@ snapshots:
|
||||
|
||||
jose@6.2.3: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
@@ -4692,6 +4733,10 @@ snapshots:
|
||||
|
||||
lodash.sortby@4.7.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lru-cache@6.0.0:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
@@ -4919,6 +4964,12 @@ snapshots:
|
||||
kleur: 3.0.3
|
||||
sisteransi: 1.0.5
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@@ -4951,6 +5002,8 @@ snapshots:
|
||||
react: 19.0.0
|
||||
scheduler: 0.25.0
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-refresh@0.9.0: {}
|
||||
|
||||
react@19.0.0: {}
|
||||
|
||||
275
scripts/seed-templates.mjs
Normal file
275
scripts/seed-templates.mjs
Normal 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 yesterday’s 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);
|
||||
});
|
||||
Reference in New Issue
Block a user