# Sırdaş MCP server

A local [Model Context Protocol](https://modelcontextprotocol.io) server over **stdio**. It reads and writes files on the machine where it runs and never opens a network connection. Every tool declares `openWorldHint: false`.

## Install

**Claude Code** (one line):

```bash
claude mcp add sirdas -- npx -y @sirdaspdf/mcp
```

**Claude Desktop, Cursor, VS Code, Windsurf** and any MCP client:

```json
{
  "mcpServers": {
    "sirdas": { "command": "npx", "args": ["-y", "@sirdaspdf/mcp"] }
  }
}
```

**From source** (to hack on it):

```bash
git clone https://github.com/Brayan15p/SIRDAS-APP-PDF.git
cd SIRDAS-APP-PDF
npm install
npm run build -w packages/core -w packages/mcp
# server entry point: <repo>/packages/mcp/dist/cli.js
```

```json
{
  "mcpServers": {
    "sirdas": { "command": "node", "args": ["/absolute/path/to/SIRDAS-APP-PDF/packages/mcp/dist/cli.js"] }
  }
}
```

Where to put that JSON:

- **Claude Desktop** — `claude_desktop_config.json` (Settings → Developer → Edit Config).
- **Claude Code** — `claude mcp add sirdas -- npx -y @sirdaspdf/mcp` (or `-- node /path/to/cli.js`).
- **Cursor** — `.cursor/mcp.json` in the project, or the global MCP settings.

Requires Node.js ≥ 20. The server prints one line to **stderr** when ready; stdout is reserved for JSON-RPC.

## Where it can read and write

The paths this server touches are chosen by a language model, not typed by a person, so it only reads and writes inside **one allowed root**. A path that resolves outside it is refused before any file is opened: `..` escapes, absolute paths elsewhere, symlinks pointing out of the root, and files that do not exist yet whose parent directory is outside it. Paths are checked after `realpath`, so a symlink cannot smuggle a write out, and the root itself may be a symlink.

| | |
|---|---|
| Variable | `SIRDAS_ROOT` |
| Default | the **working directory of the server process** — the folder your MCP client launches it from |
| Relative paths | resolved from the root |
| Opt out | `SIRDAS_ROOT=/` allows the whole filesystem |

The default is the working directory rather than something like `~/Documents` because that is the folder the client already scoped the session to, and because a default that widens over time is how a local tool becomes an arbitrary file writer. `SIRDAS_ROOT=/` restores the old, unconfined behaviour; it is a deliberate choice and never the default.

```json
{
  "mcpServers": {
    "sirdas": {
      "command": "npx",
      "args": ["-y", "@sirdaspdf/mcp"],
      "env": { "SIRDAS_ROOT": "/Users/you/Documents/pdfs" }
    }
  }
}
```

A refusal comes back as a normal tool error naming the root and the variable:

```
Path is outside the allowed root: /etc/passwd. This Sırdaş MCP server only reads
and writes inside /Users/you/Documents/pdfs. Restart it with SIRDAS_ROOT=<directory>
to point it somewhere else, or SIRDAS_ROOT=/ to allow the whole filesystem (it then
writes anywhere the process can).
```

The server also prints its allowed root on the stderr line it writes at startup.

## Tools

Seventeen tools. All PDF inputs accept **either** `path` (absolute, or relative to the allowed root) **or** `base64` (raw PDF bytes) — never both. Tools that write files refuse to overwrite unless `overwrite: true`.

### `pdf_to_markdown` — read-only

Convert a text PDF to Markdown for an LLM.

| Argument | Type | Default | Notes |
|---|---|---|---|
| `path` / `base64` | string | — | One of the two is required. |
| `anonymize` | boolean | `true` | Replace personal data with placeholders. |
| `max_chars` | integer | — | Truncate the Markdown. |

Returns the Markdown as the text content, plus `structuredContent`: `pages`, `characters`, `tokens_estimate`, `anonymized`, `redacted` (count per entity type), `truncated`, `has_text`, `source`. A scanned PDF yields `has_text: false` and an explanatory line instead of content.

Entity types: `NOMBRE`, `DOCUMENTO`, `NIT`, `EMAIL`, `TELEFONO`, `FECHA`, `DIRECCION`, `TARJETA`, `HISTORIA_CLINICA`.

### `anonymize_text` — read-only

| Argument | Type | Default | Notes |
|---|---|---|---|
| `text` | string | — | Any text. |
| `include_mapping` | boolean | `false` | Also return `[placeholder → original]` so the agent can de-anonymize an answer locally. |

Returns the anonymized text; `structuredContent` has `total`, `redacted` per type, `tokens_estimate` and, if requested, `mapping`.

Typical pattern: anonymize locally → send the placeholder text to a cloud model → map placeholders back in the answer, on the user's machine.

### `deanonymize_text` — read-only

The return trip. Give it a text with placeholders and the mapping you kept, and it puts the real values back.

| Argument | Type | Notes |
|---|---|---|
| `text` | string | Typically the AI's answer. |
| `mapping` | object | `{"[NOMBRE_1]": "Ana Gómez"}`, from `anonymize_text` with `include_mapping`. |

Returns the restored text; `structuredContent` has `restored` (how many were replaced) and `unknown` (placeholders absent from the mapping — usually invented by the model — left untouched rather than silently dropped).

**Nothing is ever deleted.** The original document is not modified and the mapping stays on the user's machine.

### `read_document` — read-only · start here

One call that does what a person would, in order: detect encryption (restricted files are unlocked with no password; `password` opens protected ones), OCR if the PDF is a scan, identify the type, extract the key fields for that type, anonymize personal data **consistently** across text, fields and tables, detect tables, split cited chunks.

| Argument | Type | Default |
|---|---|---|
| `path` / `base64` | string | — |
| `password` | string | — |
| `anonymize` | boolean | `true` |
| `ocr` | boolean | `true` |
| `max_tokens` | integer | `1500` |
| `include` | `markdown` · `chunks` · `tables` · `fields` | `["fields","tables","chunks"]` |

`structuredContent`: `status` (`ok` · `needs_password` · `wrong_password` · `scan_without_ocr`), `pages`, `steps` (what was done — tell the user), `document_type`, `type_match`, `type_signals`, `fields[]` (`name`, `value`, `page`, `evidence`, `personal`), `tables[]`, `chunks[]`, `redacted`, `tokens`, **`next_steps[]`** (`tool`, `why` — offer them), `bytes_sent: 0`. Sends progress notifications.

Fields by type — factura: `numero_factura`, `cufe`, `nit_emisor`, `fecha_emision`, `fecha_vencimiento`, `subtotal`, `iva`, `total`, `resolucion_dian` · historia_clinica: `paciente`, `documento_paciente`, `fecha_atencion`, `motivo_consulta`, `diagnostico_cie10` (all) · rut: `nit`, `razon_social`, `direccion_seccional`, `actividad_principal` · cedula · extracto_bancario · contrato · certificado. Personal fields are replaced with the same placeholders as the text; business fields (dates, totals) are kept.

### `extract_fields` — read-only

`text`, optional `type` (detected if omitted). Same fields as above, from text you already have.

### `convert_to_markdown` — read-only

`path`, `anonymize` (default `false`). DOCX, XLSX, PPTX, ODT, ODS, ODP, EPUB, HTML, CSV/TSV, EML, RTF, JSON, Markdown or text → Markdown, plus `tables` (rows) and `warnings` (e.g. formulas using cached values). Content, not layout.

### `split_documents` — writes files only with `write: true`

`path`, `write` (default `false`), `output_dir`, `overwrite`. Finds where each document starts inside one PDF — page numbering back to 1, an opening heading (FACTURA, HISTORIA CLÍNICA, CERTIFICADO…), a strong change of document type — and returns `segments` (`from`, `to`, `type`, `reasons`). With `write: true` it saves `<name>-01-factura.pdf`, `<name>-02-historia_clinica.pdf`… Show the segments to the user before writing when it matters.

### `pdf_forms` — read-only

`path` / `base64`, `anonymize`. Fillable (AcroForm) fields — text, checkbox, radio, dropdown, list — with `page`, plus checkboxes written as characters (☒ ☐, `[x]`, `( )`). Hand-ticked boxes on scans are **not** detected (that needs a vision model).

### `pdf_to_chunks` — read-only

Convert a PDF to Markdown and split it into pieces that fit a model's context. Each chunk breaks at a heading and carries that heading, so it stands on its own — for feeding a long document to a chat in order, or for RAG indexing.

| Argument | Type | Default |
|---|---|---|
| `path` / `base64` | string | — |
| `max_tokens` | integer | `2000` |
| `anonymize` | boolean | `true` |

Returns the chunks as text and, in `structuredContent`, `chunks[]` with:

| Field | Meaning |
|---|---|
| `id` | Content hash (`fnv1a:…`). Same text → same id: use it as the vector id so re-indexing updates instead of duplicating. |
| `pages` | `[first, last]` source pages, 1-based. Cite them. |
| `section` | Full heading path, e.g. `["Contrato", "Cláusula tercera"]`. |
| `heading`, `index`, `tokens`, `text` | As before. `tokens` is an estimate (~4 chars/token), not a model tokenizer. |

Plus `total_chunks`, `doc_type` / `doc_type_confidence` (see `pdf_classify`) and `bytes_sent: 0`.

### `pdf_classify` — read-only

`path` / `base64`. Detects the document type with transparent rules, no model: `factura`, `contrato`, `historia_clinica`, `cedula`, `rut`, `extracto_bancario`, `certificado`, `hoja_de_vida` or `desconocido`. Returns `type`, `confidence` (share of matched signal weight — not a calibrated probability), the `signals` that matched and a `runner_up`. A scan without text returns `has_text: false`: run `pdf_ocr` first.

### `pdf_tables` — read-only

Find the tables in a PDF and return them as CSV and Markdown.

| Argument | Type | Default | Notes |
|---|---|---|---|
| `path` / `base64` | string | — | |
| `delimiter` | `,` `;` `\t` | `,` | Use `;` for Excel in Spanish locales. |
| `min_rows` | integer | `3` | Rows, header included, for a block to count as a table. |

Columns are reconstructed from the whitespace that recurs across rows, not from where each cell starts — that is what separates right-aligned numeric columns (invoice amounts, statement balances) correctly. `structuredContent` returns `tables[]` with `page`, `columns`, `rows` and `csv`, plus `total`.

Needs a text layer: a scan returns `total: 0`. Run OCR first for those.

### `compare_pdfs` — read-only

Compare two versions of a document line by line.

| Argument | Type | Default | Notes |
|---|---|---|---|
| `before` | string | — | Path to the earlier version. |
| `after` | string | — | Path to the newer version. |
| `changes_only` | boolean | `true` | Only changed lines plus two lines of context. |
| `ignore_case` | boolean | `false` | |

The diff runs on visual lines, not paragraphs, so a one-word edit reports as one changed line instead of flagging the whole block. `structuredContent` returns `added`, `removed`, `unchanged`, `similarity` (0–1), `changed_tokens` and `identical`. Leave `changes_only` on before asking a model to explain a revision: it is what keeps the context small.

### `pdf_to_dataset` — read-only

Turn PDFs into a JSONL training set.

| Argument | Type | Default | Notes |
|---|---|---|---|
| `paths` | string[] | — | PDFs or any supported file (DOCX, XLSX, PPTX, HTML, EPUB, CSV…). |
| `format` | `text` `chat` `prompt-completion` `alpaca` `sharegpt` `langchain` `llamaindex` `embeddings` | `text` | Fine-tuning (TRL, OpenAI, Axolotl, LLaMA-Factory, Unsloth) or RAG shapes. |
| `output_dir` | string | — | Writes `train/validation/test.jsonl` plus a Hugging Face dataset card (`README.md`). |
| `max_tokens` | integer | `2000` | Tokens per record before chunking. |
| `anonymize` | boolean | `true` | |
| `dedupe` | boolean | `true` | Drop near-duplicate records. |
| `split` | boolean | `false` | Train/validation/test **by document**. |
| `template` / `system` | string | — | For `chat` and `prompt-completion`. |

Every record carries `sirdas: {document, chunk, heading, tokens, redacted}`, so a
bad example traces back to a page. Trainers ignore unknown fields, so leaving it
in costs nothing.

The split is by document, never by chunk: two chunks of one contract on opposite
sides of the split means the model already saw the template, and the metric
flatters you. It is also stable — adding a document does not move the existing
ones, so you can regenerate without invalidating an earlier evaluation.

No model is called. Records come from the documents by rules, so there is no
question-answer generation here; see `pdf_to_rl_pairs` for what can be built
without one.

### `find_duplicates` — read-only

MinHash + LSH over 5-word shingles. `threshold` (default `0.8`) is the minimum
Jaccard similarity; the LSH banding is derived from it so that lowering the
threshold actually finds more, and the final check is exact Jaccard rather than
the signature estimate. The first occurrence is always the one kept.

Duplicated text gets memorized rather than learned — Lee et al. (arXiv:2107.06499)
measured that deduplication cuts verbatim emission tenfold — and a folder of real
documents is full of repeats: the same template, the same report twice.

### `check_contamination` — read-only

| Argument | Type | Default |
|---|---|---|
| `train` / `evaluation` | string[] | — |
| `n` | integer | `13` |

Reports which training documents share an n-gram with the evaluation set, with
the literal fragments so the finding can be checked. n-grams appearing in more
than 10 reference documents are ignored, which is GPT-3's rule against legal
boilerplate — it matters in corporate corpora where boilerplate dominates. Use
`n: 8` for Llama 3's criterion.

### `pdf_to_rl_pairs` — read-only

Builds (prompt, verifiable answer) pairs from tables, for RL with verifiable
rewards. Output is JSONL with `prompt` and `ground_truth`, the column names
GRPOTrainer and the reference RLVR datasets use; extra columns reach the reward
function as kwargs. `reward_function: true` also returns the matching Python
reward function.

Generates cell lookups, row counts and column extremes. Anything ambiguous is
skipped — a repeated row key or a tie for the maximum produces no question,
because a pair with two right answers punishes the model for being correct.
Numbers compare as numbers, so `1.120.000` and `1120000` match.

### `find_boilerplate` — read-only

Find the paragraphs that repeat across a set of documents: letterheads, footers,
confidentiality notices, the data-protection clause identical in every contract.

| Argument | Type | Default |
|---|---|---|
| `paths` | string[] | — |
| `min_words` | integer | `5` |
| `min_repeats` | integer | `2` |
| `across_documents_only` | boolean | `true` |

CCNet measured duplicated paragraphs at **70% of its corpus**; in office
documents the pattern is sharper. A model learns that text by heart and learns
nothing from it. Matching follows CCNet's recipe — lowercase, digits to zero,
accents and punctuation stripped — so "Invoice 1042" and "Invoice 1043" are the
same template paragraph.

Paragraphs under `min_words` are skipped: titles, page numbers and signatures
repeat by nature, and removing them would break the document's structure.
Repeating inside one document is legitimate by default; repeating across five
is a template.

This only reports. `pdf_to_dataset` with `strip_boilerplate: true` is what
removes them, always keeping the first occurrence, so nothing disappears from
the corpus entirely.

### `pdf_ocr` — read-only

Read a scanned PDF with local OCR.

| Argument | Type | Default | Notes |
|---|---|---|---|
| `path` | string | — | The scanned PDF. |
| `language` | string | `spa+eng` | Tesseract languages. The wrong one wrecks accuracy. |
| `lang_path` | string | — | Folder with the `.traineddata` files. |
| `anonymize` | boolean | `true` | |

Use it when `pdf_to_markdown` returns `has_text: false`: that PDF is a photo of a
page, not text. Structure is rebuilt with the same rules as a text PDF, so
headings and paragraphs survive.

It does not rasterize. A scanned PDF already contains one image per page, so the
image is pulled straight out of the file and encoded as PNG. Rasterizing in Node
would need a native canvas with compiled binaries, and `npx sirdas` would stop
working without a toolchain.

Two things it needs, and the error explains both when they are missing:
**tesseract.js**, an optional dependency because its WebAssembly engine is 44 MB
and most PDFs have a text layer; and **language data**, looked up in
`SIRDAS_TESSDATA`, then `public/ocr/lang`, then the user cache. `sirdas ocr
--download-models` fetches it once — the only moment any of this touches the
network, and it is a download, never an upload.

`structuredContent` reports `recognized` against `pages`, and
`pages_with_several_images` when a page held more than one image and only the
largest was read.

### `pdf_info` — read-only

`path` / `base64` → `pages`, `encrypted`, `bytes`, `title`, `author`, `subject`, `creator`, `producer`, `created`, `modified`.

### `merge_pdfs` — writes a file

| Argument | Type | Default |
|---|---|---|
| `paths` | string[] (≥ 2) | — |
| `output` | string | — |
| `overwrite` | boolean | `false` |

### `split_pdf` — writes files

| Argument | Type | Default | Notes |
|---|---|---|---|
| `path` | string | — | |
| `ranges` | string | `""` | 1-based, e.g. `"1-3, 5, 8-end"`. Empty = one file per page. |
| `output_dir` | string | input's directory | |
| `overwrite` | boolean | `false` | |

Output names follow `<name>-p1-3.pdf`, `<name>-p5.pdf`. Returns `files` and total `pages`.

### `compress_pdf_lossless` — writes a file

`path`, optional `output` (default `<name>-optimizado.pdf`), `overwrite`. Returns `before`, `after`, `saved_percent`. Text stays selectable; the output is never larger than the input.

### `edit_pages` — writes a file

| Argument | Type | Default | Notes |
|---|---|---|---|
| `path` | string | — | |
| `action` | `delete` · `extract` · `rotate` | — | |
| `pages` | string | `""` | 1-based ranges. Required for delete/extract; empty rotates every page. Extract keeps the order given. |
| `angle` | 90 · 180 · 270 | `90` | Clockwise, for rotate. |
| `output` / `overwrite` | | `<name>-<action>.pdf` / `false` | |

Deleting every page is refused. Returns `pages_before`, `pages_after`, `affected`.

### `add_page_numbers` — writes a file

`path`, `format` (default `{n}`; `{total}` also available, e.g. `Página {n} de {total}`), `position` (`bottom-center`, `bottom-right`, `bottom-left`, `top-*`), `start_at`, `skip_first` (unnumbered cover pages), `output`, `overwrite`.

### `add_watermark` — writes a file

`path`, `text`, `opacity` (0.03–1, default 0.15), `output`, `overwrite`. A diagonal visible label, not protection. Standard PDF font: Western European characters only; others return a clear error.

### `protect_pdf` — writes a file

`path`, `password`, optional `owner_password` (random if omitted, so opening the file is not enough to lift restrictions), `allow_print`, `allow_copy`, `allow_edit` (all `true` by default), `output` (default `<name>-protegido.pdf`), `overwrite`. AES-256.

### `unlock_pdf` — writes a file

`path`, `password` (default empty), `output` (default `<name>-desbloqueado.pdf`), `overwrite`. Try **without** a password first: files that open but forbid copying/printing unlock with none. Returns `state` (`none` · `restricted` · `password`). A wrong or missing open password returns `isError` with `wrong_password: true` — ask the user, never guess. Any other tool that meets an encrypted PDF says to call `unlock_pdf`.

## Prompts

| Prompt | Arguments | What it does |
|---|---|---|
| `private_ai_answer` | `path`, `question` | The whole private round trip in one go: anonymize with mapping → answer on placeholders → `deanonymize_text` → reply with real names. |
| `index_pdf_for_rag` | `path`, `max_tokens?` | Chunk with ids, pages and sections as vector-store metadata, and cite as «section, p. N». |

## Progress

`pdf_ocr` sends `notifications/progress` (0–100) when the client passes a `progressToken`, so a 300-page scan does not look hung.

## Errors

Failures come back as a tool result with `isError: true` and one actionable sentence — for example a password-protected PDF (`This PDF is password protected…`), a file that is not a PDF, or a path outside the allowed root. The server never crashes on bad input.
