diff --git a/apps/docs/app/components/mdc/GithubReleases.vue b/apps/docs/app/components/mdc/GithubReleases.vue new file mode 100644 index 0000000..a6614a2 --- /dev/null +++ b/apps/docs/app/components/mdc/GithubReleases.vue @@ -0,0 +1,83 @@ + + + diff --git a/apps/docs/app/components/mdc/McpInstall.vue b/apps/docs/app/components/mdc/McpInstall.vue new file mode 100644 index 0000000..a61c09d --- /dev/null +++ b/apps/docs/app/components/mdc/McpInstall.vue @@ -0,0 +1,85 @@ + + + diff --git a/apps/docs/content/1.getting-started/2.mcp.md b/apps/docs/content/1.getting-started/2.mcp.md new file mode 100644 index 0000000..6d29339 --- /dev/null +++ b/apps/docs/content/1.getting-started/2.mcp.md @@ -0,0 +1,90 @@ +--- +title: Doc MCP server +description: Plug this documentation into Cursor, VS Code, Claude or any other MCP client. +navigation: + icon: i-lucide-plug +--- + +This documentation is also served as a [Model Context Protocol](https://modelcontextprotocol.io) +server. Point an AI assistant at it and it answers from the pages you are reading right now, instead +of whatever it remembers about a self-hosted pastebin it saw once during training. + +The endpoint is one URL, over HTTP, with no account and no token: + +::mcp-install +:: + +## What it exposes + +| Tool | What it does | +|---|---| +| `list-pages` | Lists every documentation page with its title, description and path. | +| `get-page` | Returns the full markdown of one page, given its path. | + +Both are read-only. The server has no access to any shhh instance, no access to your pastes, and no +way to create anything — it reads this documentation and nothing else. + +## Adding it by hand + +The buttons above cover Claude, Cursor and VS Code. Everywhere else, add the URL yourself. + +::code-group + +```bash [Claude Code] +claude mcp add --transport http shhh-docs https://shhh-docs.thoda.dev/mcp +``` + +```json [.vscode/mcp.json] +{ + "servers": { + "shhh-docs": { + "type": "http", + "url": "https://shhh-docs.thoda.dev/mcp" + } + } +} +``` + +```json [~/.cursor/mcp.json] +{ + "mcpServers": { + "shhh-docs": { + "type": "http", + "url": "https://shhh-docs.thoda.dev/mcp" + } + } +} +``` + +:: + +**ChatGPT** has no install link of its own. Custom connectors sit behind *Developer mode*, in +**Settings → Apps & Connectors → Advanced**; turn it on, then add the URL as a connector. It is +web-only and needs a paid plan — OpenAI moves those menu labels around, so follow +[their help page](https://help.openai.com/en/articles/12584461) if they have shifted again. + +Other clients — Zed, Windsurf, Continue, and anything built on an MCP SDK — take the same URL as a +*streamable HTTP* server; check their own documentation for where the config file lives. + +::note +Restart the client after editing its config. Most of them only read MCP servers at startup. +:: + +::warning +Claude and ChatGPT reach the server from their own infrastructure, so the URL has to be publicly +reachable over HTTPS. Cursor, VS Code and Claude Code connect from your own machine instead, so they +also work against an instance on your network, or on a `localhost` port while you run the docs +yourself. +:: + +## Running it from your own instance + +The MCP server is part of the documentation site, not of shhh itself. If you host the docs yourself +with `docker/docs.Dockerfile`, the endpoint is `/mcp` on your own origin — `https://docs.example.com/mcp` — +and the install buttons on this page already point at whichever host served it to you. + +::warning +shhh itself has **no public API and no MCP server**. An agent cannot create or read pastes on your +behalf; it can only read this documentation. Both are on the +[roadmap](https://github.com/thoda-dev/shhh/blob/master/ROADMAP.md) and neither is built yet. +:: diff --git a/apps/docs/content/4.releases.md b/apps/docs/content/4.releases.md new file mode 100644 index 0000000..8bc054e --- /dev/null +++ b/apps/docs/content/4.releases.md @@ -0,0 +1,19 @@ +--- +title: Releases +description: Every published version of shhh, pulled straight from GitHub. +navigation: + icon: i-lucide-tag +--- + +Release notes are generated from the commit history at tag time. This page reads the +[GitHub releases](https://github.com/thoda-dev/shhh/releases) of the repository directly, so it never +lags behind a publication. + +::note +Version numbers follow [semantic versioning](https://semver.org). Upgrading is a `docker compose pull` +away — migrations run on boot, so there is no separate step. Read the notes first when a major +version lands. +:: + +::github-releases +:: diff --git a/apps/docs/nuxt.config.ts b/apps/docs/nuxt.config.ts index c6f7ec6..104ea8a 100644 --- a/apps/docs/nuxt.config.ts +++ b/apps/docs/nuxt.config.ts @@ -20,6 +20,22 @@ export default defineNuxtConfig({ }, }, + // The docs double as an MCP server on /mcp — see content/1.getting-started/2.mcp.md. Without a + // name the handshake advertises an empty one, which is what MCP clients list the server under. + mcp: { + name: 'shhh docs', + description: 'Documentation for shhh, a self-hosted zero-knowledge pastebin.', + }, + + // Two pages have to be rendered per request rather than baked at build time. /releases reads the + // GitHub API, and prerendering would freeze the list; its handler caches, so this costs one call to + // GitHub every half hour, not one per hit. The MCP page builds absolute URLs from the request + // origin, and the prerenderer only knows `http://localhost`. + routeRules: { + '/getting-started/mcp': { prerender: false }, + '/releases': { prerender: false }, + }, + // Same reason as the app: bundle icons at build time rather than fetching them at runtime. icon: { clientBundle: { @@ -35,16 +51,19 @@ export default defineNuxtConfig({ 'lucide:file-lock', 'lucide:info', 'lucide:lock-keyhole', + 'lucide:plug', 'lucide:rocket', 'lucide:server', 'lucide:shield', 'lucide:shield-check', 'lucide:sliders-horizontal', + 'lucide:tag', 'lucide:timer', 'lucide:user', 'lucide:users', 'simple-icons:github', 'vscode-icons:file-type-dotenv', + 'vscode-icons:file-type-json', ], }, }, diff --git a/apps/docs/server/api/releases.get.ts b/apps/docs/server/api/releases.get.ts new file mode 100644 index 0000000..16b7e89 --- /dev/null +++ b/apps/docs/server/api/releases.get.ts @@ -0,0 +1,63 @@ +interface GitHubRelease { + name: string | null + tag_name: string + html_url: string + published_at: string | null + created_at: string + draft: boolean + prerelease: boolean + body: string | null +} + +interface MarkdownNode { + props?: Record + children?: MarkdownNode[] +} + +// Every release repeats the same `### 🚀 Enhancements` headings, and they all land on one page. +function prefixIds(node: MarkdownNode, prefix: string) { + if (typeof node.props?.id === 'string') { + node.props.id = `${prefix}-${node.props.id}` + } + node.children?.forEach(child => prefixIds(child, prefix)) +} + +async function renderNotes(body: string, tag: string) { + // changelogen opens each body with the version as a heading; the card already shows it. GitHub + // hands the notes back with CRLF endings, which `.` in a JS regex refuses to cross. + const notes = body.replace(/\r\n/g, '\n').trim().replace(/^##\s+.*\n+/, '') + const parsed = await parseMarkdown(notes || '_No release notes._') + prefixIds(parsed.body as MarkdownNode, tag) + return parsed.body +} + +// Unauthenticated GitHub allows 60 calls an hour per IP, shared by every visitor of the instance. +// Caching the whole list keeps a busy day at 48 calls and rides out a GitHub outage for half an hour. +export default defineCachedEventHandler(async () => { + const releases = await $fetch('https://api.github.com/repos/thoda-dev/shhh/releases', { + query: { per_page: 30 }, + headers: { + 'accept': 'application/vnd.github+json', + 'user-agent': 'shhh-docs', + 'x-github-api-version': '2022-11-28', + }, + }) + + return Promise.all(releases + .filter(release => !release.draft) + .map(async release => ({ + title: release.name || release.tag_name, + tag: release.tag_name, + url: release.html_url, + publishedAt: release.published_at || release.created_at, + prerelease: release.prerelease, + // Parsed here rather than in the browser so the page ships an AST the docs prose components + // render, instead of raw HTML that would need its own styling. + body: await renderNotes(release.body || '', release.tag_name), + }))) +}, { + name: 'github-releases', + maxAge: 60 * 30, + swr: true, + getKey: () => 'thoda-dev/shhh', +})