From db814c82dcc111f22a45858993f293f0ecae5a60 Mon Sep 17 00:00:00 2001 From: Nicholas Kissel Date: Tue, 1 Sep 2026 13:12:50 -0700 Subject: [PATCH] feat(website): move MCP guide into docs --- .gitignore | 1 + Caddyfile | 31 +- examples/docs/mcp/call-actor-action.txt | 1 + examples/docs/mcp/claude-code.sh | 1 + examples/docs/mcp/codex.sh | 1 + examples/docs/mcp/cursor.json | 7 + examples/docs/mcp/find-running-actors.txt | 1 + examples/docs/mcp/gemini-cli.sh | 1 + examples/docs/mcp/inspect-actor-inline.txt | 1 + examples/docs/mcp/local.json | 8 + examples/docs/mcp/one-request.js | 10 + examples/docs/mcp/vs-code.sh | 1 + src/components/Footer.jsx | 1 + src/components/Tabs.tsx | 33 +- src/components/TabsScript.astro | 212 +++++++--- src/components/docs/DocsArticlePage.astro | 43 +- src/components/v2/Code.tsx | 18 +- src/components/v2/Header.tsx | 30 +- src/content/docs/docs/mcp.mdx | 127 ++++++ src/layouts/BaseLayout.astro | 4 +- src/layouts/DocsLayout.astro | 5 +- src/lib/routeSeoPolicy.ts | 4 +- src/metadata/mdx-to-markdown.ts | 2 +- src/pages/[product]/[tab]/[...slug].astro | 52 ++- src/pages/docs/[...slug].astro | 43 ++ src/pages/docs/index.astro | 12 +- src/pages/mcp.astro | 431 --------------------- src/pages/metadata/docs.json.ts | 25 +- src/sitemap/ai.ts | 79 ++++ src/sitemap/docs-sources.node.ts | 14 +- src/sitemap/docs-sources.ts | 19 +- src/sitemap/mod.ts | 2 - 32 files changed, 674 insertions(+), 546 deletions(-) create mode 100644 examples/docs/mcp/call-actor-action.txt create mode 100644 examples/docs/mcp/claude-code.sh create mode 100644 examples/docs/mcp/codex.sh create mode 100644 examples/docs/mcp/cursor.json create mode 100644 examples/docs/mcp/find-running-actors.txt create mode 100644 examples/docs/mcp/gemini-cli.sh create mode 100644 examples/docs/mcp/inspect-actor-inline.txt create mode 100644 examples/docs/mcp/local.json create mode 100644 examples/docs/mcp/one-request.js create mode 100644 examples/docs/mcp/vs-code.sh create mode 100644 src/content/docs/docs/mcp.mdx create mode 100644 src/pages/docs/[...slug].astro delete mode 100644 src/pages/mcp.astro create mode 100644 src/sitemap/ai.ts diff --git a/.gitignore b/.gitignore index 64849ed2..e1790da5 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,6 @@ src/content/docs/workflows /public/agentos/ /public/cloud/ /public/dynamic-apps/ +/public/docs/ /public/workflows/ /public/metadata/ diff --git a/Caddyfile b/Caddyfile index f8b27a3d..9855457b 100644 --- a/Caddyfile +++ b/Caddyfile @@ -52,14 +52,26 @@ # www -> apex. Railway terminates TLS upstream, so always redirect to https. # Only takes effect when www.rivet.dev is attached to the Railway service. - @www host www.rivet.dev + @www { + host www.rivet.dev + not path /mcp /mcp/ + } redir @www https://rivet.dev{uri} 301 - # mcp.rivet.dev serves the MCP landing page at its root while the MCP server - # keeps every route it owns, which are proxied to MCP_UPSTREAM. A blanket - # host redirect is not possible: https://mcp.rivet.dev/mcp is the endpoint - # the landing page tells people to connect to. Only takes effect when - # mcp.rivet.dev is attached to the Railway service and MCP_UPSTREAM is set. + # The former apex landing route now lives in the docs. Keep this redirect + # host-aware: an unconditional /mcp redirect would intercept the MCP service + # endpoint on mcp.rivet.dev before its reverse-proxy handler runs. + @mcpDocsPage { + not host mcp.rivet.dev + path /mcp /mcp/ + } + redir @mcpDocsPage https://rivet.dev/docs/mcp/{http.request.uri.prefixed_query} 301 + + # mcp.rivet.dev redirects its root to the canonical docs page while the MCP + # server keeps every route it owns, which are proxied to MCP_UPSTREAM. A + # blanket host redirect is not possible: https://mcp.rivet.dev/mcp is the + # endpoint clients connect to. Only takes effect when mcp.rivet.dev is + # attached to the Railway service and MCP_UPSTREAM is set. @mcpHost host mcp.rivet.dev handle @mcpHost { @mcpService path /mcp /mcp/* /.well-known/oauth-protected-resource /health /ready @@ -68,12 +80,11 @@ } handle / { - rewrite * /mcp/index.html - file_server + redir https://rivet.dev/docs/mcp/{http.request.uri.prefixed_query} 301 } - # The landing page is the only page this host serves; the rest of the - # site keeps one canonical home. + # The service routes above are the only content this host serves; the rest + # of the site keeps one canonical home. handle { redir https://rivet.dev{uri} 301 } diff --git a/examples/docs/mcp/call-actor-action.txt b/examples/docs/mcp/call-actor-action.txt new file mode 100644 index 00000000..d089823b --- /dev/null +++ b/examples/docs/mcp/call-actor-action.txt @@ -0,0 +1 @@ +What has Acme been saying? diff --git a/examples/docs/mcp/claude-code.sh b/examples/docs/mcp/claude-code.sh new file mode 100644 index 00000000..28ad4e58 --- /dev/null +++ b/examples/docs/mcp/claude-code.sh @@ -0,0 +1 @@ +claude mcp add --transport http rivet https://mcp.rivet.dev/mcp diff --git a/examples/docs/mcp/codex.sh b/examples/docs/mcp/codex.sh new file mode 100644 index 00000000..ce3f462b --- /dev/null +++ b/examples/docs/mcp/codex.sh @@ -0,0 +1 @@ +codex mcp add rivet --url https://mcp.rivet.dev/mcp diff --git a/examples/docs/mcp/cursor.json b/examples/docs/mcp/cursor.json new file mode 100644 index 00000000..132bd185 --- /dev/null +++ b/examples/docs/mcp/cursor.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "rivet": { + "url": "https://mcp.rivet.dev/mcp" + } + } +} diff --git a/examples/docs/mcp/find-running-actors.txt b/examples/docs/mcp/find-running-actors.txt new file mode 100644 index 00000000..4180f562 --- /dev/null +++ b/examples/docs/mcp/find-running-actors.txt @@ -0,0 +1 @@ +Which chat rooms are still awake? diff --git a/examples/docs/mcp/gemini-cli.sh b/examples/docs/mcp/gemini-cli.sh new file mode 100644 index 00000000..7167165a --- /dev/null +++ b/examples/docs/mcp/gemini-cli.sh @@ -0,0 +1 @@ +gemini mcp add --transport http rivet https://mcp.rivet.dev/mcp diff --git a/examples/docs/mcp/inspect-actor-inline.txt b/examples/docs/mcp/inspect-actor-inline.txt new file mode 100644 index 00000000..33409643 --- /dev/null +++ b/examples/docs/mcp/inspect-actor-inline.txt @@ -0,0 +1 @@ +Open the Actor Inspector for chat-room · acme. diff --git a/examples/docs/mcp/local.json b/examples/docs/mcp/local.json new file mode 100644 index 00000000..1dc06c84 --- /dev/null +++ b/examples/docs/mcp/local.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "rivet": { + "command": "npx", + "args": ["-y", "@rivet-dev/mcp", "--target", "local"] + } + } +} diff --git a/examples/docs/mcp/one-request.js b/examples/docs/mcp/one-request.js new file mode 100644 index 00000000..6b916587 --- /dev/null +++ b/examples/docs/mcp/one-request.js @@ -0,0 +1,10 @@ +// "Which chat rooms are still awake, and what has Acme been saying?" +const { actors } = await rivet.actors.list({ name: "chat-room" }); +const awake = actors.filter((room) => room.status === "running"); + +const history = await rivet.actor.action({ + actor: { name: "chat-room", key: ["acme"] }, + name: "getHistory", +}); + +return { awake: awake.length, history }; diff --git a/examples/docs/mcp/vs-code.sh b/examples/docs/mcp/vs-code.sh new file mode 100644 index 00000000..40793952 --- /dev/null +++ b/examples/docs/mcp/vs-code.sh @@ -0,0 +1 @@ +code --add-mcp '{"name":"rivet","type":"http","url":"https://mcp.rivet.dev/mcp"}' diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx index 6b084de5..d9c8622a 100644 --- a/src/components/Footer.jsx +++ b/src/components/Footer.jsx @@ -33,6 +33,7 @@ const footer = { ], devs: [ { name: "Documentation", href: "/docs" }, + { name: "MCP", href: "https://rivet.dev/docs/mcp/" }, { name: "Changelog", href: "/blog/" }, { name: "Status Page", href: "https://rivet.betteruptime.com/" }, ], diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index a1cd6667..7b3d3c13 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -1,19 +1,30 @@ import { cn } from "@rivet-gg/components"; +import { Icon } from "@rivet-gg/icons"; +import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import type { ReactNode } from "react"; interface TabProps { title: string; + icon?: IconDefinition; children: ReactNode; } -export const Tab = ({ title, children }: TabProps) => { +export const Tab = ({ title, icon, children }: TabProps) => { return ( -
+
+

