+
+ Add to Claude
+
+
+
+ Add to Cursor
+
+
+
+ Add to VS Code
+
+
+
+
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..ae20724
--- /dev/null
+++ b/apps/docs/content/1.getting-started/2.mcp.md
@@ -0,0 +1,89 @@
+---
+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 call the server from their own infrastructure, so they need a URL that is
+publicly reachable over HTTPS. Cursor, VS Code and Claude Code connect from your own machine, where
+`http://localhost:3000/mcp` works fine.
+::
+
+## 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',
+})
From eede0a914feee102b7e569b48794773707231d76 Mon Sep 17 00:00:00 2001
From: Thomas <28439359+thoda-dev@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:32:52 +0200
Subject: [PATCH 2/3] docs(mcp): drop the hardcoded localhost URL from the
reachability note
---
apps/docs/content/1.getting-started/2.mcp.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/apps/docs/content/1.getting-started/2.mcp.md b/apps/docs/content/1.getting-started/2.mcp.md
index ae20724..6d29339 100644
--- a/apps/docs/content/1.getting-started/2.mcp.md
+++ b/apps/docs/content/1.getting-started/2.mcp.md
@@ -71,9 +71,10 @@ Restart the client after editing its config. Most of them only read MCP servers
::
::warning
-Claude and ChatGPT call the server from their own infrastructure, so they need a URL that is
-publicly reachable over HTTPS. Cursor, VS Code and Claude Code connect from your own machine, where
-`http://localhost:3000/mcp` works fine.
+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
From 6e071761b703f4deed7b642f75a7611633be67b2 Mon Sep 17 00:00:00 2001
From: Thomas <28439359+thoda-dev@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:39:22 +0200
Subject: [PATCH 3/3] fix(docs): handle a failing clipboard write on the MCP
page
---
apps/docs/app/components/mdc/McpInstall.vue | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/apps/docs/app/components/mdc/McpInstall.vue b/apps/docs/app/components/mdc/McpInstall.vue
index 68777e7..a61c09d 100644
--- a/apps/docs/app/components/mdc/McpInstall.vue
+++ b/apps/docs/app/components/mdc/McpInstall.vue
@@ -14,7 +14,19 @@ const copied = ref(false)
const claudeUrl = `https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=${encodeURIComponent(serverName)}&connectorUrl=${encodeURIComponent(serverUrl)}`
async function copyUrl() {
- await navigator.clipboard.writeText(serverUrl)
+ try {
+ await navigator.clipboard.writeText(serverUrl)
+ } catch {
+ // There is no clipboard outside a secure context, which a docs instance on plain HTTP is not.
+ toast.add({
+ title: 'Could not copy',
+ description: 'Select the URL above and copy it by hand.',
+ icon: 'i-lucide-triangle-alert',
+ color: 'warning',
+ })
+ return
+ }
+
copied.value = true
setTimeout(() => copied.value = false, 2000)
toast.add({ title: 'Server URL copied', icon: 'i-lucide-check-circle', color: 'success' })