PullmdSAFE
Self-hosted URL- and file-to-Markdown service for humans and AI agents - web pages, documents, images, audio, YouTube. PWA + REST + MCP + Claude Code skill, Reddit-aware, refreshable share links.
Overview
From the repository's own README, as read at the audited commit. Badges and raw HTML are left out.
[](https://github.com/AeternaLabsHQ/pullmd/releases) [](https://hub.docker.com/r/aeternalabshq/pullmd) [](https://github.com/AeternaLabsHQ/pullmd/actions/workflows/docker.yml) [](https://github.com/AeternaLabsHQ/pullmd/blob/main/LICENSE) [](https://github.com/AeternaLabsHQ/pullmd#mcp-server)
Self-hosted URL-to-Markdown service for humans and AI agents.
PullMD takes any web URL and returns clean, readable Markdown — no navigation, no ads, no boilerplate. It auto-detects Reddit and Hacker News threads (with full comment trees), uses Cloudflare's native Markdown when available, runs Mozilla Readability + Trafilatura on static HTML, and as a last resort renders JavaScript-heavy pages via headless Chromium (Playwright sidecar) before extracting.
As of v3, PullMD goes beyond web pages: it also converts documents (PDF, Office, EPUB), images, audio, and YouTube videos to Markdown, and emits a leaner, token-efficient body by default. See What's new in v3 below.
It ships as:
- a PWA frontend with raw/rendered and live-frontmatter view toggles, one-tap sharing of the output to other apps (Web Share API), a download button that saves the result as a
.mdfile under the server-suggested name, dark/paper themes, history, archive, share links, and conversion of local HTML files (drag-and-drop on desktop, file picker on desktop and mobile) - a
4e8399c05e06OBSERVED · 2026-09-30Connect
Built from this server's own package name, version and transport as found in its source — not copied from anyone's documentation, so it cannot drift against a page we do not control. Replace the environment placeholders with a token scoped to the least it needs.
claude mcp add pullmd --env OAUTH_JWT_SECRET=${OAUTH_JWT_SECRET} --env PULLMD_AUTH_MODE=${PULLMD_AUTH_MODE} --env PULLMD_LLM_API_KEY=${PULLMD_LLM_API_KEY} --env PULLMD_PDF_OCR_API_KEY=${PULLMD_PDF_OCR_API_KEY} -- npx -y [email protected]{
"mcpServers": {
"pullmd": {
"command": "npx",
"args": [
"-y",
"[email protected]"
],
"env": {
"OAUTH_JWT_SECRET": "${OAUTH_JWT_SECRET}",
"PULLMD_AUTH_MODE": "${PULLMD_AUTH_MODE}",
"PULLMD_LLM_API_KEY": "${PULLMD_LLM_API_KEY}",
"PULLMD_PDF_OCR_API_KEY": "${PULLMD_PDF_OCR_API_KEY}"
}
}
}
}Exposed tools (3)
3 read · 0 write · 0 destructive.
| Tool | Risk | Description |
|---|---|---|
get_share | read | |
list_recent | read | |
read_url | read |
Trust audit
SAFEgrade B · trust 86/100 Nothing in the source contradicts what it says it does. Grade A is reserved for packages that have also passed the behavioural sandbox.
| Layer | What it checks | Result |
|---|---|---|
| L0 | Provenance & inventory | PASS |
| L1 | Static analysis of the code | WARN |
| L2 | Instruction surface (what it tells the agent) | PASS |
| L3 | Class-specific surface | PASS |
| L4 | Behavioural (sandbox) | SKIPPED |
What the source does
- Filesystem
- none-observed
- Network
- declared (3 observation(s))
- Shell
- none-observed
- Dependencies
- not all pinned
- Secrets in source
- none-found
Findings (22)
console.log(`Password reset for ${email}.`);import { createApp } from '../../../../server.js';import { createCache } from '../../../../lib/cache.js';it('keeps images that declare only one tiny dimension (not a 1x1 beacon)', async () => {- **Block Server-Side Request Forgery (SSRF)** (closes #41). The URL-fetch endpoints (`GET /api`, the MCP `read_url` tool, and the Reddit/web/Playwright fetch paths behind them) now resolve the target
including `169.254.169.254` (AWS/GCP/Azure) and `100.100.100.200` (Alibaba).
<p class="muted" lang="de">Zum Schutz vor Server-Side Request Forgery (SSRF) werden Anfragen an private, loopback-, link-local-, CGNAT- und Cloud-Metadata-Adressen (z. B. <code>169.254.169.254</code>,
<p class="muted" lang="en">To guard against Server-Side Request Forgery (SSRF), requests to private, loopback, link-local, CGNAT and cloud-metadata addresses (e.g. <code>169.254.169.254</code>, <code>
() => renderViaSidecar('http://169.254.169.254/', { fetch: fetchFn }),return await fn(`http://127.0.0.1:${port}`);return await fn(`http://127.0.0.1:${port}`, { auth, cache });return await fn(`http://127.0.0.1:${server.address().port}`, { cache, auth });const base = `http://127.0.0.1:${server.address().port}`;return await fn(`http://127.0.0.1:${server.address().port}`, { auth, cache });youtube-transcript-api, beautifulsoup4
@modelcontextprotocol/sdk, @mozilla/readability, archiver, argon2, better-sqlite3, cheerio, express, image-size
Both commands read the password from stdin, so they need it attached:
<p class="muted" lang="en">Without Docker the same command runs as <code>node scripts/admin.js ...</code> in the instance's directory. <strong>Important:</strong> <code>create-user</code> and <code>re
- **The server now suggests the download filename** (`X-Suggested-Filename`). The name used to be derived in the browser from the frontmatter title alone, which gave a YouTube video its cryptic id as
- **Opt-in media tier** (`PULLMD_VISION_*` / `PULLMD_STT_*`). Image captioning and audio transcription (Whisper STT) run inside pullmd itself - no markitdown container needed. Per-modality or shared O
- Token endpoint (`POST /oauth/token`) with `authorization_code` and `refresh_token` grants. Refresh tokens are rotated on every refresh; reuse triggers chain-wide invalidation.
<li><strong>API key</strong> - create one at <code>/settings</code> and send it as <code>Authorization: Bearer pmd_...</code>. The key is shown once; only its hash is kept server-side. This is the pat
Gates applied: no_behavioural_pass.
4e8399c05e06full audit observations/trust-audit/mcp-server/aeternalabshq__pullmd.json · Report an issue / request a re-scanAudit history
Every audit this server has had. A grade with a past is a grade somebody is still checking.
| Date | Source | Verdict | Grade | Score | Change |
|---|---|---|---|---|---|
| 2026-09-30 | 4e8399c05e06 | SAFE | B | 86 | first audit |
Questions
What is the Pullmd MCP server?
Self-hosted URL- and file-to-Markdown service for humans and AI agents - web pages, documents, images, audio, YouTube. PWA + REST + MCP + Claude Code skill, Reddit-aware, refreshable share links.
What tools does Pullmd expose?
3 in total: 3 read-only, 0 that write, and 0 that can delete or overwrite. Every one is listed on this page with its risk.
Is Pullmd safe to connect to an agent?
The audit found nothing in the source that contradicts what it says it does, and graded it B (86/100). Grade A is held back for packages that have also passed a sandboxed behavioural run, which is why a clean server reads B.
What credentials does Pullmd need?
It reads OAUTH_JWT_SECRET, PULLMD_AUTH_MODE, PULLMD_LLM_API_KEY, PULLMD_PDF_OCR_API_KEY, PULLMD_STT_API_KEY and PULLMD_VISION_API_KEY from the environment. Give it a token scoped to the least it needs — an agent that can be talked into calling a tool can be talked into calling it with your credentials.
How does Pullmd run?
It speaks streamable-http, so it runs as a service you connect to over the network. It is published on npm as pullmd at 3.12.0.
How current is this page?
The grade is for one exact copy of the source (4e8399c05e06), read on 2026-09-30. The repository is watched and re-audited when it changes.