Skip to main content
Glama
okular-mcp

okular-mcp

by david-wang-0

okular-mcp

An MCP server that tells an LLM session what you are reading in Okular: 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, 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

{"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). 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):

{"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".

Related MCP server: omapdf-mcp

Prompt hook (Claude Code)

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

{
  "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

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:

"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

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:

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:

claude mcp add okular -s user -- okular-mcp

Codex CLI:

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), 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), 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

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):

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

Available Tools

13 tools
add_notePDF: margin note or replyA

Put a comment icon in the margin, or reply to an annotation.

Comment icon in the margin: level with near_text, or as a threaded reply to the annotation reply_to (an xref from annotations, e.g. to answer the user's own note), else at the top of the page. color as in mark_text. Same save-Okular-first caveat as mark_text.

Inputs: note (required), page, path, color, +2 more. Returns: object

annotationsPDF: highlights and notesB
Read-only

All highlights, notes and attachments saved in the PDF.

All annotations in the PDF (default: the open document): page, type, author, highlighted text, note. Only annotations saved to the file are visible.

Inputs: page, path. Returns: result

attach_filePDF: attach a text fileB

Embed a text file (Markdown, Mermaid) as a paperclip annotation.

Embed a text file (e.g. notes.md, diagram.mmd) as a paperclip annotation in the margin, level with near_text or at the top of the page; or replace the file of an existing attachment replace_xref. Okular saves it via the icon's context menu. Same save-Okular-first caveat as mark_text.

Inputs: name (required), content (required), note, page, +4 more. Returns: object

contextPDF: paragraph around the selectionA
Read-only

The paragraph around the user's mouse selection.

The paragraph around the user's current mouse selection (or text) on the page shown, with neighbours paragraphs before and after and the line numbers, so you can see what they are pointing at without reading the whole page.

Inputs: page, path, text, neighbours. Returns: object

get_selectionOkular: mouse selectionA
Read-only

Text the user has selected with the mouse, with page and lines.

Text currently selected with the mouse ('primary') or on the clipboard ('clipboard'), with the document path, page and line numbers when it is found on the page Okular shows (line numbers match page_text and context).

Inputs: source. Returns: object

gotoOkular: show a page to the userA

Jump Okular to a page for the user.

Jump Okular to page of path (default: current document; opens it if needed).

Inputs: page (required), path. Returns: object

mark_textPDF: highlight or underline textA

Highlight, underline, squiggle or strike out a passage, with a note.

Mark every occurrence of text on a page (default: the one shown) as highlight, underline, squiggly or strikeout, saved into the PDF under a distinct author; quote enough words to be unique. A note sits on the mark, or with note_in_margin on a comment icon in the margin replying to it (least intrusive). color: blue, yellow, green, orange, pink, purple, red, cyan, grey, #rrggbb or r,g,b in 0-1 (default light blue; red for strikeout). The user must save Okular first or their unsaved marks are lost.

Inputs: text (required), note, page, path, +3 more. Returns: object

outlinePDF: table of contentsC
Read-only

The document's table of contents.

The document's bookmark tree (sections with page numbers), a map of the paper.

Inputs: path. Returns: result

page_textPDF: page text, numbered linesB
Read-only

Numbered text lines of a page range.

Numbered text lines of a page range (default: the page shown in Okular); lines such as "12-30" narrows to a window. Line numbers match context.

Inputs: to, page, path, lines. Returns: result

read_attachmentPDF: read an attached fileB
Read-only

Read an attached file back as text.

Contents of a file-attachment annotation (an xref from annotations with a file field), decoded as text.

Inputs: xref (required), path. Returns: object

remove_annotationPDF: delete an annotationB
Destructive

Delete an annotation by xref.

Delete an annotation by the xref reported by annotations (default: the open document) and save. Same save-in-Okular-first caveat as mark_text.

Inputs: xref (required), path. Returns: object

viewer_stateOkular: open documents and pagesA
Read-only

Which PDF and page Okular is showing.

Open Okular windows: document path, current page (1-based) and page count.

Inputs: no named parameters. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    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

    Related MCP Connectors

    Related MCP Servers

    • F
      license
      Not graded
      quality
      D
      maintenance
      Enables AI assistants to search and query PDF documents through a local RAG system with vector embeddings. Provides semantic document search capabilities while keeping all data stored locally without external dependencies.
      -
    • A
      license
      Not graded
      quality
      B
      maintenance
      Gives AI agents tools to read PDFs, list form fields, apply operations, highlight, add notes, fill fields, place signatures, list signatures, and flatten PDFs.
      1
      AGPL 3.0
    • A
      license
      A
      quality
      B
      maintenance
      Enables a conversational workflow to read local PDFs, propose exact word-level passages, apply colored highlights, and inspect saved annotated copies, plus recolor or remove existing highlights. All PDF and image I/O stays confined to a single workspace directory, with fingerprint verification and atomic saves so originals are never overwritten.
      10
      MIT
    • A
      license
      A
      quality
      C
      maintenance
      Enables clients to inspect local PDFs by searching text, rendering pages, and cropping selected regions at high resolution directly from the source document. It runs locally over stdio without requiring models, embeddings, GPUs, or API keys, and does not modify source PDFs or send network requests.
      6
      MIT