Compare commits

...

2 Commits

Author SHA1 Message Date
Marco Sadjadi
4687c8be52 @
All checks were successful
Deploy to Production / deploy (push) Successful in 1m25s
fix(billing): correct Stripe API version + harden checkout; clarify wizard secrets

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

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

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

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

View File

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

View File

@ -480,7 +480,11 @@ function NewServerPageInner() {
/> />
<p className="text-[12px] leading-relaxed text-[--color-fg-subtle]"> <p className="text-[12px] leading-relaxed text-[--color-fg-subtle]">
Next step we&apos;ll show you exactly which tools we&apos;ll expose and let you tweak Next step we&apos;ll show you exactly which tools we&apos;ll expose and let you tweak
the spec before we build. the spec before we build.{' '}
<span className="text-[--color-fg-muted]">
Don&apos;t paste API keys or access tokens here you&apos;ll add each one in its own
encrypted field in the next step.
</span>
</p> </p>
<div className="flex flex-wrap gap-1.5 pt-1"> <div className="flex flex-wrap gap-1.5 pt-1">
{EXAMPLE_PROMPTS.map((p) => ( {EXAMPLE_PROMPTS.map((p) => (
@ -675,15 +679,18 @@ function NewServerPageInner() {
</div> </div>
<div> <div>
<h3 className="text-[13px] font-semibold tracking-tight">Credentials we need</h3> <h3 className="text-[13px] font-semibold tracking-tight">API keys &amp; credentials</h3>
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]"> <p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your One field per key or access token entered here, separately from your prompt.
implementation doesn&apos;t actually use one. AES-256-GCM encrypted at rest, injected as env vars at runtime only. Remove any your
implementation doesn&apos;t use; add any we missed.
</p> </p>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{editable.requiredSecrets.length === 0 && ( {editable.requiredSecrets.length === 0 && (
<p className="text-[12.5px] text-[--color-fg-muted]"> <p className="text-[12.5px] text-[--color-fg-muted]">
No credentials. This server runs self-contained. None detected. If your tool calls an API that needs a key or access token, add it
below with <span className="mono">+ Add credential</span> never put secrets in
the prompt.
</p> </p>
)} )}
{editable.requiredSecrets.map((key, idx) => ( {editable.requiredSecrets.map((key, idx) => (

View File

@ -0,0 +1,73 @@
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>;
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,64 @@
import { 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',
});
const GUIDES = [
{
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',
},
{
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',
},
{
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',
},
];
export default function GuidesIndex() {
return (
<div className="mx-auto max-w-3xl px-6 py-14">
<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]"
>
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
{g.tag}
</span>
<h2 className="mt-1 text-[16px] font-semibold tracking-tight text-[--color-fg]">
{g.title}
</h2>
<p className="mt-1.5 text-[13px] leading-relaxed text-[--color-fg-muted]">
{g.description}
</p>
</Link>
))}
</div>
</div>
);
}

View File

@ -24,6 +24,9 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
<Link href="/docs" className="transition-colors hover:text-[--color-fg]"> <Link href="/docs" className="transition-colors hover:text-[--color-fg]">
Docs Docs
</Link> </Link>
<Link href="/guides" className="transition-colors hover:text-[--color-fg]">
Guides
</Link>
<Link href="/changelog" className="transition-colors hover:text-[--color-fg]"> <Link href="/changelog" className="transition-colors hover:text-[--color-fg]">
Changelog Changelog
</Link> </Link>

View File

@ -1,4 +1,5 @@
import { SITE_URL } from '@/lib/seo'; import { SITE_URL } from '@/lib/seo';
import { fetchPublicTemplateSlugs } from '@/lib/templates-server';
import type { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
type Entry = { type Entry = {
@ -11,6 +12,10 @@ const ROUTES: Entry[] = [
{ path: '/', priority: 1.0, changeFrequency: 'weekly' }, { path: '/', priority: 1.0, changeFrequency: 'weekly' },
{ path: '/pricing', priority: 0.9, changeFrequency: 'weekly' }, { path: '/pricing', priority: 0.9, changeFrequency: 'weekly' },
{ path: '/templates', priority: 0.9, changeFrequency: 'daily' }, { path: '/templates', priority: 0.9, changeFrequency: 'daily' },
{ path: '/guides', priority: 0.8, changeFrequency: 'weekly' },
{ path: '/guides/host-mcp-server-with-oauth', priority: 0.8, changeFrequency: 'monthly' },
{ path: '/guides/hosted-mcp-platforms-compared', priority: 0.8, changeFrequency: 'monthly' },
{ path: '/guides/mintmcp-alternative', priority: 0.7, changeFrequency: 'monthly' },
{ path: '/docs', priority: 0.8, changeFrequency: 'weekly' }, { path: '/docs', priority: 0.8, changeFrequency: 'weekly' },
{ path: '/docs/concepts', priority: 0.7, changeFrequency: 'monthly' }, { path: '/docs/concepts', priority: 0.7, changeFrequency: 'monthly' },
{ path: '/docs/oauth', priority: 0.7, changeFrequency: 'monthly' }, { path: '/docs/oauth', priority: 0.7, changeFrequency: 'monthly' },
@ -25,12 +30,24 @@ const ROUTES: Entry[] = [
{ path: '/terms', priority: 0.3, changeFrequency: 'yearly' }, { path: '/terms', priority: 0.3, changeFrequency: 'yearly' },
]; ];
export default function sitemap(): MetadataRoute.Sitemap { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const now = new Date(); const now = new Date();
return ROUTES.map((r) => ({ const staticEntries: MetadataRoute.Sitemap = ROUTES.map((r) => ({
url: `${SITE_URL}${r.path}`, url: `${SITE_URL}${r.path}`,
lastModified: now, lastModified: now,
changeFrequency: r.changeFrequency, changeFrequency: r.changeFrequency,
priority: r.priority, priority: r.priority,
})); }));
// 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: now,
changeFrequency: 'weekly',
priority: 0.6,
}));
return [...staticEntries, ...templateEntries];
} }

View File

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

View File

@ -1,95 +1,71 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ShieldCheck, GitFork, Activity, ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { CodeBlock } from '@/components/code-block'; import { 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 { // Server-rendered for SEO: per-template <title>, description, OpenGraph and
name: string; // SoftwareApplication JSON-LD. The only interactive piece (the code-audit
description: string; // toggle) lives in a client island.
inputSchema: Record<string, unknown>;
interface PageProps {
params: Promise<{ slug: string }>;
} }
interface SecretHint { export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
key: string; const { slug } = await params;
description: string; const t = await fetchTemplate(slug);
howToGetUrl?: string; if (!t) {
} return pageMetadata({
title: 'Template not found',
interface TemplateDetail { description: 'This MCP server template is not available.',
id: string; path: `/templates/${slug}`,
slug: string; });
title: string; }
shortDescription: string; return pageMetadata({
longDescription: string | null; title: `${t.title} — MCP server for Claude, Cursor & ChatGPT`,
category: string; description:
status: 'draft' | 'public' | 'hidden' | 'takedown'; t.shortDescription.length > 0
verified: boolean; ? t.shortDescription
forkCount: number; : `Fork the ${t.title} MCP server and deploy your own OAuth-protected copy in seconds.`,
activeDeployments: number; path: `/templates/${slug}`,
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);
}); });
}, [params.slug]);
function useTemplate() {
if (!template) return;
router.push(`/servers/new?template=${template.slug}`);
} }
if (error) { export default async function TemplateDetailPage({ params }: PageProps) {
return ( const { slug } = await params;
<div className="flex min-h-screen items-center justify-center px-6"> const template: TemplateDetail | null = await fetchTemplate(slug);
<div className="text-center"> if (!template) notFound();
<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>
);
}
if (!template) { const forkHref = `/servers/new?template=${template.slug}`;
return (
<div className="px-8 py-20 text-center mono text-[12px] text-[--color-fg-muted]">Loading</div>
);
}
return ( return (
<div className="flex min-h-screen flex-col"> <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"> <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="mx-auto flex h-12 max-w-5xl items-center justify-between px-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Logo /> <Logo />
<span className="text-[12.5px] text-[--color-fg-subtle]"> <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> </span>
</div> </div>
<Link <Link
@ -130,17 +106,20 @@ export default function TemplateDetail() {
Tools ({template.toolsSchema.length}) Tools ({template.toolsSchema.length})
</h2> </h2>
<div className="mt-3 space-y-3"> <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 key={tool.name} className="panel p-3">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="mono text-[13px] font-semibold">{tool.name}</span> <span className="mono text-[13px] font-semibold">{tool.name}</span>
<span className="mono text-[10.5px] text-[--color-fg-subtle]"> <span className="mono text-[10.5px] text-[--color-fg-subtle]">
{Object.keys(tool.inputSchema ?? {}).length} param {paramCount} param{paramCount === 1 ? '' : 's'}
{Object.keys(tool.inputSchema ?? {}).length === 1 ? '' : 's'}
</span> </span>
</div> </div>
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p> <p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">
{Object.keys(tool.inputSchema ?? {}).length > 0 && ( {tool.description}
</p>
{paramCount > 0 && (
<div className="mt-2"> <div className="mt-2">
<CodeBlock <CodeBlock
label="input schema" label="input schema"
@ -149,7 +128,8 @@ export default function TemplateDetail() {
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
</section> </section>
@ -178,62 +158,34 @@ export default function TemplateDetail() {
</a> </a>
)} )}
</div> </div>
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]"> <p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">{s.description}</p>
{s.description}
</p>
</div> </div>
))} ))}
</div> </div>
</section> </section>
)} )}
<section className="mt-10"> <CollapsibleCode code={template.generatedCode} />
<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>
</div> </div>
<aside className="space-y-3"> <aside className="space-y-3">
<div className="panel p-4"> <div className="panel p-4">
{template.status === 'public' ? ( {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 Fork this template
</Button> </Link>
<p className="mt-2 text-[11.5px] text-[--color-fg-muted]"> <p className="mt-2 text-[11.5px] text-[--color-fg-muted]">
One click your own isolated container. One click your own isolated container.
</p> </p>
</> </>
) : ( ) : (
<>
<div className="rounded-md border border-amber-400/30 bg-amber-400/5 p-2.5 text-[12px] text-amber-200/90"> <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 This template is <span className="mono">{template.status}</span> not forkable.
forkable. {template.sourceServerId ? 'Re-share it from the servers Publish tab to allow forks.' : ''}
</div> </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> </div>
@ -245,17 +197,17 @@ export default function TemplateDetail() {
icon={<Activity size={11} />} icon={<Activity size={11} />}
/> />
<Row label="Category" value={template.category} mono /> <Row label="Category" value={template.category} mono />
<Row label="Published" value={new Date(template.createdAt).toLocaleDateString()} />
<Row <Row
label="Published" label="Author"
value={new Date(template.createdAt).toLocaleDateString()} value={template.ownerName ?? template.ownerOrgName ?? 'anonymous'}
/> />
<Row label="Author" value={template.ownerName ?? template.ownerOrgName ?? 'anonymous'} />
</div> </div>
<div className="panel p-3 text-[11.5px] leading-relaxed text-[--color-fg-muted]"> <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 <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 Docker container, its own port, its own AES-256-encrypted secrets. The template author
author has no visibility into your traffic or data. has no visibility into your traffic or data.
</div> </div>
</aside> </aside>
</div> </div>

View File

@ -184,6 +184,58 @@ 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;
}): object {
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}` },
datePublished: opts.datePublished,
dateModified: opts.dateModified ?? opts.datePublished,
inLanguage: 'en',
author: { '@id': `${SITE_URL}/#organization` },
publisher: { '@id': `${SITE_URL}/#organization` },
};
}
/** 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 * Per-page metadata. `title` is a bare string so the root layout's
* "%s | BuildMyMCPServer" template appends the brand exactly once. * "%s | BuildMyMCPServer" template appends the brand exactly once.

View File

@ -0,0 +1,76 @@
// 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;
}
}
/** 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 [];
}
}