A local Model Context Protocol 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):
claude mcp add sirdas -- npx -y @sirdaspdf/mcp
Claude Desktop, Cursor, VS Code, Windsurf and any MCP client:
{
"mcpServers": {
"sirdas": { "command": "npx", "args": ["-y", "@sirdaspdf/mcp"] }
}
}
From source (to hack on it):
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
{
"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.jsonin 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.
{
"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.