+ {icon && } + {title} +

-
+
{children}
@@ -32,21 +44,24 @@ export const Tab = ({ title, children }: TabProps) => { interface TabsProps { children: ReactNode; + label?: string; } -export const Tabs = ({ children }: TabsProps) => { +export const Tabs = ({ children, label = "Options" }: TabsProps) => { return (
-
+ -
+ -
+
{children}
diff --git a/src/components/TabsScript.astro b/src/components/TabsScript.astro index dddb588d..b04c6bb1 100644 --- a/src/components/TabsScript.astro +++ b/src/components/TabsScript.astro @@ -4,45 +4,129 @@ --- diff --git a/src/components/docs/DocsArticlePage.astro b/src/components/docs/DocsArticlePage.astro index fe893176..894ed169 100644 --- a/src/components/docs/DocsArticlePage.astro +++ b/src/components/docs/DocsArticlePage.astro @@ -12,7 +12,8 @@ import { DocsLanding } from '@/components/docs/DocsLanding'; import { docsLandings } from '@/components/docs/docsLandings'; import { Icon, faPencil } from '@rivet-gg/icons'; import { sitemap } from '@/sitemap/mod'; -import { findActiveTab } from '@/lib/sitemap'; +import { withAiSidebarSection } from '@/sitemap/ai'; +import { findActiveTab, findPageForHref } from '@/lib/sitemap'; import { getContentSlugPath } from '@/lib/content-path'; import { productAccent } from '@/lib/product-accent'; import { getProductMetadata } from '@/sitemap/product-metadata'; @@ -38,6 +39,10 @@ const { // copy points search engines at one winner instead of letting Google pick. canonicalUrl: canonicalUrlOverride, robots, + exactTitle = false, + routeSlugOverride, + markdownPathOverride, + editUrlOverride, // Extra MDX components for this page, e.g. the bound product slot renderer. extraComponents = {}, } = Astro.props; @@ -85,22 +90,39 @@ const routeEntryId = entryIdPrefix && entry.id.startsWith(entryIdPrefix) : entryIdPrefix && `${entry.id}/` === entryIdPrefix ? '' : entry.id; -const slugPath = getContentSlugPath(routeEntryId); +const slugPath = routeSlugOverride ?? getContentSlugPath(routeEntryId); // Section overviews render as a custom icon-grid landing instead of the // standard prose article. The markdown chrome (edit button, breadcrumb, TOC, // page dropdown) is suppressed for these. const landing = docsLandings[entrySlugPath ?? '']; const isLanding = Boolean(landing); const fullPath = slugPath ? `${routePrefix}/${slugPath}/` : `${routePrefix}/`; -const foundTab = findActiveTab(fullPath, sitemap); -const rawParentPage = foundTab?.page?.parent; +const discovered = findActiveTab(fullPath, sitemap); +const routeOwnedTab = productId && tabId + ? sitemap.find((tab) => tab.href.replace(/\/$/, '') === `/${productId}/${tabId}`) + : undefined; +const activeTab = routeOwnedTab ?? discovered?.tab; +const sourceSidebar = activeTab?.sidebar ?? []; +const sidebar = tabId === 'docs' || fullPath.startsWith('/docs/') + ? withAiSidebarSection(sourceSidebar, productId) + : sourceSidebar; +const activePage = activeTab + ? findPageForHref(fullPath, { ...activeTab, sidebar }) + : discovered?.page; +const discoveredParentPage = activePage?.parent; +const rawParentPage = + discoveredParentPage && + 'title' in discoveredParentPage && + discoveredParentPage.title === 'AI Integration' + ? { ...discoveredParentPage, title: 'AI Tools' } + : discoveredParentPage; // A section's overview page sits inside a sidebar section of the same name, so // the eyebrow would just repeat the h1 ("Control Plane" over "Control Plane"). const parentPage = rawParentPage && 'title' in rawParentPage && rawParentPage.title === title ? undefined : rawParentPage; -const hasSidebar = (foundTab?.tab?.sidebar?.length ?? 0) > 0; +const hasSidebar = sidebar.length > 0; const canonicalUrl = canonicalUrlOverride ?? `https://rivet.dev${fullPath}`; // Inside a product vertical, prose links carry that product's accent instead of @@ -111,7 +133,7 @@ const proseStyle = accent ? { '--tw-prose-links': accent.hex } : undefined; // The Markdown mirror written by `pnpm gen:markdown` sits at the page's own // site path plus `.md`, so the dropdown's link is just this route unslashed. -const markdownPath = fullPath.replace(/^\/|\/$/g, ''); +const markdownPath = markdownPathOverride ?? fullPath.replace(/^\/|\/$/g, ''); const componentSourcePath = entry.filePath ? entry.filePath.replace(/^.*?src\/content\//, '') : `docs/${entry.id}.mdx`; @@ -128,11 +150,11 @@ const productContentPath = normalizedFilePath?.includes('/docs/content/') : productContentMarker && normalizedFilePath?.includes(productContentMarker) ? normalizedFilePath.slice(normalizedFilePath.indexOf(productContentMarker) + productContentMarker.length) : `${tabId}/${routeEntryId ? `${routeEntryId}.mdx` : 'index.mdx'}`; -const editUrl = product && tabId !== 'self-host' +const editUrl = editUrlOverride ?? (product && tabId !== 'self-host' ? product.localBundle ? `https://github.com/rivet-dev/website/edit/main/${product.localBundle}/docs/content/${productContentPath}` : `https://github.com/rivet-dev/${product.repo}/edit/main/docs/content/${productContentPath}` - : `https://github.com/rivet-dev/website/edit/main/src/content/${componentSourcePath}`; + : `https://github.com/rivet-dev/website/edit/main/src/content/${componentSourcePath}`); // Build BreadcrumbList schema const breadcrumbItems = [ @@ -163,15 +185,16 @@ const breadcrumbSchema = { description={seo.description} canonicalUrl={canonicalUrl} robots={robots} - sidebar={foundTab?.tab?.sidebar} + sidebar={sidebar} productId={productId} tabId={tabId} sectionLabel={sectionLabel} + exactTitle={exactTitle} > diff --git a/src/pages/metadata/docs.json.ts b/src/pages/metadata/docs.json.ts index 36581ea2..b81ac40e 100644 --- a/src/pages/metadata/docs.json.ts +++ b/src/pages/metadata/docs.json.ts @@ -10,6 +10,8 @@ import { PROJECT_ROOT, SITE_BASE_URL, } from "../../metadata/shared"; +import { inlineCodeSnippets } from "../../metadata/mdx-to-markdown"; +import { snippetRootForContentPath } from "../../sitemap/docs-sources.node"; const CURATED_LIMIT = 50; @@ -46,10 +48,24 @@ async function buildMetadata(): Promise { const canonicalUrl = `${SITE_BASE_URL}${canonicalPath}`; const tags = slug.split("/").filter(Boolean); const productArea = tags[0] ?? null; - const body = entry.body ?? ""; - const headings = ensureHeadings(extractHeadings(body), entry.data.title); + const sourceBody = entry.body ?? ""; + const expandSnippets = (content: string) => + inlineCodeSnippets(content, { + snippetRoot: snippetRootForContentPath(entry.filePath), + sourceLabel: slug, + }); + const body = expandSnippets(sourceBody); + const headings = ensureHeadings(extractHeadings(sourceBody), entry.data.title); const updatedAt = await getUpdatedAt(entry.filePath); - const sectionRecords = splitSections(body, headings, resourceUri, canonicalUrl, updatedAt, canonicalPath); + const sectionRecords = splitSections( + sourceBody, + headings, + resourceUri, + canonicalUrl, + updatedAt, + canonicalPath, + expandSnippets, + ); const plaintext = toPlainText(body); pages.push({ @@ -162,6 +178,7 @@ function splitSections( canonicalUrl: string, updatedAt: string, canonicalPath: string, + expandSnippets: (content: string) => string, ) { const lines = body.split(/\r?\n/); const records: SectionRecord[] = []; @@ -169,7 +186,7 @@ function splitSections( for (const heading of headings) { const start = Math.max(heading.startLine - 1, 0); const end = typeof heading.endLine === "number" ? heading.endLine : lines.length; - const content = lines.slice(start, end).join("\n").trim(); + const content = expandSnippets(lines.slice(start, end).join("\n")).trim(); const snippet = toSnippet(content); const uri = `${resourceUri}#section=${heading.anchor}`; diff --git a/src/sitemap/ai.ts b/src/sitemap/ai.ts new file mode 100644 index 00000000..836362d9 --- /dev/null +++ b/src/sitemap/ai.ts @@ -0,0 +1,79 @@ +import type { SidebarItem } from "@/lib/sitemap"; + +export const AI_DOCS_ITEMS = [ + { + id: "skills", + title: "Skills", + href: "/actors/docs/general/skill/", + contentId: "actors/docs/general/skill", + markdownPath: "actors/docs/general/skill", + editUrl: + "https://github.com/rivet-dev/rivet/edit/main/docs/content/docs/general/skill.mdx", + description: + "Install Rivet guidance and implementation patterns in supported AI coding assistants.", + }, + { + id: "mcp", + title: "MCP", + href: "/docs/mcp/", + contentId: "docs/mcp", + markdownPath: "docs/mcp", + editUrl: + "https://github.com/rivet-dev/website/edit/main/src/content/docs/docs/mcp.mdx", + description: + "Connect Claude Code, Codex, Cursor, and other AI clients to Rivet.", + }, +] as const; + +export type AiDocsItem = (typeof AI_DOCS_ITEMS)[number]; + +/** + * Keep shared AI documentation inside the current product's docs shell. Actors + * owns the canonical Skills page and is the default shell for the canonical MCP + * page; every other product gets a noncanonical contextual alias. + */ +export function aiDocsHref(item: AiDocsItem, productId?: string): string { + if (!productId || productId === "actors") return item.href; + return `/${productId}/docs/ai/${item.id}/`; +} + +export function aiSidebarSection(productId?: string): SidebarItem { + return { + title: "AI Tools", + pages: AI_DOCS_ITEMS.map((item) => ({ + title: item.title, + href: aiDocsHref(item, productId), + })), + }; +} + +/** + * Add the site-wide AI links to a rendered Documentation sidebar without + * making those links part of every product's route-owning sitemap. + */ +export function withAiSidebarSection( + sidebar: readonly SidebarItem[], + productId?: string, +): SidebarItem[] { + const normalized = sidebar + .filter((item) => item.title !== "AI" && item.title !== "AI Tools") + .map((item) => { + if (!("pages" in item)) return item; + return { + ...item, + pages: item.pages.filter( + (page) => !("title" in page) || page.title !== "AI Integration", + ), + }; + }); + const referenceIndex = normalized.findIndex( + (item) => item.title === "Reference", + ); + const insertAt = referenceIndex === -1 ? normalized.length : referenceIndex; + + return [ + ...normalized.slice(0, insertAt), + aiSidebarSection(productId), + ...normalized.slice(insertAt), + ]; +} diff --git a/src/sitemap/docs-sources.node.ts b/src/sitemap/docs-sources.node.ts index 97b48cd4..c2d2a831 100644 --- a/src/sitemap/docs-sources.node.ts +++ b/src/sitemap/docs-sources.node.ts @@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url"; import { DOCS_SOURCES, SHARED_CONTENT_PRODUCT, + SITE_DOCS_NAMESPACE, productFromPath, } from "./docs-sources"; @@ -104,20 +105,29 @@ export function snippetRootForContentPath( contentPath: string | undefined, ): string | undefined { if (contentPath) { + // Website-owned global docs use the website repo as their snippet root. + // Resolve their explicit collection namespace before matching real repo + // roots: the website root contains unrelated content that must keep falling + // back to the Actors examples. + const namespacedSource = productFromPath(contentPath); + if (namespacedSource === SITE_DOCS_NAMESPACE) { + return snippetRoot(SITE_DOCS_NAMESPACE); + } + // Product docs are symlinked in, and Vite reports the resolved realpath // (`/home/me/agentos/docs/content/...`), so the `src/content/docs/` // shape is usually gone by the time we see it. Match on the repo root // first and fall back to the path shape. const normalized = path.resolve(contentPath); for (const productId of Object.keys(DOCS_SOURCES)) { + if (productId === SITE_DOCS_NAMESPACE) continue; const root = docsRoot(productId); if (root && normalized.startsWith(`${path.resolve(root)}${path.sep}`)) { return snippetRoot(productId) ?? root; } } - const product = productFromPath(contentPath); - if (product) return snippetRoot(product); + if (namespacedSource) return snippetRoot(namespacedSource); } return docsRoot(SHARED_CONTENT_PRODUCT); diff --git a/src/sitemap/docs-sources.ts b/src/sitemap/docs-sources.ts index 9656a969..f36bc35f 100644 --- a/src/sitemap/docs-sources.ts +++ b/src/sitemap/docs-sources.ts @@ -1,5 +1,5 @@ /** - * Which repository owns each product's docs. + * Which repository owns each docs namespace. * * Product docs are not authored in this repo. Each product repo ships a bundle: * @@ -32,7 +32,7 @@ export interface DocsSource { snippetFrom?: string; } -export const DOCS_SOURCES: Record = Object.fromEntries( +const PRODUCT_DOCS_SOURCES: Record = Object.fromEntries( PRODUCTS.map((product) => [ product.id, { @@ -43,14 +43,25 @@ export const DOCS_SOURCES: Record = Object.fromEntries( ]), ); +/** Website-owned documentation that sits beside, rather than inside, a product vertical. */ +export const SITE_DOCS_NAMESPACE = "docs"; + +export const DOCS_SOURCES: Record = { + ...PRODUCT_DOCS_SOURCES, + [SITE_DOCS_NAMESPACE]: { + repo: "rivet-website", + localBundle: ".", + }, +}; + export const DOCS_PRODUCT_IDS = Object.keys(DOCS_SOURCES); /** - * The product that owns a path, derived from where its content sits. + * The docs namespace that owns a path, derived from where its content sits. * * Accepts either a site path (`/agentos/docs/fs`) or a content-file path * (`.../src/content/docs/agentos/fs.mdx`). Returns undefined for anything - * outside the product verticals, such as the shared self-host guides. + * outside the docs collection, such as the shared self-host guides. */ export function productFromPath(pathname: string): string | undefined { const normalized = pathname.replace(/\\/g, "/"); diff --git a/src/sitemap/mod.ts b/src/sitemap/mod.ts index 790b6ba1..9d05d5c4 100644 --- a/src/sitemap/mod.ts +++ b/src/sitemap/mod.ts @@ -8,8 +8,6 @@ export * from "./products"; // a handful of tabs each — so the flat sitemap is derived rather than authored, and the // product registry in `./products.ts` is the single source of truth. // -// Every docs section is owned by a product now, so the flat sitemap is exactly -// the product tabs. export const sitemap = [ ...products.flatMap((product) => product.tabs.map((tab) => ({