KlarPDF MCP bridge
KlarPDF's PDF engine as MCP tools — for Claude Code, Claude Desktop, Codex CLI, Gemini CLI, and any other client that speaks MCP over stdio. Twenty-three tools: read a document without pulling it whole into context; work out its structure from its headings, links and tables, and write that back as bookmarks; transform it by splitting, merging, reordering, rotating, deleting pages, filling forms and annotating; and redact it destructively with cross-engine verification.
Quick setup
Claude Code, on Linux or macOS:
uv tool install klarpdf # pipx: pipx install klarpdf
claude mcp add --scope user klarpdf -- ~/.local/bin/klarpdf-mcp
That is the whole install. --scope user is the part worth not dropping — without it
claude mcp add registers the server for the current directory alone
(scopes). The first line is the same everywhere — only the path and the client
command change: on Windows where klarpdf-mcp prints the path to use (and if it prints nothing, see
PATH), Codex CLI and Gemini CLI take their own mcp add, and
Claude Desktop installs a bundle rather than a command.
The quick-setup guide
has each of those combinations written out: every client, on every OS.
Contents
Everything below is the reference — what each tool guarantees, how to restrict what the server may touch, and what redaction does and does not promise. Already set up, and here to use a tool? Go straight to The tools.
Setting it up
- Quick setup, above, and the quick-setup guide for every client and OS
- What it is, and what it is not
- What you need
- Install: if it says
command not found· with neitheruvnorpipx· from a clone - Connect it to your client: Claude Code · Claude Desktop · Codex CLI · Gemini CLI · any other client
- Limiting what the server can do
Using the tools
- The tools: every tool, grouped into read, transform and redact
- Password-protected PDFs
- Reading a tool's full contract: the
klarpdf://docs/…reference pages
What it is, and what it is not
It is independent of the KlarPDF app and needs no GUI components. It runs on macOS, Linux and Windows.
Your PDFs stay where they are. An agent works on the file on your disk — nothing is uploaded and no third-party service sees it, which is the point of running the engine locally rather than sending documents somewhere to be processed. The server itself makes no network connections: stdio is the only transport and there is no listening port. What a tool returns — a page's text, a rendered image — goes back to your model like any other tool result, so the usual care about what you hand a hosted model still applies.
It does not understand your documents. Every tool here is mechanical: search matches literal
text rather than meaning, extract_text returns what is on the page rather than a summary, and
annotate and the redactions take boxes and strings rather than questions. Working out what a
clause means, which name matters, or what ought to be removed is the model's job — this server's job
is to hand it accurate material and then do exactly what it is told.
What you need
uv |
What the examples here use. It puts the bridge in an environment of its own — which is what you want, because it pins all 29 of its dependencies exactly and will conflict with anything you co-install. uv is a single binary rather than a Python program, so it is also the one option that does not presuppose the next row. |
| Python 3.11 – 3.14 | Any version in that range; each is tested in CI on Linux and Windows. uv will fetch one if you have none that fits — uv python list shows what it can download. Every other route needs you to already have it. |
or pipx |
Does the same job and is equally supported; every uv command below names its pipx equivalent. It is a Python application itself, so it needs a suitable Python first, and it cannot launch the Claude Desktop bundle (next row). |
uv for Claude Desktop, specifically |
The .mcpb bundle is launched with uv run, so there uv is required rather than preferred — and it must be on the PATH Desktop sees, which is not always the PATH your terminal has. Nothing warns you if it is missing; the symptom is the server failing to start. |
| An MCP client | The app your AI assistant runs in — Claude Code, Claude Desktop, Codex CLI, Gemini CLI and others. It starts klarpdf-mcp as a local subprocess and relays the model's tool calls to it. |
poppler-utils |
Optional. Adds a second, independent engine to redaction's verification step. Without it, redaction still verifies — with PyMuPDF alone. See What redaction guarantees. |
Neither installer is required. install.py below needs only the
Python you already have, and plain pip works too, into a virtualenv you made for it. uv leads
here because it is the one route that also covers Claude Desktop and can supply the Python.
No GUI toolkit is installed. The bridge's only dependencies are PyMuPDF and the MCP SDK.
Install
The two lines at the top of this page are the whole install. uv tool install puts the bridge in
an isolated environment and its klarpdf-mcp command in uv's bin directory, so nothing else on
your machine is touched; pipx install klarpdf does the same job. To try it once without installing
anything, uvx --from klarpdf klarpdf-mcp --help runs it straight from the package.
The rest of this section is the detail behind that — what you get, what to avoid, and how to work from a clone instead.
Every install gets the exact versions we test and scan. The published package pins all 29 of
its dependencies at ==, rather than declaring floors the way a library would — because this is an
application, and an application installed into an environment of its own has nothing to conflict
with. So uv tool install klarpdf resolves to the set our CI runs against and our weekly audit
scans, not to whatever happens to be newest that day.
The flip side, worth knowing before you reach for plain pip: those exact pins mean
pip install klarpdf into a shared environment will conflict loudly with anything wanting a
different version. That is the intended behaviour, not a bug. If you use pip, give it a
virtualenv of its own.
Check that it worked, and note the path — you will need it in a moment:
klarpdf-mcp --help
which klarpdf-mcp # Windows: where klarpdf-mcp
# -> ~/.local/bin/klarpdf-mcp
If those say command not found
Then the install worked and its bin directory is not on your PATH — the most common thing to go
wrong here, and not really about this package. uv tool and pipx install into a directory of
their own (~/.local/bin on Linux and macOS), and putting that directory on your PATH is a
separate, one-time step. Both tools warn you when it is missing, in the middle of the install
output where it scrolls past:
/home/you/.local/binis not on your PATH. To use installed tools, runuv tool update-shellor add the directory to your PATH.
uv tool update-shell # pipx: pipx ensurepath
Then start something new — the session you ran it in will never see it. These commands edit your
shell profile (Linux, macOS) or your user PATH variable (Windows), and a process keeps the
environment it was started with. pipx puts it plainly: "You will need to open a new terminal or
re-login for the PATH changes to take effect." Which means, concretely:
| What | What to do about it |
|---|---|
| The terminal you installed from | Open a new one. On Linux and macOS, source ~/.bashrc — or whichever file your shell reads — updates the current one instead. Windows has no equivalent: open a new terminal. |
| A client that is already running | Quit and reopen it. Claude Desktop, your editor, a claude session you started earlier — each holds the PATH it was launched with, and opening a new terminal does not restart them. |
| Windows specifically | update-shell sets the variable for new processes. Anything started from an icon or a login item before that keeps the old value; signing out and back in is the blunt fix that always works. |
None of this is required to run the bridge. PATH is a convenience for typing klarpdf-mcp
yourself. Every client below is given an absolute path, which works whether or not PATH knows
about it — and you can read that path off without PATH at all:
uv tool dir --bin # -> /home/you/.local/bin
# pipx: pipx environment --value PIPX_BIN_DIR
klarpdf-mcp is inside whichever directory that prints — klarpdf-mcp.exe on Windows.
With neither uv nor pipx
Download install.py from the latest release
and run it with any supported Python:
curl -LO https://github.com/utyagi24/klarpdf/releases/latest/download/install.py
curl -LO https://github.com/utyagi24/klarpdf/releases/latest/download/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS # macOS: shasum -a 256 -c
python3 install.py # Windows: py -3 install.py
It makes a virtual environment of its own, installs into it, proves the server starts, and prints
the exact line your client needs — then writes an uninstall.py beside it. It touches nothing else:
not your PATH, not your shell profile, not your system Python.
--client claude-code also registers the server for you, using that client's own CLI. It requires
--client-scope alongside it — there is no default, because the two on offer are both wrong: a
client's own default is the directory install.py is running in, which is wherever you downloaded
it to, and user would write a config file you never named. So:
python3 install.py --client claude-code --client-scope user
--client-scope takes local, user or project for Claude Code and user or project for
Gemini CLI, and is not accepted with --client codex, which has no scopes.
Claude Desktop is not a --client value — it has no CLI, and install.py does not edit another
application's config file. --print-config claude-desktop gives you the JSON block and names the
claude_desktop_config.json to paste it into; it works for the other clients too, and installs
nothing, so it is also how you get the line back later:
python3 install.py --print-config claude-desktop
--help carries the scope table and worked examples; --install-dir, --reinstall and
--index-url are there too.
The checksum step is worth the two extra lines. curl … | python3 - works and the file says so when
you do it, but there is then no file to verify.
From a clone instead
For working on the bridge, or for pinning to a specific tag:
git clone https://github.com/utyagi24/klarpdf.git
cd klarpdf
git checkout v<version> # a release tag; `git tag` lists them
python3 -m venv .venv # Windows: py -3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements-mcp.txt # the exact versions we test against
pip install -e . # puts `klarpdf-mcp` inside the virtualenv
Run both pip lines, in that order — the first installs the audited set, the second installs
klarpdf-mcp itself with its requirements already satisfied. which klarpdf-mcp then reports
/path/to/klarpdf/.venv/bin/klarpdf-mcp, which is the path your client needs, since a virtualenv
puts nothing on the PATH your client sees.
You will not normally run klarpdf-mcp yourself — your client starts it and talks to it through
the subprocess's stdin and stdout.
Connect it to your client
Every client has its own way of configuring MCP servers, but they all ask for a command to run.
For our MCP server that command is klarpdf-mcp.
That command is the whole of KlarPDF's entry in that list. There is nothing else to fill in: no
arguments, no environment variables, and no URL, port or token — those belong to servers that run
over a network, and this one does not. Your client starts klarpdf-mcp as a subprocess and the two
talk over its stdin and stdout. (Two optional switches can restrict what it is allowed to do — see
Limiting what the server can do.)
Use the full path that which klarpdf-mcp printed. Once its bin directory is
on your PATH, klarpdf-mcp resolves by name and the bare name
often works — but often is the problem: a client
launched from an icon or a login item inherits a different PATH from your terminal, and the symptom
is a server that simply fails to start with nothing explaining why. The absolute path always works,
so the examples below use one. Substitute yours:
| Installed with | Find it with | Typically |
|---|---|---|
uv tool / pipx |
which klarpdf-mcp · Windows: where klarpdf-mcp. Prints nothing? It is not on your PATH — uv tool dir --bin (pipx: pipx environment --value PIPX_BIN_DIR) names the directory regardless |
~/.local/bin/klarpdf-mcp on Linux and macOS. On Windows it depends on where your uv/pipx puts its shims, so read it off rather than assuming |
| a clone + virtualenv | the same, with the virtualenv active | /path/to/klarpdf/.venv/bin/klarpdf-mcp — Windows: ...\.venv\Scripts\klarpdf-mcp.exe |
Claude Code
Install first (above), then:
claude mcp add --scope user klarpdf -- ~/.local/bin/klarpdf-mcp
--scope user is doing real work there. Drop it and claude mcp add falls back to its default,
--scope local — the current directory alone, which is almost never what you want from this
server: the bridge works on any PDF anywhere, while a local-scope entry follows you into exactly one
project. A uv tool or pipx install is one command at one absolute path, good from any directory,
so register it once for the whole account. Three scopes are available:
--scope |
Stored in | Applies to |
|---|---|---|
local (default) |
~/.claude.json, under the current directory |
you, in that one directory |
user |
~/.claude.json, at the top level |
you, in every directory |
project |
.mcp.json committed in the repo |
anyone who clones it |
Read the line as three parts, and the order matters. Flags for claude come first
(--scope user); then the name the server appears under in /mcp (klarpdf); then --, after
which everything is the command Claude Code will run. The -- is what keeps the two apart — a
flag written after it is handed to klarpdf-mcp, so claude mcp add klarpdf --scope user -- … does
not set a scope, it passes --scope user to a server that has no such option.
The same line on Windows, with the path where klarpdf-mcp printed, quoted because it can contain
spaces:
claude mcp add --scope user klarpdf -- "C:\Users\you\.local\bin\klarpdf-mcp.exe"
Already added it at the wrong scope? There is no move — remove and re-add. claude mcp remove
takes it out of whichever scope holds it (pass --scope to name one, when it is in more than one):
claude mcp remove klarpdf
claude mcp add --scope user klarpdf -- ~/.local/bin/klarpdf-mcp
When the same name exists in more than one scope, the narrowest wins: local beats project,
which beats user. That matters here because the three usually point at different binaries — a
working tree's virtualenv, whatever is on PATH, and the installed tool — so "the server is broken"
can mean three different programs depending on where you launched from. claude mcp get klarpdf
names the scope and the exact command it resolved; claude mcp list warns when scopes conflict.
If you run Claude Code from inside this repo, skip that command. A .mcp.json at the repo root
already describes the server, and Claude Code offers it when you start here — approve the prompt and
you are configured. (It is offered rather than switched on silently, which is why checking it in is
safe.) That file calls the bare klarpdf-mcp, so activate the virtualenv before you start Claude
Code: source .venv/bin/activate.
Confirm with /mcp: it should say klarpdf — 23 tools. If it says failed, run the command by
hand in the same shell you launch Claude from; the error is almost always command not found
(nothing installed, or a different virtualenv active) or No module named mcp (installed the
package but not its dependencies).
Claude Desktop
Option A — the .mcpb bundle. Install it in Claude Desktop as an extension. You can get the
bundle in two ways:
- Download it —
klarpdf-<version>.mcpbfrom the latest release, withSHA256SUMSalongside it. No clone and no Node required. - Build from a checkout — run
python packaging/mcp/mcpb/build_mcpb.py. This needs Node, since the packer itself is a Node tool. The script's header documents exactly what goes into the bundle and what's deliberately left out.
Either way, you also need uv. The bundle contains only source — no
interpreter, no dependencies of its own — so Claude Desktop relies on uv run to actually launch
the server. Downloading spares you Node, but not uv; make sure it's on the PATH that Desktop sees
before you open the file.
This also sets Option A apart from every other path here: it installs online. At install time,
uv fetches wheels from PyPI for your machine, resolved against a hashed uv.lock shipped inside
the bundle — so you get the exact package set we reviewed, not whatever PyPI happens to resolve that
day. That lock does pin a handful of platform-specific packages that our weekly pip-audit never
sees. If that residual risk isn't acceptable, use Option B.
Option B — edit the config. Add to claude_desktop_config.json
(macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\):
{
"mcpServers": {
"klarpdf": {
"command": "~/.local/bin/klarpdf-mcp"
}
}
}
Codex CLI
codex mcp add klarpdf -- ~/.local/bin/klarpdf-mcp
There is no scope to choose here: codex mcp add writes ~/.codex/config.toml, which applies
everywhere. Or write it into that file yourself:
[mcp_servers.klarpdf]
command = "~/.local/bin/klarpdf-mcp"
Gemini CLI
gemini mcp add klarpdf ~/.local/bin/klarpdf-mcp --scope user
Gemini takes the flag after the command, where Claude Code takes it before the name — and its
default is --scope project, meaning the .gemini/settings.json of the directory you run it in.
--scope user writes ~/.gemini/settings.json, which is the one that applies everywhere. Or edit
either file yourself; both use the same mcpServers block Claude Desktop does:
{
"mcpServers": {
"klarpdf": {
"command": "~/.local/bin/klarpdf-mcp"
}
}
}
Any other client
Clients differ in where the configuration lives, not in what it has to say. Claude Desktop and
Gemini CLI share the mcpServers JSON block above; Codex CLI uses the [mcp_servers.<name>] TOML
table; others vary again. All of them want one thing from you — a command to run. Point yours at
your ~/.local/bin/klarpdf-mcp, add args if you want the switches below, and ignore
every field about URLs, ports and tokens, because this server has none.
Limiting what the server can do
By default the server can read and write any file you can. That is the honest default rather than a lax one: it is a subprocess you launched, running as you, and a client that can start it can already read your disk. Two switches narrow that when you want a smaller blast radius — a shared machine, an agent you are still learning to trust, a directory of client documents.
| Switch | Environment variable | What it does |
|---|---|---|
--read-only |
KLARPDF_MCP_READ_ONLY=1 |
Registers only the ten read tools. The thirteen transform and redaction tools are never advertised, so the model does not see them and cannot ask for them. |
--allow-root DIR |
KLARPDF_MCP_ALLOW_ROOTS |
Confines every path — inputs and outputs alike — to that directory tree. Repeatable on the command line; the variable takes a list separated by your platform's path separator (: on macOS and Linux, ; on Windows). |
Pass them wherever your client names the command:
{
"mcpServers": {
"klarpdf": {
"command": "~/.local/bin/klarpdf-mcp",
"args": ["--read-only", "--allow-root", "/Users/me/Documents/contracts"]
}
}
}
Three details that matter in practice:
- Containment is checked on the resolved path, through the same file-identity code the app uses,
so a symlink pointing out of an allowed root, a
..escape, and a case-different spelling on Windows are all refused. A path that does not exist yet — an output you are about to write — is judged by its parent directory. --allow-rootwins over the variable when both are set.--read-onlyis on if either asks for it, and neither turns it back off.- A restriction is announced on stderr when the server starts, naming what is in force. Never on stdout, which carries the JSON-RPC stream — one stray line there ends the session.
Writes are on by default because no write tool can destroy data by construction: each needs an
explicit new output path, and in-place save is never exposed. --read-only is the cautious opt-out,
not the recommended setting.
The tools
Page numbers are 1-based everywhere, matching what a reader sees. An out-of-range page is an
error, never a silent clamp. Every tool takes an optional password — see
Password-protected PDFs.
| Read | |
|---|---|
get_info |
Pages, size, page sizes, encryption + permissions, has-text-layer, outline. Call it first. |
get_outline |
Bookmarks as {level, title, page, top}, where top is how far down its page the bookmark lands. |
get_tables |
Tables on the pages you name, as rows with each table's title, page and bbox — plus unread_regions, naming every region that could not be read and why. Rows come only from what the page draws (a grid, or ruled lines and shaded bands), columns from the white space no text crosses, and every table is checked against its page before it is returned. pages is required: reading tables costs far more than extract_text. |
get_heading_candidates |
Lines set to stand out from the document's body text — larger, bold, or italic, including a heading that opens a paragraph — with page, bbox and a style id, plus a table describing each style once. For building bookmarks with set_outline when a document has neither an outline nor a linked contents page, or to add sections under an outline that has only chapters. The agent decides what is a heading; styles narrows to the ones it chose, and tables: true marks lines inside a table get_tables returns. |
get_links |
Every link: where it points (target_page and target_top / uri / file), its rect, and the words under it. The structure a document carries when it has no bookmarks. Link annotations only — a URL merely typeset on the page is not one, so pair it with search for a privacy sweep. |
search |
Hits with page, snippet, box, and whether the text is invisible on the page. match_case, whole_words. |
extract_text |
Text of named pages. |
render_page |
One page — or one clip region of it — as a PNG image block. |
get_form_fields |
Fillable fields, one entry per occurrence, with each one's checkbox on-state and read-only / required / multiline / max-length. |
get_annotations |
Every mark on the page — anyone's — with its note, colour and boxes; mine / editable say who wrote it and whether the app can edit it. |
| Transform — writes a new file | |
|---|---|
extract_pages |
Named pages out as one new document — the "give me pages 10-20" tool. |
delete_pages · reorder · rotate |
Page-set edits; bookmarks follow their pages. |
split · merge |
Cut into several files by print-dialog ranges ("1-3", "5-") / concatenate; merge renames colliding fields. |
fill_form · flatten |
Fill (still editable; checkboxes take true or their own export state, anything else is an error) / bake in (no longer editable). fill_form warns on an XFA form and on read-only fields. |
set_outline |
Give a document bookmarks: write [{level, title, page, top}] as its outline — the shape get_outline returns, so an outline round-trips and enriching one is get_outline + concatenate. top is optional: give it and the bookmark lands on its heading rather than the top of the page. Refuses if the document already has an outline unless replace_outline: true; a page the document does not have is an error, levels are repaired. |
export_images |
Rasterise pages — or one clip region of each — to png/jpg files. |
annotate |
Write highlights / underlines / strike-throughs, each able to carry a note. Takes boxes, not queries; merges with markup already there rather than stacking. |
| Redact — destructive, verified | |
|---|---|
redact_text |
Remove every match of a query — or of several queries, in one verified pass. |
redact_regions |
Remove rectangles; a search hit's box feeds straight back in. |
What the write tools guarantee
- Your input is never written to. Every write tool requires an explicit
out, in-place save is not exposed, and the refusal resolves through the same path-identity check the app uses — so a symlink, a..segment, or a case-different spelling on Windows cannot slip the input back in as the destination. "The source is left byte-identical" is a test, not a claim. - Nothing else is clobbered either. An existing output is refused unless you pass
overwrite: true. - Lossless for content; for document structure, only if the page set is unchanged. Text layer,
form fields, annotations and bookmarks always survive, and bookmarks and links are re-pointed at
pages' new positions rather than left dangling, still landing on the same spot on their page. But a tool that moves pages (
reorder,delete_pages,extract_pages,split,merge) builds a new document, and the accessibility structure tree,/Perms, the/Namestree and encryption do not survive that — they are document-level, and copying pages does not copy them. A tool that leaves every page in place (fill_form,flatten,set_outline, the redactions) edits a copy of the original instead and keeps all of it. See PLAN.md §Key design idea for the table. - A mistake is an error, not a quiet partial success. An unknown form-field name, a
reorderthat is not a full permutation, adelete_pagesthat would empty the document, aredact_textthat matches nothing — all fail loudly rather than writing something plausible. - A misspelled parameter is an error too. Every tool rejects an argument name it does not
declare, naming it, suggesting the near miss (case-insensitively, so
PAGESis answered withpages), and listing what it does accept — before reading or writing anything. This is not decoration: the MCP SDK's default is to drop an unrecognised key, soredact_textcalled withquerysinstead ofqueriesused to redact only the other query and report an unqualified success —residual_matches: 0, cross-engine verified — on a file that still held the value you asked it to remove. Nothing downstream can catch that, because the record of what you asked for is gone before the tool runs (PLAN.md §M106).
Clipping to a region
render_page and export_images both take an optional clip — [x0, y0, x1, y1] in page
points, the same coordinate space search reports hits in and redact_regions consumes them
from. Boxes are this server's native currency, so region→image is the "I know where" workflow it
already serves, minus the destruction.
The composition worth knowing: search → render_page(clip=…) shows a person the actual pixels
of what is about to be deleted. Every other safety mechanism here is textual; this is the one that
makes the preview visual, on tools whose docs already say to search before you redact. It also
reads a stamp, signature or single table cell at 300 dpi without paying to render the page around
it.
A search hit carries boxes, one per line, so a match wrapping a line break has several. Pass
one, or their bounding box to see the whole match. clip takes a single rectangle where
redact_regions takes the list, and the asymmetry is deliberate: a union spanning two lines picks
up whatever sits between them, which is helpful when looking and is data loss when deleting.
Rotation is handled for you. search reports boxes in the page's unrotated space — the same
coordinates at /Rotate 0 and /Rotate 90 — and clip reads them there, so a hit feeds straight
back whatever the rotation. The image is the region as displayed, so a quarter turn swaps its
width and height. One consequence worth knowing: get_info.page_sizes reports displayed
dimensions, so on a turned page a perfectly valid box can extend past the width shown there.
The pixel size rounds outward to whole device pixels, so it is
ceil(x1 x dpi/72) - floor(x0 x dpi/72), not (x1-x0) x dpi/72. A 100 pt square at 150 dpi comes
back 209 px, not 208.33 — and exactly 100 px at 72 dpi, where the scale is 1:1. Expanding outward is
deliberate: no partial pixel of the region you asked for is dropped, which is what you want from a
crop. Just don't assert on the naive formula.
Every exported file carries its page number, zero-padded to the document's page count —
<stem>-03.png from a 20-page file, <stem>-003.png from a 572-page one, whether you export one
page or twenty. The width comes from the document rather than from the pages you asked for, so two
exports from one file into one directory agree and sort correctly.
name chooses the stem. That matters for the use clip exists for: cutting two
regions out of one page needs name: "card_front" then name: "card_back", or both calls want
the same filename and the second is refused. name is a plain filename stem, never a path: no
separators, no .., no extension.
A clip that runs off the edge of the page is an error, not a smaller image. PyMuPDF would
happily intersect it and hand back a cropped pixmap; render_page returns an image block, so its
reply has nowhere to say that it did. The error names the page's rect instead, so the fix is one
step rather than a guess. export_images checks every page in the set before writing anything —
page sizes vary within a document, and a clip that dies on page 7 must not leave six files behind.
Reading a document's structure
extract_text gives an agent a page's words. These tools give it the structure around them: what
the document's sections are and where each one starts, where it points, and what its tables hold.
With that, an agent can work through an 800-page report by its parts instead of reading all of it,
answer "what does section 4 say" by going to section 4, and quote a table as a table rather than as
a run of numbers. All four are read tools, so they are available under --read-only.
get_outlinereturns the document's bookmarks, when it has them, as{level, title, page, top}. It is the cheapest answer, and many documents do not have one.get_linksreturns every link the document carries: where it points (a page in this file, withtarget_topfor where on that page, or a web address, or another file) and the words it is anchored on. Two things follow from that. It answers where does this document send me, which is a privacy question as much as a navigation one. And a printed contents page is usually a set of links, each already carrying its title, its target page and, in its indent, its level: structure the publisher wrote, not structure guessed from the page.get_heading_candidateslists the lines set to stand out from the body text (larger, bold or italic, including a heading that opens a paragraph), each with its page, its box and a style, plus a short table describing each style once. It is for documents with neither bookmarks nor a linked contents page, and for finding the sections inside a chapter. The agent decides which styles are headings and at what level; the tool makes sure none is missed.get_tablesreturns the tables on the pages you name as rows, each with its page, its box and its caption. Every table is checked against its page before it is returned, and one that cannot be read without guessing is declined by name, with the reason, rather than returned wrong.extract_textreportstable_pages, so an agent can tell which pages are worth asking about.
Writing the structure back as bookmarks
Once the structure is known, set_outline can give the document the bookmarks it never had, so a
reader in any viewer gets a sidebar instead of scrolling. Send it [{level, title, page, top}].
Pass top (a heading's box top, or a link's target_top) and each bookmark lands on its heading
rather than the top of its page, which matters when a page holds several sections. The page set does
not change, so the copy keeps everything the original held, encryption and permissions included.
set_outline never merges. If the document already has bookmarks, the call is refused unless you
pass replace_outline. To add to an outline instead, read it with get_outline, add your entries
to that list, and send the whole tree back. Keep each entry's top: the reply reports
positions_discarded if you drop them.
Marking up, and the review hand-off
annotate writes highlights, underlines and strike-throughs; get_annotations reads back every
mark a document carries, including ones made in Acrobat, Preview or Edge. Both are worth having on
their own — "underline every termination clause", "summarise the review comments in this contract" —
and together they make a hand-off an agent cannot do alone:
- Propose. Locate what matters with
search, thenannotateit, perhaps one colour for certain and another for needs a look, with anoteon each saying why. Nothing is deleted. - Dispose. A person opens the output in KlarPDF. Every mark written here is an ordinary editable mark: recolour it, extend it, delete it, reply in its note. That review surface already exists and is better than any tool reply.
- Act. Read the reviewed file with
get_annotations, filter on what they left, and go.
annotate takes boxes, not queries. It does not search and it has no idea what a name or a
termination clause looks like — that judgement is the caller's, and a PDF engine that pretended
otherwise would be guessing. This is the same line redaction's variant scan draws when it reports
rather than matches.
Colour is the reviewer's convention, never the tool's. Nothing here decides that orange means
delete. If deletion is the outcome, filter get_annotations and pass the boxes to redact_regions,
which verifies the removal as it always does. There is deliberately no "redact what I highlighted"
shortcut: that step should be one you took, not one a colour triggered. The boxes need no adjusting
on the way — both tools work in unrotated page points, at any page rotation.
A repeat call merges rather than stacking. Re-marking a span in the same colour is a no-op, a different colour takes the span over, and notes are carried onto the survivor — the app's own rules, because it is the app's own function. So retrying a call is safe.
What redaction guarantees, and where it stops
The text is physically removed, not covered with a black box, and the redaction annotation is
consumed so there is nothing left in the output to delete and reveal what was under it. Then the
written file is re-read and checked: with PyMuPDF always, and with Poppler's pdftotext when it is
installed — a different engine from the one that did the removing. If anything is still
recoverable, the output is deleted and the call fails. A path coming back always names a file that
was verified.
The guarantee covers the text layer. Text that is part of a scanned image is pixels: the region
is removed along with the image, but there is no text to verify, so verified_text comes back empty
and the result says so. cross_engine_verified tells you whether the second engine ran at all —
install poppler-utils for the stronger check.
The two tools prove different things, and redact_regions proves the narrower one. redact_text
removes what you named and re-checks the whole document to prove it caught every occurrence.
redact_regions removes what is there — you said where, so the boxes are the whole of the request,
and the check confirms those boxes are empty, nothing wider. Read verified_text on a region
redaction: it lists what actually came out, which is often more than you aimed at, since a rectangle
over a name may take the address under it too. If you are removing PII rather than blanking an
area, search those strings before sending the file on, or use redact_text.
Preview with search before redact_text. Matching is the app's find-bar behaviour, so with
whole_words off a search for "Smith" also matches inside "Smithsonian" — and this tool deletes
what it finds.
residual_literal counts occurrences, not spellings. It is the number of times the query is
still in the file in a form whole_words did not match, with residual_literal_forms naming each
spelling, its own count and its pages. A single spelling can account for a dozen occurrences on a
long document, so read the integer as "how much is still there" and the list as "what it is".
When a redaction lands inside a longer word, the reply says so. That is the one way this tool
damages text you never asked about: redacting Male with whole_words off also takes the male
out of Female, leaving Fe. It is not filtered out, because matching inside longer words is
exactly what you want for an identifier buried in a machine tag — but it is reported.
partial_word_matches names the term, the word it came out of, and what that word now reads, and a
warning repeats it. None of the residual checks can find this on their own: they are all scoped to
the query, and the query was removed exactly as asked.
A redacted file can be larger than its source, and the reply explains why. Erasing pixels
inside an image means decoding it; re-compressing lossily would degrade exactly the area being
redacted, so the image is stored losslessly, and a photograph held losslessly is much bigger than
the same photograph as JPEG. images_recoded names each one by its PDF filter (DCTDecode →
FlateDecode) with the embedded stream size before and after, so the numbers reconcile against the
output file. Nothing is duplicated — only the placements a box actually overlaps are re-encoded.
Removing several things? Use queries, not several calls. The argument is data hygiene rather
than convenience: every write needs a fresh out, so a chain of six redactions strews five
intermediate files, each a partially-redacted copy still holding the values you have not reached
yet, and each one yours to remember and delete. One pass writes one file. It also retires an
ordering trap — chained calls had to run the longest query first, or a shorter one consumed part
of a longer match and left fragments behind. In one pass every box is computed against the intact
source before anything is applied, so the order of the list cannot matter. Overlapping terms
(607347469 203 1 and 607347469) are the case this is for, not an edge case to avoid.
whole_words: true means whole token, and for a single-token query that is usually the wrong
choice. A "word" is a run of characters between spaces, so 220885-1063303 with whole_words on
does not match <AccountNumber:220885-1063303> — the entire tag is one word. Machine-readable tags,
filenames, URLs and key:value pairs all have that shape, and a bill that stamps its account number
into such a tag will keep it through a redaction that looks like it worked. For a query with no
spaces, whole_words: false is both safe (there is no second word to over-match) and the mode that
finds the value wherever it is embedded.
That case is why the reply carries two fields worth reading rather than just a success:
residual_literal— places the query still appears literally that thewhole_wordssetting does not match, each named inwarnings. Deliberately a warning and not a failure: redacting whole-word "Smith" legitimately leaves "Smithsonian". The named token is what separates the two —'Smithsonian'reads as fine,'<AccountNumber:220885-1063303>'does not. This check shares nothing with the matcher, which is the point: the older verification did, so it could confirm the matcher's own blind spots as clean.invisible_matches— removals that were never visible on the page (white-on-white, transparent, painted over). They are gone, but they tell you this document stores data where no reader, and no before/after render comparison, would ever find it.searchflags the same thing per hit asinvisible.residual_normalized— spellings still in the file that differ from the query only in separators:6073474692031against607347469 203 1,08/24/1970against08-24-1970, or an identifier broken by a line wrap. A literal scan sees none of these, so redacting one form used to report the file clean while the other stayed in it. Nothing is deleted for a variant — whether two spellings are one value is a fact about the document that only you have.[]andnullmean different things.[]is "the scan ran and found nothing";nullis "the scan did not run", which happens for a short unpunctuated query like000000, where matching across separators finds coincidence rather than spellings.nullcomes with a warning saying so, because a feature that exists to close an invisible failure must not go quiet in a way that reads as reassurance. A query you punctuated —999 99 9999,AB 12 CD— is always scanned: the separators are you saying it is a structured value.queries— the per-query breakdown when you passed a list. Every field above is reported inside it, per query, beside that query's ownmatches, because flattening six queries' counts into one set would report the last one's numbers as the whole call's. The shape follows which parameter you used, not how many queries you happened to put in it: a one-elementquerieslist still comes back as a list, so a caller iterating a variable-length list gets one shape to parse. A query that matches nothing does not fail the call when another matched — it comes backmatches: 0with a warning, because failing would delete an output that correctly removed the others.residual_scope— the pages the two scans above actually read. Every page, unless you passedpages; then the scans cover that slice, exactly as the redaction did, and a warning says so. Everything in a reply describes the operation you asked for — the tool never mixes page-scoped and document-wide answers in one response. Don't readpages_redactedas a substitute: it lists only where boxes landed, which is a smaller set ([1]for a call that scanned[1, 2, 3]).matchesvsboxes_redacted— different numbers, both correct.matchessums each query's own hits, so text two queries both matched counts twice;boxes_redactedcounts distinct rectangles applied. Neither is "how many identifiers came out": a short query matching inside a longer query's match makes two real boxes over one piece of text.query_terms— the per-term match breakdown whenwhole_wordsis off and the query has several words, with a warning when one term did most of the deleting and the phrase itself is rare. This is the over-redaction counterweight: everything else here proves the query is gone, and all of it is silent when a query removed far more than you meant. It is also the only warning you get, because destroyed content leaves no trace in the output — the only record it was ever there is the input, which is never modified.
Password-protected PDFs
Every tool takes an optional password. Call get_info first — it reports needs_password: true
for a document that will not open without one, and reports the encryption and permissions of a
document that opens but is restricted.
A missing or wrong password fails the call with a message naming the file and telling you to call
again with password. The server never prompts: there is nobody behind an MCP call to answer a
prompt, so prompting would hang the client instead of asking anyone anything.
Reading a tool's full contract
Each tool's description carries what you need before calling it. The reference half — the
field-by-field catalogue for redact_text, the counting and scope rules, how to feed search hits
to redact_regions — is published as an MCP resource instead, for the eight tools whose replies
need it:
klarpdf://docs/redact_text
klarpdf://docs/search
klarpdf://docs/get_tables
klarpdf://docs/get_heading_candidates
klarpdf://docs/get_links
klarpdf://docs/set_outline
klarpdf://docs/annotate
klarpdf://docs/get_annotations
This is not organisation for its own sake. Claude Code truncates a tool description at 2,048
characters and appends … [truncated] with no error, so 69% of redact_text's documentation used
to be discarded in transit — and it was the half describing what the reply means. Resource reads
are capped at 100,000 characters, so that material now has somewhere to live. The resource serves
the tool's live registered description plus the appendix, so it can never drift from what the tool
actually advertises.
Metadata
Release files for klarpdf 0.20.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| klarpdf-0.20.0.tar.gz | 346.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| klarpdf-0.20.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 682.5 kB
Release files / klarpdf-0.20.0.tar.gz
| Download URL | klarpdf-0.20.0.tar.gz |
|---|---|
| Size | 346.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b1ccf4be0fe0e301bdb202fc6d3d01e01e19fe9c7e6b9cdf8ac30df5e5ddac5b
|
|
BLAKE2b-256 checksum How to use checksums |
ee65feda4c010c8be26dbaaf6c370627c74664949312fbb53cc821757292b67b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.
Transparency logRelease files / klarpdf-0.20.0-py3-none-any.whl
| Download URL | klarpdf-0.20.0-py3-none-any.whl |
|---|---|
| Size | 336.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6f01e04ee09e7030f1ff939e82455af460a96e79614155c8b77e8c89ab78fb69
|
|
BLAKE2b-256 checksum How to use checksums |
69f88c313b793a466b44db6b05523e790a4067ac3d110286560d3a0be8982a3d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.
Transparency log