Skip to content

Architecture

conmcp is deliberately small. The organizing rule: server.py stays thin — deterministic PDF work lives in pdf/, construction knowledge lives in domain/, and the MCP layer just wires them together.

Module map

Module What lives there
src/conmcp/server.py FastMCP app; registers all tools and the fence_takeoff prompt. Thin wrappers only — validate input, call a module function, return the result. Tool docstrings are written for the client LLM, not for developers.
src/conmcp/pdf/loader.py Open/cache plan documents (small LRU keyed by path + mtime, since plan sets often run 100+ sheets); 1-based page helpers.
src/conmcp/pdf/render.py Page → PNG for vision models, with DPI capping and optional clip rectangles.
src/conmcp/pdf/search.py Keyword search across pages with relevance scores and snippets.
src/conmcp/pdf/tables.py Ruled-table extraction (pdfplumber) for schedules.
src/conmcp/domain/sheets.py Sheet-type detection from title blocks: sheet numbers (C-101, A2.1), NCS discipline prefixes, title keywords. Unknown pages fall back to unclassified rather than guessing.
src/conmcp/domain/quantities.py Regex quantity extraction (LF/EA/SF callouts, fence/gate callouts, post spacing) and the single source of truth for the UOM vocabulary.
src/conmcp/domain/takeoff.py The Pydantic TakeoffReport/TakeoffItem schema and the contractor-bid to_bid_json export.
src/conmcp/domain/prompts.py The takeoff playbooks: workflow steps, trade-specific gotchas, UOM conventions. This is where 37 years of estimating scar tissue lives.
src/conmcp/report.py TakeoffReport → Markdown/CSV/JSON rendering and sandboxed file output.
src/conmcp/config.py Settings and the path sandbox (CONMCP_ALLOWED_DIRS, DPI and page caps).
src/conmcp/audit.py Append-only JSONL audit log of file access.
src/conmcp/cli.py Typer/Rich CLI mirroring the MCP tools, for humans without an LLM in the loop.
src/conmcp/sampleplan.py Synthetic 4-sheet sample plan generator.

Two invariants cut across everything:

  1. All file access goes through config.resolve_path() — the sandbox check and audit log. No module opens a user-supplied path directly.
  2. Page numbers are 1-based in the entire public API. Estimators think in sheet and page numbers, not indices.

Why vision-first

Construction drawings are graphics with a little text sprinkled on. The fence run is a linetype defined in a legend; the gates are symbols; the scale is a drawn bar; height changes are callouts placed along the run. Text extraction finds some of this, but a takeoff done from text alone is a takeoff done blind.

So conmcp splits responsibility along the deterministic/judgment line:

  • Deterministic, server-side: anything with one right answer — the sheet index, page text, keyword hits, ruled tables, regex quantity matches, PNG renders. Fast, repeatable, testable.
  • Visual judgment, client-side: reading the rendered drawing, measuring against the graphic scale, deciding whether that dashed line is a fence or a setback. That's what vision LLMs are for — and the server-supplied playbooks keep them honest about how estimators actually do it.

The deterministic extractions are explicitly framed as leads, not answers: 450 LF - 6' HIGH CHAIN LINK FENCE in the page text tells the model where to look, and the drawing tells it whether to believe it.

Why the server never calls an LLM (v0.1)

Tools return images and structured data for the client's model to reason over — never model output. This buys four things:

  • Client and model agnosticism. Claude, a hosted model in Cursor, a local vision model behind a custom agent — conmcp doesn't care. The intelligence is pluggable because it was never inside.
  • Privacy. The server has no API key, no outbound connection, nothing to leak. Where page images go is entirely your client's configuration (see Privacy & safety).
  • Determinism and testability. Every tool is a pure function of the PDF and its arguments. The test suite asserts exact outputs with no mocking of model calls.
  • No double-billing. Your client already pays for a model; the server piggybacking its own calls would double cost and latency for no benefit.

A local vision backend (Ollama/MLX) for fully server-side auto-takeoff is a v0.2 idea — as an opt-in addition, not a change to this default. See the roadmap.

The sample plan philosophy

Real plan sets are confidential bid documents, so no real plan PDF is ever committed to the repo — the policy is non-negotiable and enforced in review. Instead, sampleplan.py generates a synthetic 4-sheet commercial set (cover, site plan, fence details, schedules & notes) with realistic content: quantity callouts, a ruled gate schedule, post-spacing notes, BY OTHERS exclusions, even a silt-fence trap in the general notes.

The generator is the shared fixture for the test suite, the docs, and bug reports. Need a test case it doesn't cover? Extend the generator — don't add a PDF.

Testing approach

Tests run against the real FastMCP app through its in-memory client (fastmcp.Client(mcp)) — no subprocess, no stdio transport, no running server. A test generates the sample plan into a temp directory, points CONMCP_ALLOWED_DIRS at it, and calls tools exactly as an MCP client would. That keeps the suite fast enough to run on every change and honest enough to catch wiring bugs, not just unit-level ones.

The dev loop is on the contributing page.