Atlas / MCP servers / aeternalabshq / Pullmd

PullmdSAFE

mcp/aeternalabshq/pullmd

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.

Verdict
SAFE
Grade
B
Trust score
86 /100
Exposed tools
3 3r · 0w · 0d
Transport
streamable-http
License
AGPL-3.0
Stars
485
01

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 .md file 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
Read from source at commit 4e8399c05e06OBSERVED · 2026-09-30
02

Connect

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-code
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]
claude-desktop
{
  "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}"
      }
    }
  }
}
03

Exposed tools (3)

3 read · 0 write · 0 destructive.

ToolRiskDescription
get_shareread
list_recentread
read_urlread
04

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.

LayerWhat it checksResult
L0Provenance & inventoryPASS
L1Static analysis of the codeWARN
L2Instruction surface (what it tells the agent)PASS
L3Class-specific surfacePASS
L4Behavioural (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)

MEDIUMInformation disclosure · disclose.log_secret · CWE-209, CWE-532
scripts/admin.js:131
console.log(`Password reset for ${email}.`);
LOWFilesystem / path · fs.traversal · CWE-22, CWE-59
test/fixtures/query-extract/golden/capture.mjs:15
import { createApp } from '../../../../server.js';
LOWFilesystem / path · fs.traversal · CWE-22, CWE-59
test/fixtures/query-extract/golden/capture.mjs:16
import { createCache } from '../../../../lib/cache.js';
LOWNetwork egress · net.beacon_words · CWE-200, CWE-319
test/web.test.js:577
it('keeps images that declare only one tiny dimension (not a 1x1 beacon)', async () => {
LOWNetwork egress · net.metadata_ip · CWE-200, CWE-319
CHANGELOG.md:169
- **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
Why it matters. cloud metadata endpoint: the classic SSRF credential grab
LOWNetwork egress · net.metadata_ip · CWE-200, CWE-319
README.md:598
including `169.254.169.254` (AWS/GCP/Azure) and `100.100.100.200` (Alibaba).
Why it matters. cloud metadata endpoint: the classic SSRF credential grab
LOWNetwork egress · net.metadata_ip · CWE-200, CWE-319
public/help.html:653
<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>,
Why it matters. cloud metadata endpoint: the classic SSRF credential grab
LOWNetwork egress · net.metadata_ip · CWE-200, CWE-319
public/help.html:654
<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>
Why it matters. cloud metadata endpoint: the classic SSRF credential grab
LOWNetwork egress · net.metadata_ip · CWE-200, CWE-319
test/playwright-ssrf.test.js:13
() => renderViaSidecar('http://169.254.169.254/', { fetch: fetchFn }),
Why it matters. cloud metadata endpoint: the classic SSRF credential grab
LOWNetwork egress · net.raw_ip · CWE-200, CWE-319
test/auth-middleware.test.js:30
return await fn(`http://127.0.0.1:${port}`);
LOWNetwork egress · net.raw_ip · CWE-200, CWE-319
test/auth-routes.test.js:28
return await fn(`http://127.0.0.1:${port}`, { auth, cache });
LOWNetwork egress · net.raw_ip · CWE-200, CWE-319
test/auth-signup-switch.test.js:28
return await fn(`http://127.0.0.1:${server.address().port}`, { cache, auth });
LOWNetwork egress · net.raw_ip · CWE-200, CWE-319
test/auth-signup-switch.test.js:154
const base = `http://127.0.0.1:${server.address().port}`;
LOWNetwork egress · net.raw_ip · CWE-200, CWE-319
test/integration-auth.test.js:27
return await fn(`http://127.0.0.1:${server.address().port}`, { auth, cache });
LOWSupply chain · supply.unpinned · CWE-829, CWE-1357
markitdown-sidecar/requirements.txt
youtube-transcript-api, beautifulsoup4
Why it matters. 2 requirement(s) not pinned with ==
Fix. pin exact versions
LOWSupply chain · supply.unpinned · CWE-829, CWE-1357
package.json
@modelcontextprotocol/sdk, @mozilla/readability, archiver, argon2, better-sqlite3, cheerio, express, image-size
Why it matters. 12 dependency range(s) float
Fix. pin exact versions or ship a lockfile
LOWPrompt injection · prompt.credential_read · CWE-94, CWE-1427
README.md:283
Both commands read the password from stdin, so they need it attached:
Why it matters. asks the agent to read credentials
LOWPrompt injection · prompt.credential_read · CWE-94, CWE-1427
public/help.html:1050
<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
Why it matters. asks the agent to read credentials
LOWPrompt injection · prompt.transfer_instruction · CWE-94, CWE-1427
CHANGELOG.md:68
- **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 
Why it matters. an instruction to move sensitive data to an outside destination
Fix. remove; a skill never needs the user's secrets off the machine
LOWPrompt injection · prompt.transfer_instruction · CWE-94, CWE-1427
CHANGELOG.md:214
- **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
Why it matters. an instruction to move sensitive data to an outside destination
Fix. remove; a skill never needs the user's secrets off the machine
LOWPrompt injection · prompt.transfer_instruction · CWE-94, CWE-1427
CHANGELOG.md:287
- Token endpoint (`POST /oauth/token`) with `authorization_code` and `refresh_token` grants. Refresh tokens are rotated on every refresh; reuse triggers chain-wide invalidation.
Why it matters. an instruction to move sensitive data to an outside destination
Fix. remove; a skill never needs the user's secrets off the machine
LOWPrompt injection · prompt.transfer_instruction · CWE-94, CWE-1427
public/help.html:1002
<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
Why it matters. an instruction to move sensitive data to an outside destination
Fix. remove; a skill never needs the user's secrets off the machine

Gates applied: no_behavioural_pass.

Audited 2026-09-30 · audit v0.4.1 · source sha 4e8399c05e06full audit observations/trust-audit/mcp-server/aeternalabshq__pullmd.json · Report an issue / request a re-scan
05

Audit history

Every audit this server has had. A grade with a past is a grade somebody is still checking.

DateSourceVerdictGradeScoreChange
2026-09-304e8399c05e06SAFEB86first audit
06

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.

Advertisement