Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/docs/app/components/mdc/GithubReleases.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<script setup lang="ts">
const { data: releases, status, error } = await useFetch('/api/releases')

const formatter = new Intl.DateTimeFormat('en', { dateStyle: 'long' })
</script>

<template>
<div>
<div
v-if="status === 'pending'"
class="flex flex-col gap-4"
>
<USkeleton
v-for="index in 3"
:key="index"
class="h-32 w-full"
/>
</div>

<UAlert
v-else-if="error || !releases"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
title="Releases unavailable"
description="GitHub did not answer. The full list is always on the releases page of the repository."
:actions="[{
label: 'Open on GitHub',
color: 'neutral',
variant: 'outline',
to: 'https://github.com/thoda-dev/shhh/releases',
target: '_blank',
}]"
/>

<div
v-else
class="flex flex-col gap-12"
>
<section
v-for="release in releases"
:key="release.tag"
>
<div class="flex flex-wrap items-center gap-2 border-b border-default pb-3">
<h2
:id="release.tag"
class="scroll-mt-20 text-xl font-bold text-highlighted"
>
{{ release.title }}
</h2>

<UBadge
v-if="release.prerelease"
color="warning"
variant="subtle"
size="sm"
>
Pre-release
</UBadge>

<span class="ms-auto flex items-center gap-3 text-sm text-muted">
<time :datetime="release.publishedAt">{{ formatter.format(new Date(release.publishedAt)) }}</time>

<ULink
:to="release.url"
target="_blank"
class="inline-flex items-center gap-1 hover:text-default"
>
<UIcon name="i-simple-icons-github" class="size-4" />
<span class="sr-only">{{ release.tag }} on GitHub</span>
</ULink>
</span>
</div>

<MDCRenderer
v-if="release.body"
:body="release.body"
class="mt-4"
/>
</section>
</div>
</div>
</template>
85 changes: 85 additions & 0 deletions apps/docs/app/components/mdc/McpInstall.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
const runtimeConfig = useRuntimeConfig()
const toast = useToast()

// Kept in step with `mcp.name` in nuxt.config.ts, which the module does not expose to the client.
const serverName = 'shhh docs'

const route = (runtimeConfig.public.mcp as { route?: string } | undefined)?.route || '/mcp'
const serverUrl = `${useRequestURL().origin}${route}`
const copied = ref(false)

// Cursor and VS Code go through the module's own deeplink route; claude.ai only pre-fills its
// dialog, so the user still confirms the URL there — by design, the link comes from outside.
const claudeUrl = `https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=${encodeURIComponent(serverName)}&connectorUrl=${encodeURIComponent(serverUrl)}`

async function copyUrl() {
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' })
}
</script>

<template>
<div class="my-5 flex flex-col gap-4 rounded-lg border border-default bg-elevated/50 p-4">
<div class="flex items-center gap-2">
<code class="min-w-0 flex-1 truncate rounded-md bg-default px-3 py-2 font-mono text-sm text-highlighted ring ring-accented">{{ serverUrl }}</code>

<UButton
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
color="neutral"
variant="subtle"
aria-label="Copy the MCP server URL"
@click="copyUrl"
/>
</div>

<div class="flex flex-wrap gap-2">
<UButton
icon="i-simple-icons-claude"
color="neutral"
variant="outline"
:to="claudeUrl"
external
target="_blank"
>
Add to Claude
</UButton>

<UButton
icon="i-simple-icons-cursor"
color="neutral"
variant="outline"
:to="`${route}/deeplink?ide=cursor`"
external
target="_blank"
>
Add to Cursor
</UButton>

<UButton
icon="i-vscode-icons-file-type-vscode"
color="neutral"
variant="outline"
:to="`${route}/deeplink?ide=vscode`"
external
target="_blank"
>
Add to VS Code
</UButton>
</div>
</div>
</template>
90 changes: 90 additions & 0 deletions apps/docs/content/1.getting-started/2.mcp.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
thoda-dev marked this conversation as resolved.

::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.
::
19 changes: 19 additions & 0 deletions apps/docs/content/4.releases.md
Original file line number Diff line number Diff line change
@@ -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
::
19 changes: 19 additions & 0 deletions apps/docs/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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',
],
},
},
Expand Down
63 changes: 63 additions & 0 deletions apps/docs/server/api/releases.get.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<GitHubRelease[]>('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',
})