From 1349dc1dc05f2eb94364f30650972951eeee44fc Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Sun, 31 May 2026 12:08:05 +0200 Subject: [PATCH] =?UTF-8?q?@=20feat(web):=20SEO=20=E2=80=94=20server-rende?= =?UTF-8?q?red=20template=20pages=20+=20/guides=20articles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) @ --- .../app/(marketing)/guides/article-shell.tsx | 73 ++++++ .../host-mcp-server-with-oauth/page.tsx | 116 +++++++++ .../hosted-mcp-platforms-compared/page.tsx | 110 +++++++++ .../guides/mintmcp-alternative/page.tsx | 96 ++++++++ apps/web/app/(marketing)/guides/page.tsx | 64 +++++ apps/web/app/(marketing)/layout.tsx | 3 + apps/web/app/sitemap.ts | 21 +- .../app/templates/[slug]/collapsible-code.tsx | 34 +++ apps/web/app/templates/[slug]/page.tsx | 230 +++++++----------- apps/web/lib/seo.ts | 52 ++++ apps/web/lib/templates-server.ts | 76 ++++++ 11 files changed, 734 insertions(+), 141 deletions(-) create mode 100644 apps/web/app/(marketing)/guides/article-shell.tsx create mode 100644 apps/web/app/(marketing)/guides/host-mcp-server-with-oauth/page.tsx create mode 100644 apps/web/app/(marketing)/guides/hosted-mcp-platforms-compared/page.tsx create mode 100644 apps/web/app/(marketing)/guides/mintmcp-alternative/page.tsx create mode 100644 apps/web/app/(marketing)/guides/page.tsx create mode 100644 apps/web/app/templates/[slug]/collapsible-code.tsx create mode 100644 apps/web/lib/templates-server.ts diff --git a/apps/web/app/(marketing)/guides/article-shell.tsx b/apps/web/app/(marketing)/guides/article-shell.tsx new file mode 100644 index 0000000..e4e5823 --- /dev/null +++ b/apps/web/app/(marketing)/guides/article-shell.tsx @@ -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 ( +
+ + ← Guides + +

+ {title} +

+ {subtitle &&

{subtitle}

} + {updated &&

Updated {updated}

} +
{children}
+ +
+

+ Skip the boilerplate — describe your tool, get a hosted MCP server. +

+

+ 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. +

+ + Start building → + +
+
+ ); +} + +export function H2({ children }: { children: ReactNode }) { + return ( +

{children}

+ ); +} + +export function P({ children }: { children: ReactNode }) { + return

{children}

; +} + +export function UL({ children }: { children: ReactNode }) { + return ( + + ); +} + +export function Strong({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/app/(marketing)/guides/host-mcp-server-with-oauth/page.tsx b/apps/web/app/(marketing)/guides/host-mcp-server-with-oauth/page.tsx new file mode 100644 index 0000000..1aa8361 --- /dev/null +++ b/apps/web/app/(marketing)/guides/host-mcp-server-with-oauth/page.tsx @@ -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 ( + <> + + +

Local vs remote: why this is harder than it looks

+

+ 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 Streamable HTTP as the + remote transport (it replaced the older HTTP+SSE pairing), and on{' '} + OAuth 2.1 as the auth model. Both are non-negotiable if you want the + server installable from Claude Desktop or ChatGPT. +

+ +

The OAuth 2.1 pieces you can't skip

+

+ MCP authorization is OAuth 2.1, and for remote servers it leans on a few RFCs that older + OAuth tutorials don't cover: +

+
    +
  • + PKCE (RFC 7636) on every authorization-code exchange — mandatory in + OAuth 2.1, no exceptions for "confidential" clients. +
  • +
  • + Dynamic Client Registration (RFC 7591) — clients like Claude Desktop + register themselves at runtime; you can't pre-provision a client_id for every user. +
  • +
  • + Resource Indicators (RFC 8707) — the token has to be bound to the + specific MCP server (the resource), so a token minted for one server can't + be replayed against another. +
  • +
  • + Protected-resource metadata — your server returns a{' '} + WWW-Authenticate header pointing at the authorization server so clients can + discover where to get a token. +
  • +
+

+ 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. +

+ +

Option A — roll your own on generic infra

+

+ You can deploy a remote MCP server to Cloudflare Workers (the most common + production choice, edge-global), or to Render, Fly, or Cloud Run 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. +

+

+ 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. +

+ +

Option B — a platform that wraps it for you

+

+ If you already have a server, tools like MintMCP take a local STDIO + server and expose it as a remote one with OAuth wrapping. If you{' '} + don't have a server yet, 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. +

+ +

A practical checklist before you ship

+
    +
  • Transport is Streamable HTTP, served over TLS at a stable public URL.
  • +
  • Unauthenticated request returns 401 + a WWW-Authenticate pointing at your AS.
  • +
  • Authorization code flow enforces PKCE (S256), exact redirect-URI match, single-use codes.
  • +
  • Issued access tokens are audience-bound to the specific server (RFC 8707).
  • +
  • Per-caller secrets are encrypted at rest and injected only at runtime, never logged.
  • +
  • You've actually installed it from Claude Desktop end-to-end — not just curl'd it.
  • +
+ +

+ Whichever route you take, test the real install path in a real client early. See the{' '} + + platform comparison + {' '} + for which option fits your situation. +

+
+ + ); +} diff --git a/apps/web/app/(marketing)/guides/hosted-mcp-platforms-compared/page.tsx b/apps/web/app/(marketing)/guides/hosted-mcp-platforms-compared/page.tsx new file mode 100644 index 0000000..f13b4e9 --- /dev/null +++ b/apps/web/app/(marketing)/guides/hosted-mcp-platforms-compared/page.tsx @@ -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 ( + <> + + +

1. Registries & directories

+

+ Smithery, Glama and PulseMCP are about{' '} + discovery — 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. +

+ +

2. Connector platforms

+

+ Composio, Nango, Klavis,{' '} + Zapier and Pipedream expose their 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. +

+ +

3. Hosting infrastructure

+

+ Cloudflare Workers is the default for hosting a remote MCP server in + production — edge-global, with an OAuth provider library. Vercel,{' '} + Render and Cloud Run host custom Node containers too.{' '} + MintMCP 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 you bring the code. +

+ +

4. Generators (the gap most lists miss)

+

+ 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 bespoke 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{' '} + BuildMyMCPServer plays. +

+ +

So which do you pick?

+
    +
  • + Need a popular SaaS connector? A connector platform (Composio / Klavis / + Zapier) — don't rebuild what they maintain. +
  • +
  • + Have a server and engineers? Host it on Cloudflare Workers; wrap your + own OAuth or use MintMCP if you want the compliance posture done for you. +
  • +
  • + Just browsing for something that exists? Smithery or Glama. +
  • +
  • + Need a custom tool and don't want to write or host a server? A generator + — describe it, ship it. Export the source later if you outgrow it. +
  • +
+ +

Where BuildMyMCPServer fits honestly

+

+ We're not trying to out-scale Cloudflare's edge or out-catalog Composio. The job we do is + the bespoke one: prompt → a hosted, OAuth-protected MCP server, 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. +

+ +

+ Next:{' '} + + what hosting a remote MCP server with OAuth actually involves + + . +

+
+ + ); +} diff --git a/apps/web/app/(marketing)/guides/mintmcp-alternative/page.tsx b/apps/web/app/(marketing)/guides/mintmcp-alternative/page.tsx new file mode 100644 index 0000000..7f2e7f7 --- /dev/null +++ b/apps/web/app/(marketing)/guides/mintmcp-alternative/page.tsx @@ -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 ( + <> + + +

What MintMCP does well

+

+ MintMCP takes a local STDIO-based MCP server you already wrote and turns + it into a production remote deployment — one-click, with automatic OAuth wrapping. Its + headline strength is compliance: 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. +

+ +

Where it leaves a gap

+

+ The model assumes the hard part — designing and writing the server — is already done. If + you're starting from "I need a tool that does X" 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. +

+ +

The alternative: start from the prompt

+

+ BuildMyMCPServer 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. +

+ +

Pick by your starting point

+
    +
  • + You have a working STDIO server + need SOC 2/HIPAA today → MintMCP is + the more honest fit. We don't claim those certifications. +
  • +
  • + You have an idea, not a server → generate it here, ship in minutes, and + export the TypeScript if you later move it onto your own infra. +
  • +
  • + You're an agency building one-off tools for clients repeatedly → + generation + a fork-able template marketplace removes the per-client boilerplate. +
  • +
  • + You're in the EU/DACH and care where prompts go → we expose the provider + and offer a data-residency choice rather than defaulting everything to one region. +
  • +
+ +

What's the same either way

+

+ 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{' '} + where you start: with code, or with a sentence. +

+ +

+ More on the landscape:{' '} + + hosted MCP platforms compared + + . +

+
+ + ); +} diff --git a/apps/web/app/(marketing)/guides/page.tsx b/apps/web/app/(marketing)/guides/page.tsx new file mode 100644 index 0000000..42064b7 --- /dev/null +++ b/apps/web/app/(marketing)/guides/page.tsx @@ -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 ( +
+

MCP guides

+

+ Hosting, auth and shipping for Model Context Protocol servers — written for people building + real tools, not demos. +

+
+ {GUIDES.map((g) => ( + + + {g.tag} + +

+ {g.title} +

+

+ {g.description} +

+ + ))} +
+
+ ); +} diff --git a/apps/web/app/(marketing)/layout.tsx b/apps/web/app/(marketing)/layout.tsx index 555a57e..4288b8a 100644 --- a/apps/web/app/(marketing)/layout.tsx +++ b/apps/web/app/(marketing)/layout.tsx @@ -24,6 +24,9 @@ export default function MarketingLayout({ children }: { children: React.ReactNod Docs + + Guides + Changelog diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index b8b327e..a1ead88 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -1,4 +1,5 @@ import { SITE_URL } from '@/lib/seo'; +import { fetchPublicTemplateSlugs } from '@/lib/templates-server'; import type { MetadataRoute } from 'next'; type Entry = { @@ -11,6 +12,10 @@ const ROUTES: Entry[] = [ { path: '/', priority: 1.0, changeFrequency: 'weekly' }, { path: '/pricing', priority: 0.9, changeFrequency: 'weekly' }, { 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/concepts', 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' }, ]; -export default function sitemap(): MetadataRoute.Sitemap { +export default async function sitemap(): Promise { const now = new Date(); - return ROUTES.map((r) => ({ + const staticEntries: MetadataRoute.Sitemap = ROUTES.map((r) => ({ url: `${SITE_URL}${r.path}`, lastModified: now, changeFrequency: r.changeFrequency, 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]; } diff --git a/apps/web/app/templates/[slug]/collapsible-code.tsx b/apps/web/app/templates/[slug]/collapsible-code.tsx new file mode 100644 index 0000000..fb9ee0a --- /dev/null +++ b/apps/web/app/templates/[slug]/collapsible-code.tsx @@ -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 ( +
+ +

+ Audit before you fork. We re-scan every published template for banned patterns (eval, + child_process, prompt-injection markers). +

+ {show && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/app/templates/[slug]/page.tsx b/apps/web/app/templates/[slug]/page.tsx index 23cad14..30b5627 100644 --- a/apps/web/app/templates/[slug]/page.tsx +++ b/apps/web/app/templates/[slug]/page.tsx @@ -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; +// Server-rendered for SEO: per-template , 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; +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}`, + }); } -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 async function TemplateDetailPage({ params }: PageProps) { + const { slug } = await params; + const template: TemplateDetail | null = await fetchTemplate(slug); + if (!template) notFound(); -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) { - 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> - ); - } - - 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,26 +106,30 @@ export default function TemplateDetail() { Tools ({template.toolsSchema.length}) </h2> <div className="mt-3 space-y-3"> - {template.toolsSchema.map((tool) => ( - <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'} - </span> - </div> - <p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p> - {Object.keys(tool.inputSchema ?? {}).length > 0 && ( - <div className="mt-2"> - <CodeBlock - label="input schema" - code={JSON.stringify(tool.inputSchema, null, 2)} - /> + {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]"> + {paramCount} param{paramCount === 1 ? '' : 's'} + </span> </div> - )} - </div> - ))} + <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" + code={JSON.stringify(tool.inputSchema, null, 2)} + /> + </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.' : ''} - </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 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. + </div> )} </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> diff --git a/apps/web/lib/seo.ts b/apps/web/lib/seo.ts index 17aa2fe..5382447 100644 --- a/apps/web/lib/seo.ts +++ b/apps/web/lib/seo.ts @@ -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 * "%s | BuildMyMCPServer" template appends the brand exactly once. diff --git a/apps/web/lib/templates-server.ts b/apps/web/lib/templates-server.ts new file mode 100644 index 0000000..887f699 --- /dev/null +++ b/apps/web/lib/templates-server.ts @@ -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 []; + } +}