# pdf-triage-mcp [![npm](https://img.shields.io/npm/v/pdf-triage-mcp.svg)](https://www.npmjs.com/package/pdf-triage-mcp) [![CI](https://github.com/vishalmeena2211/pdf-triage-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/vishalmeena2211/pdf-triage-mcp/actions/workflows/ci.yml) [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-io.github.vishalmeena2211%2Fpdf--triage--mcp-blue)](https://registry.modelcontextprotocol.io) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) An MCP server that lets any AI tool read local PDFs — **without uploading them, without an OCR bill, and without silently handing back garbage.** Built on [`@firecrawl/pdf-inspector`](https://github.com/firecrawl/pdf-inspector) (Rust, no ML models, no external services). ``` ┌─ pdf_classify ──→ text_based · 0.98 · 12 pages · 0 need OCR ~20ms ├─ pdf_search ──→ "invoice total" found on p4, p9 ~150ms ├─ pdf_extract ──→ clean Markdown, truncated to your budget ~150ms └─ pdf_tables ──→ just the pipe tables, no prose ~150ms ``` --- ## Table of contents - [Why this exists](#why-this-exists) - [Install](#install) - [Connect it to your AI tool](#connect-it-to-your-ai-tool) — 12 clients - [Tools](#tools) - [Configuration](#configuration) - [Engine fallback](#engine-fallback) - [Known limitations](#known-limitations) - [Development](#development) --- ## Why this exists Most PDF tooling has the same failure mode: **it returns confident text regardless of whether extraction actually worked.** Broken CID fonts, substitution-cipher encodings, scanned pages with no text layer — you get plausible-looking output and find out downstream, if at all. `pdf-inspector` is unusually good at *knowing* when it failed. It emits `U+FFFD` rather than guessing at an unmapped CID, runs substitution-cipher detection over its own output, and reclassifies a document as scanned when extracted text drops below 50% alphanumeric. But it stops at reporting those findings on a result object — and most wrappers throw them away. **This server acts on them.** Every response carries the trust signals, above the content, where the model reads them first: ```markdown > [!WARNING] ENCODING ISSUES DETECTED. The text layer decoded to suspicious > output — typically a garbled CID font or a substitution-cipher encoding > where letter frequencies match natural language but the letters themselves > are wrong. Treat all extracted text here as unreliable and prefer OCR. --- # Quarterly Report ... ``` Three design rules follow: 1. **Classify before extracting.** `pdf_classify` costs ~20ms and tells you whether extraction is worth attempting at all. 2. **Bound every output.** A 300-page PDF is easily 500k tokens. Everything truncates by default and tells you how to page through instead. 3. **Confine every path.** A model that has just read an untrusted document must not be talkable into reading `~/.ssh/id_rsa`. Enforced in code, not left to the model's judgement. --- ## Install **Nothing to install.** Every config below runs the published package straight from npm: ``` npx -y pdf-triage-mcp --root /path/to/your/documents ``` Your MCP client runs that for you — you only need to paste the config. Confirm it works first: ```bash npx -y pdf-triage-mcp --version ``` **Requires Node 20+.** Available on npm as [`pdf-triage-mcp`](https://www.npmjs.com/package/pdf-triage-mcp) and in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.vishalmeena2211/pdf-triage-mcp`.
From source instead (for development) ```bash git clone https://github.com/vishalmeena2211/pdf-triage-mcp.git cd pdf-triage-mcp npm install npm run build node dist/index.js --root ~/Documents ``` Then substitute `"command": "node", "args": ["/absolute/path/to/dist/index.js", ...]` for the `npx` invocation in any config below.
--- ## Connect it to your AI tool Every config below is complete as written except for one value: - **`/Users/me/Documents`** — replace with the directory the server may read. This is the only thing you must change. Use an absolute path; `~` is not expanded by most clients. Repeat `--root` for multiple directories. > **PATH gotcha, applies to every GUI client below.** Desktop apps launch servers with a minimal environment, so bare `npx` often fails to resolve even though it works in your terminal. If the server won't start, substitute the absolute path — find it with `which npx` (commonly `/opt/homebrew/bin/npx` on Apple Silicon, `/usr/local/bin/npx` on Intel macOS). > **Why `-y`?** It skips npx's install confirmation prompt. Without it, a first run can hang waiting for input that an MCP client cannot provide — the server appears to start and then silently times out.
1. Claude Desktop **Config file** | OS | Path | |---|---| | macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | | Windows | `%APPDATA%\Claude\claude_desktop_config.json` | | Linux | `~/.config/Claude/claude_desktop_config.json` | ```json { "mcpServers": { "pdf-triage": { "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ] } } } ``` **Verify:** Fully quit and relaunch Claude Desktop (not just close the window). A tools icon appears near the chat input — click it and confirm the four `pdf_*` tools are listed. Logs: `~/Library/Logs/Claude/mcp*.log` (macOS), `%APPDATA%\Claude\logs\mcp*.log` (Windows). [Docs](https://modelcontextprotocol.io/quickstart/user)
2. Claude Code **CLI — the easiest route.** The `--` separator is mandatory; everything after it is the server command. ```bash # Just you, this project (default) claude mcp add pdf-triage -- npx -y pdf-triage-mcp --root /Users/me/Documents # Just you, every project claude mcp add --scope user pdf-triage -- npx -y pdf-triage-mcp --root /Users/me/Documents # Shared with your team, writes .mcp.json to the repo claude mcp add --scope project pdf-triage -- npx -y pdf-triage-mcp --root /Users/me/Documents ``` **Or edit `.mcp.json` at the project root directly:** ```json { "mcpServers": { "pdf-triage": { "type": "stdio", "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ] } } } ``` | Scope | Stored in | Shared | |---|---|---| | `local` (default) | `~/.claude.json`, under this project | No | | `user` | `~/.claude.json`, top level | No | | `project` | `.mcp.json` in repo root | Yes, via git | **Verify:** `claude mcp list` → look for `✔ Connected`. Project-scoped servers need approval on first use — run `/mcp` inside a session. [Docs](https://code.claude.com/docs/en/mcp)
3. Cursor **Config file:** `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global). Project wins on conflict. ```json { "mcpServers": { "pdf-triage": { "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ] } } } ``` **Verify:** Cursor hot-reloads — no restart. Open **Cursor Settings → Tools & MCP** and look for a green dot next to `pdf-triage`. [Docs](https://docs.cursor.com/context/model-context-protocol)
4. Windsurf **Config file:** `~/.codeium/windsurf/mcp_config.json` (macOS/Linux), `%USERPROFILE%\.codeium\windsurf\mcp_config.json` (Windows). Not created on first launch — create it yourself if missing. ```json { "mcpServers": { "pdf-triage": { "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ] } } } ``` **Verify:** Windsurf watches the file and hot-reloads on save. Tools appear in Cascade on the next chat session. [Docs](https://docs.windsurf.com/windsurf/mcp)
5. VS Code + GitHub Copilot > **The key is `servers`, not `mcpServers`.** This is the most common mistake when copying a config from Claude Desktop. **Config file:** `.vscode/mcp.json` (workspace), or Command Palette → **MCP: Open User Configuration** (global). ```json { "servers": { "pdf-triage": { "type": "stdio", "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ] } } } ``` **CLI alternative:** ```bash code --add-mcp '{"name":"pdf-triage","command":"npx","args":["-y","pdf-triage-mcp","--root","/Users/me/Documents"]}' ``` **Verify:** MCP tools only work in **Agent mode** — switch from Ask/Edit to Agent in Copilot Chat, then click **Configure Tools** and confirm the `pdf_*` tools appear. Restart VS Code after first adding the file. [Docs](https://code.visualstudio.com/docs/agent-customization/mcp-servers)
6. Zed > **The key is `context_servers`, not `mcpServers`,** and `command` is a nested object rather than a string. **Config file:** `~/.config/zed/settings.json` (macOS/Linux), `%APPDATA%\Zed\settings.json` (Windows). Command Palette → **zed: open settings**. ```json { "context_servers": { "pdf-triage": { "source": "custom", "command": { "path": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ], "env": {} } } } } ``` If your Zed version rejects that, it predates the nested form — try `command`, `args` and `env` flat at the top level of the server object instead. **Verify:** Agent Panel (`Cmd+Shift+A`) → gear icon → **MCP Servers**. Green dot means connected. [Docs](https://zed.dev/docs/ai/mcp)
7. Cline (VS Code extension) **Config file** — separate from VS Code's own: | OS | Path | |---|---| | macOS | `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | | Windows | `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json` | | Linux | `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` | ```json { "mcpServers": { "pdf-triage": { "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ], "disabled": false, "autoApprove": ["pdf_classify", "pdf_search"] } } } ``` `autoApprove` runs the listed read-only tools without a confirmation prompt. **Easier route:** Cline panel → MCP servers icon → **Edit MCP Settings** opens this file directly. **Verify:** Panel refreshes automatically; green dot next to the server. [Docs](https://docs.cline.bot/mcp/mcp-overview)
8. Continue.dev **Config file:** `~/.continue/config.yaml` (global) or `.continue/config.yaml` (project). YAML is current; `config.json` is deprecated. > `mcpServers` here is a **list**, not an object — and YAML needs spaces, never tabs. ```yaml mcpServers: - name: pdf-triage command: npx args: - -y - pdf-triage-mcp - --root - /Users/me/Documents ``` **Verify:** Reloads automatically on save. Switch Continue to **Agent mode** — MCP tools are unavailable in other modes. [Docs](https://docs.continue.dev/customize/deep-dives/mcp)
9. Google Gemini CLI **CLI:** ```bash gemini mcp add pdf-triage npx -y pdf-triage-mcp --root /Users/me/Documents # global instead of project-scoped gemini mcp add --scope user pdf-triage npx -y pdf-triage-mcp --root /Users/me/Documents ``` **Or edit `~/.gemini/settings.json` (global) / `.gemini/settings.json` (project):** ```json { "mcpServers": { "pdf-triage": { "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ], "timeout": 30000, "trust": false } } } ``` **Verify:** Run `/mcp` inside a `gemini` session — servers show `CONNECTED` with their tool list. Or `gemini mcp list` from the shell. [Docs](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html)
10. OpenAI Codex CLI **Config file:** `~/.codex/config.toml` (global) or `.codex/config.toml` (project). > TOML, and the key is `mcp_servers` — snake_case, never `mcpServers`. ```toml [mcp_servers.pdf-triage] command = "npx" args = [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ] startup_timeout_sec = 20 tool_timeout_sec = 60 ``` **Verify:** `codex doctor --json` validates the config syntax. Note that it validates syntax only — it does not confirm the server actually spawned. > **Known upstream issue:** several Codex CLI versions have a bug where stdio servers validate cleanly but silently fail to start, showing `Tools: none` in the TUI ([#3441](https://github.com/openai/codex/issues/3441), [#26810](https://github.com/openai/codex/issues/26810)). That is a Codex runtime bug, not a config error. [Docs](https://learn.chatgpt.com/docs/extend/mcp?surface=cli)
11. JetBrains AI Assistant / Junie **AI Assistant** — configured through the IDE, no file to edit: 1. **Settings → Tools → AI Assistant → Model Context Protocol (MCP)** 2. **Add** → transport **STDIO** 3. Paste: ```json { "mcpServers": { "pdf-triage": { "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ] } } } ``` 4. **OK → Apply** **Junie** uses a file instead — `~/.junie/mcp/mcp.json` (global) or `.junie/mcp/mcp.json` (project), same JSON shape. **Verify:** Check the **Status** column in the MCP settings panel; click it to list the server's tools. [Docs](https://www.jetbrains.com/help/ai-assistant/mcp.html)
12. LM Studio **Config file:** `~/.lmstudio/mcp.json` (macOS/Linux), `%USERPROFILE%\.lmstudio\mcp.json` (Windows). Easier via the app: right sidebar → **Program** tab → **Install → Edit mcp.json**. ```json { "mcpServers": { "pdf-triage": { "command": "npx", "args": [ "-y", "pdf-triage-mcp", "--root", "/Users/me/Documents" ] } } } ``` **Verify:** Auto-reloads on save; tools appear in the Program panel. LM Studio shows a confirmation dialog the first time a model calls a tool. [Docs](https://lmstudio.ai/docs/app/mcp)
### Cheat sheet | Client | File | Top-level key | Restart? | |---|---|---|---| | Claude Desktop | `claude_desktop_config.json` | `mcpServers` | Full quit | | Claude Code | `.mcp.json` / CLI | `mcpServers` | No | | Cursor | `.cursor/mcp.json` | `mcpServers` | No | | Windsurf | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` | No | | VS Code Copilot | `.vscode/mcp.json` | **`servers`** | First time | | Zed | `~/.config/zed/settings.json` | **`context_servers`** | No | | Cline | `cline_mcp_settings.json` | `mcpServers` | No | | Continue.dev | `~/.continue/config.yaml` | `mcpServers` (**list**) | No | | Gemini CLI | `~/.gemini/settings.json` | `mcpServers` | No | | Codex CLI | `~/.codex/config.toml` | **`[mcp_servers.*]`** | N/A | | JetBrains | IDE settings UI | `mcpServers` | No | | LM Studio | `~/.lmstudio/mcp.json` | `mcpServers` | No | **The three that differ:** VS Code (`servers`), Zed (`context_servers` + nested `command`), Codex (TOML `mcp_servers`). Everything else takes the Claude Desktop format verbatim. --- ## Tools | Tool | Cost | Purpose | |---|---|---| | **`pdf_classify`** | ~20ms | Type, confidence, page count, exact pages needing OCR. **Call this first.** | | **`pdf_extract`** | ~150ms | PDF → Markdown. Truncates by default; slice with `pages`. | | **`pdf_search`** | ~150ms | Locate text, return page-attributed snippets. Cheapest way into a long document. | | **`pdf_tables`** | ~150ms | Tables only, as Markdown pipe tables. | Full parameter reference: [docs/TOOLS.md](docs/TOOLS.md). **The intended flow** on an unfamiliar document: ``` pdf_classify → is it text_based with no warnings? ├─ yes → pdf_search to locate → pdf_extract with `pages` └─ no → stop; route to OCR ``` --- ## Configuration ``` pdf-triage-mcp [options] -r, --root Directory the server may read. Repeatable. Default: cwd. --max-chars Default truncation ceiling. Default: 40000. Max: 200000. --max-file-bytes Largest PDF to read. Default: 104857600 (100 MB). --log-level debug | info | warn | error | silent. Default: info. -h, --help Show usage. -v, --version Print version. ``` Environment equivalents: `PDF_TRIAGE_ROOTS` (separated by the platform PATH delimiter — `:` on macOS/Linux, `;` on Windows), `PDF_TRIAGE_MAX_CHARS`, `PDF_TRIAGE_MAX_FILE_BYTES`, `PDF_TRIAGE_LOG_LEVEL`. Flags win over environment. **Roots are a security boundary, not a convenience.** Grant the narrowest directory that works. Paths are resolved through symlinks before checking, so a link inside a root pointing outside it is rejected rather than followed. --- ## Engine fallback Upstream ships prebuilt native binaries for exactly three targets: `linux-x64-gnu`, `darwin-arm64`, `win32-x64-msvc`. **No musl build, no Linux ARM64 build** ([upstream #216](https://github.com/firecrawl/pdf-inspector/issues/216)) — so it fails to load on Alpine containers, Graviton instances, and most edge runtimes. This server prefers native and falls back to WASM, which runs anywhere. Capability differences are **surfaced, never faked**: | | Native | WASM | |---|---|---| | Classify / extract | Yes | Yes | | Per-page extraction | Yes | No — throws, and `pdf_search` reports its matches are unattributed | | `pages` selection | Yes | No — ignored, and the response says so | Check which engine you got: `pdf_classify` reports it, and the server logs `engine selected` at startup. --- ## Known limitations Inherited from upstream. Worth reading before you trust output: - **RTL scripts are broken.** Arabic and Hebrew return in *visual* order, reversed and unusable, while being reported as `text_based` with high confidence ([#212](https://github.com/firecrawl/pdf-inspector/issues/212)). **This server detects and escalates it** — the one upstream failure mode we actively guard. - **No xref recovery.** Malformed PDFs that `pypdf` and `pdfium` silently repair will throw ([#228](https://github.com/firecrawl/pdf-inspector/issues/228)). - **Japanese CIDFontType0 (CFF)** subset fonts can decode to unrelated glyphs ([#208](https://github.com/firecrawl/pdf-inspector/issues/208)). - **Multi-column reading order** may emit in raster order on some layouts despite columns being detected ([#219](https://github.com/firecrawl/pdf-inspector/issues/219)). - **Images are not PDFs.** A scanned JPEG has no text layer; the server rejects non-PDF input rather than pretending otherwise. --- ## Development ```bash npm run typecheck # tsc --noEmit, maximum strictness npm test # vitest, 108 tests npm run test:coverage npm run build npm run dev # tsx, no build step ``` The TypeScript config runs every strictness flag including `exactOptionalPropertyTypes` and `noUncheckedIndexedAccess`. Upstream responses are validated with Zod at the boundary rather than cast — see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for why. Debug a client connection: ```bash node dist/index.js --root ~/Documents --log-level debug ``` Troubleshooting: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). --- ## Roadmap - [ ] `pdf_regions` — bbox-scoped extraction for hybrid model pipelines - [ ] Positioned-item tool exposing `{page, bbox}` for visual citation UX - [ ] Integration tests asserting native and WASM produce identical normalized shapes - [ ] Optional OCR adapter interface, closing the routing loop end to end ## License MIT