MCP server bridging Paperless-ngx to Claude Code
  • C# 74.1%
  • HTML 24.2%
  • CSS 1%
  • JavaScript 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
CheapNud 46c0434b3d
All checks were successful
Build & Test / Build and Test (push) Successful in 1m42s
Build, Push & Deploy / Build and Test (push) Successful in 1m52s
Build, Push & Deploy / Build & Push Docker Image (push) Successful in 50s
Build, Push & Deploy / Deploy to the app host (push) Successful in 18s
Point the stack at the X-13 hosts (#21)
2026-08-24 21:50:05 +00:00
.forgejo/workflows Point the stack at the X-13 hosts (#21) 2026-08-24 21:50:05 +00:00
CheapClerk.Tests Show only document tasks in the queue (#14) 2026-08-17 23:04:59 +00:00
CheapClerk.Web Use archive totals on the dashboard and show basic metrics (#17) 2026-08-18 00:09:24 +00:00
Configuration Add expiring share links with per-document revocation (#5) 2026-08-15 19:09:23 +00:00
Data Add expiring share links with per-document revocation (#5) 2026-08-15 19:09:23 +00:00
docker Move the production cache to PostgreSQL 2026-07-13 16:24:30 +02:00
Models Use archive totals on the dashboard and show basic metrics (#17) 2026-08-18 00:09:24 +00:00
Services Coin new labels when nothing existing fits (#19) 2026-08-18 01:15:36 +00:00
Tools Add expiring share links with per-document revocation (#5) 2026-08-15 19:09:23 +00:00
.gitignore Move CI and deployment to the forge (#1) 2026-08-12 09:48:58 +00:00
appsettings.json Add expiring share links with per-document revocation (#5) 2026-08-15 19:09:23 +00:00
AppVersion.cs Show app version in nav drawer, serialize publish runs 2026-07-11 19:56:40 +02:00
CheapClerk.csproj Use archive totals on the dashboard and show basic metrics (#17) 2026-08-18 00:09:24 +00:00
CheapClerk.slnx Add test project with HTTP stub harness 2026-07-06 12:35:31 +02:00
docker-compose.yml Point the stack at the X-13 hosts (#21) 2026-08-24 21:50:05 +00:00
LICENSE Initial commit 2026-04-04 13:48:14 +02:00
Program.cs Register the share tools with the MCP host (#6) 2026-08-15 19:18:31 +00:00
README.md Move CI and deployment to the forge (#1) 2026-08-12 09:48:58 +00:00
TODO.md Point the stack at the X-13 hosts (#21) 2026-08-24 21:50:05 +00:00

CheapClerk

A C# MCP server that bridges Paperless-ngx to Claude Code, turning your scanned home documents into a queryable knowledge base.

Part of the CheapNud open-source ecosystem.


What This Is

CheapClerk lets you ask natural language questions about your household paperwork — insurance policies, utility contracts, tax documents, receipts, warranty cards — directly from Claude Code. No GUI needed. You scan documents into Paperless-ngx, CheapClerk exposes them as MCP tools, and Claude Code does the rest.

You: "What's the deductible on my home insurance?"
Claude Code → search_documents("home insurance deductible")
           → get_document_content(doc_id: 47)
           → "Your deductible is €500 per claim (KBC Woonverzekering, policy dated 2024-03-12)"

Architecture

Physical documents
        │
        │  scan (Paperless mobile app / flatbed scanner)
        ▼
┌─────────────────────────────┐
│  Paperless-ngx              │  Docker on Sierra-Madre (:8010)
│  • Tesseract OCR            │  Media on mirrored bfa pool (RAID10)
│  • Full-text search (FTS)   │  Database on Vault-Tec PostgreSQL
│  • REST API                 │
│  • Tagging & correspondents │
└─────────────┬───────────────┘
              │ HTTP (internal network)
              ▼
┌─────────────────────────────┐
│  CheapClerk MCP Server      │  .NET 11 / C# console app
│  • Wraps Paperless REST API │  Runs as stdio MCP server
│  • Vision OCR fallback      │  Launched by Claude Code
│  • 5 tools exposed          │
└─────────────┬───────────────┘
              │ MCP (stdio)
              ▼
┌─────────────────────────────┐
│  Claude Code                │
│  "What's my electricity     │
│   contract end date?"       │
└─────────────────────────────┘

Tech Stack

Layer Technology Notes
Runtime .NET 11 Matches broader CheapNud ecosystem
MCP SDK ModelContextProtocol NuGet Official .NET MCP implementation
HTTP Client HttpClient via DI Talking to Paperless-ngx REST API
Shared plumbing CheapHelpers NuGet Shared utilities from the CheapNud ecosystem
OCR (primary) Tesseract via Paperless-ngx Built into Paperless, no extra config
OCR (fallback) Claude Vision API For handwritten or low-quality scans
Document store Paperless-ngx Docker deployment, handles all storage
Config appsettings.json + env vars Paperless URL, API token, vision thresholds

MCP Tools

search_documents

Full-text search across all ingested documents.

[Tool("search_documents")]
async Task<string> SearchDocuments(
    string query,
    string? tag = null,
    string? correspondent = null,
    int maxResults = 10
)

Returns: document ID, title, matched excerpt, tags, date, correspondent.

get_document_content

Retrieve the full OCR text of one or more documents. Triggers vision fallback if OCR quality is poor.

[Tool("get_document_content")]
async Task<string> GetDocumentContent(
    int[] documentIds,
    bool forceVisionOcr = false
)

Returns: full text content, with a per-document header (and links) when multiple IDs are passed — bulk-first so an assistant reads N documents in one round trip. If forceVisionOcr is true or Tesseract output is below confidence threshold, fetches original scan and runs Claude Vision.

list_documents

Browse documents with filters. Useful for "show me all documents from KBC" or "what did I scan last month."

[Tool("list_documents")]
async Task<string> ListDocuments(
    string? correspondent = null,
    string? tag = null,
    DateTime? addedAfter = null,
    DateTime? addedBefore = null,
    int maxResults = 25
)

Returns: summary list with ID, title, correspondent, tags, dates.

get_document_metadata

Retrieve metadata for one or more documents without the full text — faster for bulk operations.

[Tool("get_document_metadata")]
async Task<string> GetDocumentMetadata(int[] documentIds)

Returns: title, correspondent, tags, dates (created, added, modified), archive serial number, original filename.

Document links: when Web:PublicBaseUrl is configured (e.g. Web__PublicBaseUrl=https://clerk.example.com in the MCP server's environment), search_documents, list_documents, get_document_metadata and bulk get_document_content append two links per document — the viewer page and the raw file — so the assistant can hand you the document, not just describe it.

list_tags

List all available tags in Paperless. Helps Claude Code understand the taxonomy.

[Tool("list_tags")]
async Task<string> ListTags()

Returns: tag names with document counts.

list_review_queue

List all documents awaiting review (tagged with Needs Review). Each entry includes the stored suggestion.

[Tool("list_review_queue")]
async Task<string> ListReviewQueue()

Returns: documents with their low-confidence suggestions (title, correspondent, document type, tags, date).

apply_suggestion

Accept a queued document's suggestion (with optional field overrides) and file it. Removes the Needs Review tag and applies the metadata update to Paperless.

[Tool("apply_suggestion")]
async Task<string> ApplySuggestion(
    int documentId,
    string? title = null,
    string? correspondent = null,
    string? documentType = null,
    string? tags = null,
    string? documentDate = null   // yyyy-MM-dd
)

Parameters are merged onto the stored suggestion; explicit values override the cached fields. Returns: confirmation of the filed document.

upload_document

Upload a new document for ingestion into Paperless-ngx. Consumes the file, tracks progress (~30s), auto-classifies once consumption completes, and surfaces any duplicate-rejection feedback.

[Tool("upload_document")]
async Task<string> UploadDocument(string filePath)

Allowed file types: PDF, PNG, JPG, JPEG, WebP, GIF, TIFF (50MB cap). Returns: consumption status, auto-classification result, or duplicate-rejection reason.

reclassify_document

Re-run classification on a document already tagged with Needs Review, optionally forcing a fresh Vision OCR pass. Stores the new suggestion and updates the review queue.

[Tool("reclassify_document")]
async Task<string> ReclassifyDocument(
    int documentId,
    bool forceVisionOcr = false
)

Returns: the refreshed suggestion (also stored as the document's latest).

translate_taxonomy

Fill in missing tag and document-type translations for every supported culture. Run after adding tags or when labels show untranslated.

[Tool("translate_taxonomy")]
async Task<string> TranslateTaxonomy()

Returns: per-culture translation summary (already translated, newly translated, failed).

get_payment_details

Structured payment data for an extracted invoice, plus the raw EPC069-12 payload (renderable as a SEPA payment QR).

[Tool("get_payment_details")]
async Task<string> GetPaymentDetails(int documentId)

Requires a cached extraction with an IBAN and amount. Returns: beneficiary, normalized IBAN, amount, reference, due date, and the EPC payload. Amounts with sub-cent precision, non-EUR currencies and malformed IBANs are rejected rather than corrected — a human should read that bill.

list_document_types

List all document types with document counts (sibling of list_tags).

[Tool("list_document_types")]
async Task<string> ListDocumentTypes()

list_correspondents

List all correspondents with document counts.

[Tool("list_correspondents")]
async Task<string> ListCorrespondents()

update_document

Update a document's title, tags, correspondent or document type using canonical names. Unknown names are rejected with the list of valid options; workflow tags (Inbox / Needs Review) on the document are always preserved.

[Tool("update_document")]
async Task<string> UpdateDocument(
    int documentId,
    string? title = null,
    string? tags = null,          // comma-separated canonical names, replaces the set
    string? correspondent = null,
    string? documentType = null
)

delete_document

Delete a document — refuses without explicit confirmation, echoing the title so you know what you're about to lose.

[Tool("delete_document")]
async Task<string> DeleteDocument(int documentId, bool confirm = false)

paperless_status

Connectivity and taxonomy-count diagnostic. Honest about its limits: an all-zero reply may mean an empty archive or an unreachable server.

[Tool("paperless_status")]
async Task<string> PaperlessStatus()

search_documents and list_documents additionally accept a documentType filter alongside the existing tag and correspondent filters.


Payment QR

Invoices whose extraction contains an IBAN and amount get a Pay this bill section on the document page: an EPC069-12 (SEPA) QR code that any Belgian banking app scans directly, alongside the beneficiary, IBAN, amount and reference in plain text — always verify those against the document before paying. The QR encodes only what extraction read; validation rejects anything suspicious (sub-cent amounts, non-EUR, malformed IBANs) instead of guessing.


Localization

CheapClerk supports multi-language UI and taxonomy data translation.

UI Localization

The Blazor UI supports English (en) and Dutch (nl) cultures, configured via a culture picker and persisted in a culture cookie. The UI strings live in Resources/ resx files, one per language.

Data Translation

Document taxonomy (tags and document types) can be displayed in multiple languages while writes stay canonical:

  • Canonical storage: Tag and document-type names in Paperless keep their canonical form — the language the classifier coins them in (Classification:TaxonomyLanguage, default Dutch)
  • Display-only translation: The TaxonomyTranslationService maintains a translation map in the cache database, keyed by (tag/type name, culture), populated on-demand by the configured LLM
  • Self-healing on renames: When a tag is renamed in Paperless, the translation map automatically falls back to the canonical name for that key. No manual cleanup needed

The translate_taxonomy MCP tool backfills translations for any missing entries across all supported cultures when called (typically after adding new tags).


Vision OCR Fallback

The fallback triggers when Paperless OCR output looks unreliable:

1. get_document_content called
2. Fetch OCR text from Paperless REST API
3. Quality check:
   - Text length < 50 chars for a multi-page doc? → suspect
   - High ratio of garbage characters (□, <20>, ...)? → suspect
   - forceVisionOcr explicitly set? → skip check
4. If suspect → fetch original image/PDF from Paperless
5. Convert to base64, send to Claude Vision API
6. Return vision transcription instead

Threshold is conservative — Tesseract is fast and free, Vision API costs money. Only falls back when clearly needed.


Paperless-ngx REST API Reference

Base URL: http://<paperless-host>:8000/api/ Auth: Token-based (Authorization: Token <api-token>)

Key endpoints:

  • GET /api/documents/ — list/search documents (supports ?query=, ?tags__id=, ?correspondent__id=, ?ordering=)
  • GET /api/documents/{id}/ — document metadata
  • GET /api/documents/{id}/download/ — original file
  • GET /api/documents/{id}/preview/ — archived (OCR'd) version
  • GET /api/documents/{id}/thumb/ — thumbnail
  • GET /api/tags/ — list tags
  • GET /api/correspondents/ — list correspondents
  • GET /api/document_types/ — list document types

Full API docs ship with Paperless at /api/schema/swagger-ui/.


Project Structure

CheapClerk/
├── CheapClerk.csproj
├── CheapClerk.slnx
├── Program.cs                       # MCP server bootstrap
├── appsettings.json
├── README.md
├── TODO.md
├── LICENSE
├── .gitignore
├── .github/
│   └── workflows/
│       └── dotnet.yml
├── Configuration/
│   ├── PaperlessOptions.cs
│   └── VisionFallbackOptions.cs
├── Tools/
│   ├── SearchDocumentsTool.cs
│   ├── GetDocumentContentTool.cs
│   ├── ListDocumentsTool.cs
│   ├── GetDocumentMetadataTool.cs
│   └── ListTagsTool.cs
├── Services/
│   ├── PaperlessClient.cs           # HTTP client for Paperless REST API
│   ├── VisionOcrService.cs          # Claude Vision fallback
│   └── OcrQualityChecker.cs         # Confidence threshold logic
├── Models/
│   ├── PaperlessDocument.cs
│   ├── PaperlessTag.cs
│   ├── PaperlessCorrespondent.cs
│   ├── PaperlessPage.cs
│   └── DocumentMatch.cs
└── docker/
    └── docker-compose.yml           # Paperless-ngx deployment

Claude Code MCP Configuration

Add to ~/.claude.json (global) or .claude/settings.json (project):

{
  "mcpServers": {
    "cheapclerk": {
      "command": "dotnet",
      "args": ["run", "--project", "/path/to/CheapClerk"]
    }
  }
}

Configuration (appsettings.json)

{
  "Paperless": {
    "BaseUrl": "http://localhost:8000",
    "ApiToken": "<from-paperless-admin-panel>"
  },
  "VisionFallback": {
    "Enabled": true,
    "MinTextLength": 50,
    "MaxGarbageRatio": 0.15
  },
  "Llm": {
    "Provider": "Anthropic",
    "Anthropic": {
      "ApiKey": "<anthropic-api-key>",
      "Model": "claude-haiku-4-5-20251001"
    },
    "Ollama": {
      "BaseUrl": "http://localhost:11434",
      "Model": "llama3.2"
    }
  }
}

LLM providers: Structured extraction uses the configured Llm.Provider (Anthropic or Ollama). Vision OCR fallback always uses Anthropic since local vision models are still unreliable for Belgian household documents. To run fully offline, set Provider: Ollama and disable VisionFallback.Enabled.

Cache database: Extractions, parked review suggestions and taxonomy translations live in a small cache database. Cache:Provider selects the backend:

{
  "Cache": {
    "Provider": "Sqlite",          // local dev and tests
    "DatabasePath": "cheapclerk.db",
    "ConnectionString": null       // required when Provider is "Postgres"
  }
}

Production runs Provider: Postgres against the shared PostgreSQL instance (see the compose file) — SQLite is only for local dev and tests. The schema is created automatically on first start; no migrations to run.


Paperless-ngx Docker Compose

Deployed on Sierra-Madre at /opt/paperless (see docker/docker-compose.yml for the reference file). Key deployment choices:

  • Port 8010 on the host (8000 was taken by Portainer's edge tunnel)
  • Media and export on /mnt/bfa-appdata/paperless — a mirrored ZFS pool, because scanned originals are the one copy that must survive a disk failure. Data/consume/redis stay in local Docker volumes (regenerable).
  • PostgreSQL on Vault-Tec instead of SQLite, following the pattern of the other self-hosted services
  • Secrets in /opt/paperless/.env (PAPERLESS_DBPASS, PAPERLESS_SECRET_KEY, PAPERLESS_ADMIN_PASSWORD)
  • PAPERLESS_OCR_LANGUAGES: nld fra deu installs the tesseract language packs at container start (the image only ships English data; PAPERLESS_OCR_LANGUAGE alone fails the startup check). If the container comes up unhealthy right after an image update, restart it once — the language install can race the startup check on first boot.

OCR languages: Dutch (primary), English, French, German — covers Belgian household documents.

The cheapclerk-web container runs separately on Megaton (/opt/blazor-apps/cheapclerk) and points at Paperless via Paperless__BaseUrl + an API token generated with manage.py drf_create_token.

Releases are tag-driven: pushing a v*.*.* tag to the forge runs .forgejo/workflows/deploy.yml — build + test, push the image to the forge container registry, then SSH to the app host, reset its git replica to the tag and docker compose up. The root docker-compose.yml is the deployment file (memory caps committed, secrets in the host-side untracked .env); docker/docker-compose.yml remains the Paperless reference stack.


Automatic Classification

New documents don't need manual filing. Paperless marks every consumed document with the Inbox tag (created automatically as an inbox-type tag on the clerk's first run); CheapClerk then reads the OCR text and asks the configured LLM for a title, correspondent, document type, topical tags and the document date, PATCHing the result back and removing the inbox tag. Existing taxonomy is strongly preferred — new tags are only created when nothing fits (capped, existing matches win). Garbled scans go through the Vision OCR fallback first. Anything the classifier isn't confident about gets a Needs Review tag instead of guesses.

Four triggers:

  • Background pollInboxPollingService in CheapClerk.Web, every Classification:PollIntervalMinutes (0 disables the poller)
  • Dashboard button — "Process now" on the inbox card
  • MCP toolprocess_inbox from Claude Code
  • Webhook — Paperless fires POST /api/inbox/process (token-guarded) the moment a document is added; the poll becomes a safety net. Prefer sending the token via the X-Webhook-Token header; the ?token= query form works but relies on request-path logging staying suppressed (Microsoft.AspNetCore at Warning), and any reverse proxy in front would log query strings regardless.

Configuration (Classification section):

Key Default Meaning
Enabled true Master switch
InboxTagName Inbox Tag marking unprocessed documents
ReviewTagName Needs Review Applied instead of guesses below the confidence bar
MinConfidence 0.6 Below this, documents go to review
PollIntervalMinutes 15 Background poll cadence; 0 = manual only
MaxTagsPerDocument 4 Cap on applied tags, existing matches first
AutoCreateTags true Allow the LLM to introduce new tags
MaxDocumentsPerRun 20 Batch size per run; the poller drains over successive runs
TaxonomyLanguage nl Display language for taxonomy in UI and MCP tools; affects what translations are cached and offered
WebhookToken (unset) Shared secret for the webhook endpoint; unset = endpoint returns 404

Classification uses the same Llm.Provider switch as extraction — without an Anthropic key (or Ollama endpoint) configured, the processor logs that the provider is unconfigured and leaves the inbox untouched.

Review queue

Low-confidence documents — those below the MinConfidence threshold — receive a Needs Review tag instead of auto-filed suggestions. The classification run stores its LLM-generated suggestion (title, correspondent, document type, tags, document date) in the cache database at the moment of low-confidence detection; only the latest suggestion per document is retained.

The /review page in CheapClerk.Web displays all queued documents awaiting review. Each shows the stored suggested fields (title, correspondent, document type, tags, date) in editable form. Three actions are available:

  • Accept — applies the suggestion (or user edits) through the same filing path as auto-classification: PATCH the metadata back to Paperless and remove the Needs Review tag.
  • Edit — modify any suggested field before accepting.
  • Re-run — request a fresh classification attempt with an optional forceVisionOcr flag to skip the quality check and re-extract text via Claude Vision even if Tesseract looks acceptable.

Uploading

Documents may be uploaded via the Blazor app's upload dialog in the app bar, or directly from Claude Code using the upload_document MCP tool. Supported formats: PDF, PNG, JPG, JPEG, WebP, GIF, TIFF, with a 50MB per-file cap.

The upload flow:

  1. File sent to Paperless-ngx for consumption (~30 seconds).
  2. Consumption status polled by a shared UploadTracker (budget: 30s). UploadRules enforces type and size limits.
  3. Duplicate rejection surfaces immediately if Paperless detects a collision.
  4. Upon successful consumption, auto-classification begins (the webhook or a poller picks it up from the inbox).

Outcome states: consumed (waiting for classification), duplicate-rejected (collision detected), still-processing (timeout waiting for consumption). The webhook files everything after consumption; the poller is a safety net.


Example Queries

Once documents are scanned and indexed, these should all work from Claude Code:

  • "What's my home insurance policy number?"
  • "When does my electricity contract with Engie expire?"
  • "How much was my last water bill?"
  • "Show me all documents from KBC"
  • "What warranty do I have on the Optoma projector?"
  • "What's the cadastral income on my property tax assessment?"
  • "Find anything related to my car insurance for the Focus ST"
  • "What documents did I scan this week?"

Roadmap

Phase 1 (now): MCP server + Paperless-ngx, Claude Code only Phase 2: Blazor Server UI with MudBlazor (search, browse, quick actions) Phase 3: Multi-LLM support (Ollama local models for non-sensitive queries) Phase 4: Structured data extraction — recognize bill formats, extract amounts/dates into typed models (feeds back into Voltiq's bill parser) Phase 5: Automated workflows — "notify me when a document tagged 'expiring' is within 30 days of its end date"


Relationship to Voltiq

CheapClerk is a general-purpose document archive for personal use. Voltiq is a focused energy intelligence platform for Belgian SMEs.

The skills developed here — PDF parsing, OCR quality assessment, Vision API fallback, structured extraction from scanned documents — will be reapplied in Voltiq's bill ingestion feature. But the architectures are intentionally separate: CheapClerk uses Paperless-ngx for broad document management, Voltiq will have a focused BillParserService that extracts typed energy data directly into TimescaleDB.


License

MIT — see LICENSE.