Skip to main content
Glama
okular-mcp

okular-mcp

by david-wang-0
README.md
# okular-mcp

An [MCP](https://modelcontextprotocol.io) server that tells an LLM session **what
you are reading in [Okular](https://okular.kde.org/)**: which documents are open
(in every window and tab) and on what page, the text you have selected with the
mouse (with its LaTeX source lines when the PDF has SyncTeX data), and (when you
ask) showing you a page.
A prompt hook for Claude Code adds the document, the page and your selection to
every prompt you type in a project that contains the PDF, and `okular-mcp-open`
lets LaTeX tools open a PDF or do a forward search in Okular.

This server does not read or write PDF content. It implements the viewer-state
contract in [`CONTRACT.md`](CONTRACT.md), and the PDF work is done by sibling
servers:

- **`pdf-reader-writer-mcp`**: page text with line numbers, links, outline,
  annotations, marks and notes written back, and `locate` (selection to line
  numbers). Every tool takes the `path` and `page` this server reports.
- **`vscode-pdf-mcp`**: the same contract for the PDF viewer inside VS Code.

`CONTRACT.md` is shared: both viewer servers keep an identical copy, and its
clauses (C1 to C8) are pinned by tests named after them (`test_c3_...`). Change it
in both repositories together, never in one.

## Tools

| Tool | What it does |
|---|---|
| `viewer_state()` | One entry per document open in Okular (each tab of each window), the active one first (C1) |
| `get_selection(source="primary")` | The text selected with the mouse (`primary`), attributed to the active document and the page it is on, with its LaTeX source lines when the PDF has SyncTeX data; or the clipboard (`clipboard`), never attributed (C2) |
| `goto(page, path?)` | Show `page` of `path` (default: the active document), bringing its tab to the front, or opening the file first if no tab has it; returns its entry after the move. Meant for "show me where that is", not for the model to navigate on its own (C3) |

An **entry** is

```json
{"viewer": "okular", "window": "org.kde.okular-12345", "path": "/abs/paper.pdf",
 "page": 3, "pages": 12, "active": true}
```

`window` is the Okular process's D-Bus service name, followed by the tab's D-Bus
object for tabs other than the process's first (`org.kde.okular-12345/okular2`),
`page` is 1-based, `pages` the page count. The **active** entry is the tab on show
in the Okular window that has the keyboard focus, else in the first window that has
a document open (Okular processes in the order they joined the bus, each one's
windows in the order they were made). With nothing open (or Okular not running),
`viewer_state` and `goto` without a path fail with a message starting
`no Okular window with a PDF open`.

### Tabs and windows

Okular gives every document view (a tab, or the document of a window without tabs)
its own D-Bus object, `/okular`, `/okular2`, `/okular3`, ... in the order they were
made, and every main window an object `/okular/okular__Shell_<n>` with its Qt
properties. The server reads all of them, so each tab is an entry; a process
started as `okular a.pdf b.pdf` with two windows gives two. Which tab a window shows
is not reported as such: the window's title names its document, so the server
matches the title against each tab's file name, its path, or the document's own
title (Okular's settings choose which of them the title shows), and `isActiveWindow`
says which window has the focus. When you type a prompt, the focus is in the
terminal, not in Okular, so the active entry is then the tab on show in the first
window. Two tabs with the same file name (two projects' `main.pdf`) look the same
in the title; the one opened last is taken. Setting Okular to put the path in the
title tells them apart (see [Setup tips](#setup-tips)). The reads of all tabs run at
the same time, so many tabs cost about as much as one: the hook takes about 0.15 s
with nine tabs.

`get_selection` returns `{"text", "source"}`, plus `viewer`, `path` and `page`
for `source="primary"` when a document is open. `text` is `""` when nothing is
selected. The primary selection is system-wide: it is whatever was last selected
in any window, so it is attributed to Okular's active document on trust, and
Okular does not say where in the document it is. So the server looks for the
selected words in the text of the active document's current page, then the next
page, then the previous one (with poppler's `pdftotext`, the same text layer
Okular selects from), and `page` is the first page that has them. Okular also
selects over a page break, giving the end of one page's text (its page number
included), then the start of the next one's: words found that way across these
pages give the page the selection starts on. Otherwise `page` is the current
page. When the words occur exactly once there and the PDF has a
`.synctex.gz` (or `.synctex`) next to it, `tex` gives their LaTeX source lines,
from `synctex edit` at the first and last word and one word of each printed line
between (except the printed lines right before and after a page break, which hold a
page number or a running head that SyncTeX places where the page was shipped out):

```json
{"text": "...", "source": "primary", "viewer": "okular", "path": "/abs/paper.pdf",
 "page": 4, "tex": [{"file": "/abs/sec/intro.tex", "lines": [12, 15]}]}
```

One entry per source file, in document order. A selection may start or end inside
a word, and a word TeX hyphenated at a line end matches with or without its
hyphen. pdfTeX tags the space after a source line's first word with the line
before, so `synctex edit` alone gives that word the previous line; the server
checks with `synctex view` whether the next line, if it has text, starts right
after the word and then takes that line, and it maps a paragraph's last word, which SyncTeX puts on
the blank line after it, to the line it is on.

`goto` brings a tab that is not on show to the front first, by asking Okular to
open the file the way its own launcher does (`/okularshell`, which switches to the
tab that has it open; Okular's "Switch to tab if already open" setting, on by
default). A file that no tab has opens next to the active entry: in a new tab when
Okular opens new files in tabs (Settings, General: "Open new files in tabs"),
otherwise in place of the active entry's document, as before. It fails with
`no such file: <path>` for a path that does not exist, `cannot open <path>`
if Okular fails to load it, and `page N outside 1-M` before moving if the page is out of
range. For a file it cannot load, Okular shows a modal error dialog and answers the
D-Bus call only once the user closes it, so `goto` (and `okular-mcp-open`) take a
few seconds to report `cannot open`; the dialog stays in Okular until it is closed.

Errors reach the model as their message (as an MCP tool error), not as a
generic "Error executing tool".

## Prompt hook (Claude Code)

`okular-mcp-hook` is a `UserPromptSubmit` hook. Add it to a Claude Code
`settings.json` (user or project):

```json
{
  "hooks": {
    "UserPromptSubmit": [
      {"hooks": [{"type": "command", "command": "okular-mcp-hook", "timeout": 5}]}
    ]
  }
}
```

When the active Okular document lies inside the session's working directory
(after resolving symlinks; a PDF in a sibling directory does not count), it adds

```
The user is reading /abs/project/paper.pdf in Okular, page 3 of 12.
Selected text (page 4):
the words you selected
Source: sec/intro.tex:12-15
```

to the prompt's context. Otherwise it adds nothing. The selection lines come only
when the selected words are found on the document's current page or a page next
to it, or over a page break between them (as for `get_selection`): the system
primary selection cannot be tied to Okular otherwise, and is often the text just
selected in the terminal the prompt is typed in. The text is cut at 2000
characters (` […]` marks the cut). The
`Source:` lines follow when the PDF has SyncTeX data (`:12` alone for a single
line), with the file relative to the working directory when it lies inside it.

The hook always exits 0, also on bad input or when Okular or the bus does not
answer (each qdbus call may take 0.3 s, and a 0.8 s watchdog ends it silently), so
it never blocks a prompt. The selection part has its own deadline, 0.6 s after the
hook starts: if reading the selection, `pdftotext` or `synctex` is slow or
missing, the hook leaves the selection out and still reports the page. Measured
on a 54-page paper with SyncTeX data, the whole hook takes about 0.26 s with a
selection found and mapped (about 0.14 s of it for the selection part) and about
0.13 s without one.

## LaTeX: `okular-mcp-open`

```sh
okular-mcp-open <pdf>                                         # open the PDF
okular-mcp-open --tex <file> --line <n> [--column <n>] <pdf>  # forward search
```

The same command line as `vscode-pdf-mcp-open`, so a LaTeX tool can be pointed
at either viewer. A tab that has the PDF open is brought to the front and otherwise
left as it is. Otherwise the PDF opens as `okular <pdf>` would open it: as a new tab
of the running Okular when Okular opens new files in tabs (or into an empty Okular
window), else in a new Okular window (`okular` started without arguments, which
never joins a running Okular). The forward search asks `synctex view`
for the page first, then hands Okular the URL `file://<pdf>#src:<line> <file>`
(the `--unique` forward search), in the tab that has the PDF open (brought to the
front), else in a new tab or window as above. Okular has no column in a forward
search, so `--column` is accepted and ignored (a negative one means none, as LaTeX
Workshop may pass). It prints
`<pdf>: page <n> of <pages>` (what Okular shows once it has settled), exits 1
with the reason on stderr when it cannot (`no such file: <pdf>`, `cannot open
<pdf>`, `no SyncTeX data for <file>:<line> in <pdf>`, Okular not starting) and
2 on bad arguments.

A forward search opens the document anew, like `okular --unique`, and Okular would
first ask to save unsaved annotations. So when the tab has unsaved changes (its
window says so: KDE's "modified" mark, `windowModified` on D-Bus), `okular-mcp-open`
goes to the page of that line instead, without the precise place, keeps your marks,
and says so on stderr; save in Okular for the exact forward search. Okular reports
this only for the tab on show, which a forward search brings to the front first.

LaTeX Workshop, in VS Code's `settings.json`:

```json
"latex-workshop.view.pdf.viewer": "external",
"latex-workshop.view.pdf.external.viewer.command": "okular-mcp-open",
"latex-workshop.view.pdf.external.viewer.args": ["%PDF%"],
"latex-workshop.view.pdf.external.synctex.command": "okular-mcp-open",
"latex-workshop.view.pdf.external.synctex.args": ["--tex", "%TEX%", "--line", "%LINE%", "%PDF%"]
```

Then *View LaTeX PDF* opens Okular and LaTeX Workshop's forward search
(Ctrl+Alt+J) lands in it. Use the absolute path of `okular-mcp-open` as the
command if VS Code does not have it on its `PATH`. Inverse search is Okular's
own: Shift+click in the page opens the source in the editor set under Settings →
Configure Okular → Editor (for VS Code, see [Setup tips](#setup-tips)).

## Setup tips

Okular's own settings, none of them needed:

- **Inverse search** (Shift+click in the page opens the source line) is Okular's:
  Settings → Configure Okular → Editor. For VS Code, pick *Custom Text Editor* with
  the command `code -g %f:%l` (in `okularpartrc`: `ExternalEditor=Custom` and
  `ExternalEditorCommand=code -g %f:%l` under `[General]`).
- **Tabs**: Settings → Configure Okular → General → "Open new files in tabs"
  (`ShellOpenFileInTabs=true`) makes `okular <pdf>`, `goto` and `okular-mcp-open`
  open a PDF as a tab of the running Okular.
- **Two tabs with the same file name**: "Display document title in titlebar if
  available" off and "When not displaying document title": "Display full file path"
  (`DisplayDocumentTitle=false`, `DisplayDocumentNameOrPath=Path`) put the path in
  the window title, so the server can tell `one/main.pdf` from `two/main.pdf`.
- **Your marks' author**: Settings → Configure Okular → Annotations → Author
  (`IdentityAuthor` under `[Identity]`; default: your full name). Set it to the same
  name as the VS Code viewer's `vscodePdfMcp.author` if marks made in both viewers
  should carry one author.

## No reload tool, and saving

Nothing here writes the PDF, and there is no reload tool (C4). Okular's
"Reload document on file change" (WatchFile, on by default) re-renders the
document by itself after another tool saves it: an incremental save by
`pdf-reader-writer-mcp` showed up in about 1.5 s. But Okular keeps your own
**unsaved** annotations in memory and loses them when the file changes, so save
in Okular (Ctrl+S) before another tool writes to the file; the server's
instructions tell the model to ask you first.

## Requirements

Linux with KDE's Okular on the session D-Bus, plus:

- `qdbus` (Qt tools; installed with Okular on most distributions)
- `wl-clipboard` on Wayland, or `xclip`/`xsel` on X11, for `get_selection`
- optional: `pdftotext` (poppler; `poppler` or `poppler-utils`), to find the page
  of a selection; without it, `page` is the current page and the hook leaves
  the selection out
- optional: `synctex` (TeX Live), for the `tex` source lines, the hook's
  `Source:` lines and `okular-mcp-open --tex`
- Python 3.10+; `mcp` is the only dependency

## Install

With [uv](https://docs.astral.sh/uv/):

```sh
uv tool install git+https://github.com/david-wang-0/okular-mcp
```

This installs `okular-mcp` (the server), `okular-mcp-hook` and
`okular-mcp-open`. Register the server with your MCP client. Claude Code, user scope:

```sh
claude mcp add okular -s user -- okular-mcp
```

Codex CLI:

```sh
codex mcp add okular -- okular-mcp
```

Any other client: run `okular-mcp` as a stdio server.

## Gotchas

- **Tabs.** Okular's `/okular` object answers for the first-opened tab, not the
  one on show ([KDE bug 502482](https://bugs.kde.org/show_bug.cgi?id=502482)), and
  no D-Bus call names the tab on show. The server therefore reads every tab's own
  object and goes by the window title (see [Tabs and windows](#tabs-and-windows)),
  which needs a KDE Frameworks Okular that exports its main windows on D-Bus (checked
  with Okular 26.08). Where it does not, or no title matches a tab, the first tab is
  the active entry, as before, and `goto` does not bring a tab to the front.
- **Selection text is Okular's text layer**, returned verbatim: hyphenation
  and column order can be messy. A selection whose words the text of the current
  page and the pages next to it does not have in that order (it reaches further,
  or Okular split a formula differently from `pdftotext`) is not found: `page` is
  then the current page, without `tex`, and the hook leaves it out.
- **Minimal client environments.** Some MCP clients (Codex, the Python SDK's
  client) start servers with only `HOME`, `PATH`, `SHELL` and `USER`. Before any
  `qdbus`, `wl-paste`, `xclip` or `xsel` call, the server (and the hook) recover
  `DBUS_SESSION_BUS_ADDRESS` and `WAYLAND_DISPLAY` from `XDG_RUNTIME_DIR` (or
  `/run/user/<uid>`), so no per-client `env` block is needed on a systemd
  desktop (C5).

## Development

```sh
uv venv && uv pip install -e '.[test]'
.venv/bin/pytest                    # the default suite: fakes only, about 15 s
.venv/bin/pytest -m integration     # real Okular, headless on a private bus, about 30 s
```

The default suite never starts Okular or touches the real bus or selection. Unit
tests run against fakes (a fake session bus behind `viewer._call`, a fake
`subprocess.run` and `PATH`, fake `pdftotext`/`synctex` output); one fake Okular
(`fakes.okular`: tabs, windows, their titles and focus, `/okularshell`) answers for
both the in-process bus and the fake `qdbus` executable; the tests of a
CONTRACT.md clause are named after it (`test_c3_...`). The process-level tests run the real entry points with the fake
`qdbus`, `wl-paste`, `pdftotext` and `synctex` executables of `tests/fakes.py` on
`PATH`: `tests/test_mcp.py` drives `okular-mcp` over stdio with the `mcp`
package's client (tool list, instructions, results, error messages),
`tests/test_hook_process.py` runs `okular-mcp-hook` as Claude Code does (C6-C8,
timing included), and `tests/test_packaging.py` checks the console scripts. With
`pdflatex`, `synctex` and `pdftotext` installed, `tests/test_sourcelines.py` also
compiles small LaTeX documents and checks the selections' pages and source lines
with the real tools.

The integration suite (`tests/integration/`, marker `integration`, deselected by
default) needs `okular`, `dbus-run-session`, `qdbus` (or `qdbus6`), `pdflatex`,
`synctex` and `pdftotext`, and skips with the list of what is missing; its C4 test
also needs `pdf-reader-writer-mcp` on `PATH`. It is headless and never touches your
Okular or screen: `dbus-run-session` provides a private bus, and every Okular and
every process under test runs on it with `QT_QPA_PLATFORM=offscreen`, without
`DISPLAY`/`WAYLAND_DISPLAY`, with temporary XDG config, data, cache, state and
runtime directories, and (for the code under test) a fake `wl-paste` as the
selection. Okular's output goes to files in pytest's temporary directory, and
everything on the private bus (Okulars, and daemons it started such as `ksecretd`)
is killed at the end. It covers C1 (not running, no document, several windows), C3
`goto` (moving, out of range, no such file, a broken PDF, a closed file, a symlink,
a path with spaces and `#`), `okular-mcp-open` (new window, reuse, forward search
into an `\input` file, no SyncTeX data, exit codes), the hook with a selection on
and off the page and over a page break, and C4: a mark written by
`pdf-reader-writer-mcp`, after which Okular reloads the file and stays on the
document and page. With Okular set to open files in tabs (in the suite's own config
directory), it checks that every tab is an entry and the one on show active, `goto`
into a background tab and into a new one, two windows of one process,
`okular-mcp-open` opening a tab and forward-searching into a background one, and
the hook naming the tab on show.

Coverage, including the processes the tests start (`[tool.coverage.run]` in
`pyproject.toml`):

```sh
uv pip install coverage
.venv/bin/coverage run -m pytest && .venv/bin/coverage combine && .venv/bin/coverage report -m
```

`okular_mcp/viewer.py` is the only Okular-specific module (`windows`, `views`,
`entries`, `active`, `goto`, and `open_in_tab`, `reveal`, `new_window`, `open_url`,
`wait_loaded` for `okular-mcp-open`), `selection.py` reads the system selection,
`sourcelines.py`
finds it on the page and maps it to source lines, `hook.py` is the prompt hook,
`open.py` is `okular-mcp-open`.

## License

MIT

TDQS

A3.5/5.0

Scored across 13 tools

Disambiguation5/5

Each tool targets a distinct resource or action: viewer state, selection, page text, context around selection, links, outline, annotations, annotation creation/reading/removal, and navigation. Slight conceptual overlap exists between context/page_text and mark_text/add_note, but the descriptions clearly delimit them.

Naming Consistency3/5

All names use snake_case, but the pattern mixes noun phrases (viewer_state, annotations, outline) with verb_noun forms (get_selection, mark_text, remove_annotation, goto). It remains readable, but there is no single predictable convention across the set.

Tool Count5/5

13 tools is well within the ideal range and matches a PDF reading/annotation server's scope. Each tool represents a distinct operation, and none feels redundant or excessively granular.

Completeness4/5

The surface covers reading, navigation, annotation creation/reading/deletion, and attachments. Minor gaps remain: no explicit save or full-text search operation, and annotations cannot be modified in place, but the core lifecycle is covered.

Maintenance

ActivityMaintained
ResponsivenessNo recent community issues