EstimateGuard MCP Server — Documentation

EstimateGuard answers one question: is this home-project price reasonable for this area? It exposes two tools over MCP (streamable HTTP) plus a public health endpoint. No login, no API key — the server is authless.

All examples below are real requests and responses captured from the live server.

Tool 1 — get_cost_range

What it does, plainly: you name a trade, describe the job, and give a US ZIP code; it answers with the low, typical (median), and high price for that job in the nearest covered metro — or tells you plainly it has no data, instead of guessing.

Inputs

NameTypeRequiredMeaning
tradestringyese.g. "roofing", "interior painting", "hvac"
scopestringyesshort job description, e.g. "per sq ft", "full replacement"; used to pick the right pricing basis
zipstringyes5-digit US ZIP code, e.g. "80202"

Output shape

Either a range (reason is null) or all nulls with a reason string. Fields:

FieldMeaning
low / median / highprice bounds in USD, as decimal strings (null when the source published no bound)
unitwhat the prices are per, e.g. "per square foot"
sample_sizehow many past jobs the range is based on — always 0 for seed benchmarks (see provenance note)
as_of_datedate the figures were compiled (YYYY-MM-DD)
provenancewhere the figures came from (verbatim below)
service_typethe specific benchmarked job, e.g. "Cost per Sq Ft (Asphalt)"
regionthe metro the benchmark covers, e.g. "Denver" — city-level data is never presented as ZIP-level data
labor_rate_low / labor_rate_highhourly labor-rate bounds when the source published them, else null
permit_cost_low / permit_cost_highpermit-cost bounds when the source published them, else null
reasonnull when a range is returned; otherwise why no range is returned

Worked example — a range is found

Request:

{
  "trade": "roofing",
  "scope": "per sq ft",
  "zip": "80202"
}

Response (actual):

{
  "low": null,
  "median": "5.50",
  "high": "7.00",
  "unit": "per square foot",
  "sample_size": 0,
  "as_of_date": "2026-08-03",
  "provenance": "Seed benchmark compiled from published cost guides (Angi, HomeAdvisor, regional contractor sources). Exact collection methodology not documented in the source repository. Original 7-city data 2026-08-03; expanded to 12 cities 2026-08-09. Row source: Denver Roofing. Not observed EstimateGuard job data (sample_size=0).",
  "service_type": "Cost per Sq Ft (Asphalt)",
  "region": "Denver",
  "labor_rate_low": null,
  "labor_rate_high": null,
  "permit_cost_low": "300.00",
  "permit_cost_high": "500.00",
  "reason": null
}

When there is no data — this is correct behavior, not an error

If the trade has no benchmark near the ZIP, every field is null and reason explains why. Example request {"trade": "hvac", "scope": "furnace replacement", "zip": "10001"} (actual response, trimmed to the fields that matter):

{
  "low": null,
  "median": null,
  "high": null,
  ...
  "reason": "We don't have cost data for hvac near ZIP 10001 yet, so we won't guess at a range."
}

If the scope doesn't match any pricing basis the server has, it says which pricings it does have instead of guessing. Example request {"trade": "roofing", "scope": "per square", "zip": "80202"} (actual reason):

"For roofing near ZIP 80202 we only have pricing per project (flat price) or per square foot; 'per square' doesn't match, so we won't guess at a range."
On invalid input (empty trade, malformed ZIP), the same shape is returned with reason set, e.g. "Please tell me the trade, for example 'roofing' or 'interior painting'." or "'abc' doesn't look like a 5-digit US ZIP code." — never a stack trace.

Data provenance — read this before quoting the numbers

The cost ranges are seed benchmarks compiled from published cost guides (Angi, HomeAdvisor, regional contractor sources). The exact collection methodology was not documented in the source material, and the figures have not been independently verified. Every row carries sample_size: 0 and the provenance string shown above. Do not describe these ranges as observed, measured, or verified data — they are researched guide figures, and the server labels them as such on every response.

Tool 2 — evaluate_estimate

What it does, plainly: you paste a contractor's written estimate; the server parses each priced line, recomputes every number itself in exact decimal arithmetic, flags any line whose printed total doesn't match quantity × price, compares each unit price against the local benchmark, and returns a plain-language verdict. It also records one PII-stripped row per line item for future regional benchmarks (see the privacy policy).

Inputs

NameTypeRequiredMeaning
estimate_textstringyesthe estimate; each priced line should look like "Install shingles 20 squares @ $425.00 = $8,500.00"
zipstringyes5-digit US ZIP code
tradestringnoe.g. "roofing"; inferred from the text when omitted
quoted_totalnumbernothe total printed on the estimate; converted to exact decimal on arrival

Output shape (success)

FieldMeaning
parsed_line_itemseach line: description, quantity, unit, unit_price, computed_line_total, stated_line_total, mismatch (true when the printed total ≠ recomputed total)
computed_totalsum of recomputed line totals, decimal string
quoted_totalecho of the quoted total, decimal string (null when not given/found)
total_discrepancycomputed_total − quoted_total, decimal string (null when no quoted total)
per_line_varianceper line: description, quoted_unit_price, benchmark_median, variance_pct, flag (high / low / normal / no_data). Lines with benchmark data also carry sample_size (0 for seed benchmarks) and provenance_ref (index into benchmark_provenance); no_data lines carry neither
benchmark_provenancededuplicated list of the full benchmark provenance strings cited by per_line_variance[].provenance_ref; empty when no line had benchmark data
overall_flagbelow_range / within_range / above_range / insufficient_data
findingsplain-language findings a homeowner can act on
calculation_trailevery arithmetic step with inputs and results, so any number can be checked
coverage_notehow many lines had local benchmark data and what was excluded from the verdict

On bad input the tool returns {"error": "<code>", "reason": "<message>"} instead — never a stack trace. Observed codes: empty_estimate, invalid_zip, no_line_items, invalid_quoted_total, no_coverage, input_too_large.

Worked example

Request (actual):

{
  "estimate_text": "1. Tear off old shingles 20 squares @ $350.00/square = $7,000.00\n2. Install architectural shingles 20 squares @ $425.00/square = $8,500.00\n3. Replace pipe flashing 6 each @ $85.00 = $510.00\n4. Dumpster and disposal 1 lot @ $900.00 = $900.00",
  "zip": "10001",
  "trade": "roofing",
  "quoted_total": 16910.00
}

Response (actual; calculation_trail excerpted — the live response contains one entry per computation step):

{
  "parsed_line_items": [
    {"description": "Tear off old shingles", "quantity": "20", "unit": "squares",
     "unit_price": "350.00", "computed_line_total": "7000.00",
     "stated_line_total": "7000.00", "mismatch": false},
    {"description": "Install architectural shingles", "quantity": "20", "unit": "squares",
     "unit_price": "425.00", "computed_line_total": "8500.00",
     "stated_line_total": "8500.00", "mismatch": false},
    {"description": "Replace pipe flashing", "quantity": "6", "unit": "each",
     "unit_price": "85.00", "computed_line_total": "510.00",
     "stated_line_total": "510.00", "mismatch": false},
    {"description": "Dumpster and disposal", "quantity": "1", "unit": "lot",
     "unit_price": "900.00", "computed_line_total": "900.00",
     "stated_line_total": "900.00", "mismatch": false}
  ],
  "computed_total": "16910.00",
  "quoted_total": "16910.00",
  "total_discrepancy": "0.00",
  "per_line_variance": [
    {"description": "Install architectural shingles", "quoted_unit_price": "425.00",
     "benchmark_median": null, "variance_pct": null, "flag": "no_data"},
    ... (one entry per line; these units have no local benchmark, so all are "no_data")
  ],
  "benchmark_provenance": [],
  "overall_flag": "insufficient_data",
  "findings": [
    "We don't have local benchmark data for 4 of 4 lines (line 1 (\"Tear off old shingles\"), line 2 (\"Install architectural shingles\"), line 3 (\"Replace pipe flashing\"), line 4 (\"Dumpster and disposal\")), so those weren't rated.",
    "We don't have enough local benchmark data to rate this estimate overall."
  ],
  "calculation_trail": [
    {"step": 1, "operation": "line_total",
     "inputs": {"line": 1, "description": "Tear off old shingles", "quantity": "20", "unit_price": "350.00"},
     "result": "7000.00"},
    {"step": 2, "operation": "stated_compare",
     "inputs": {"line": 1, "stated_line_total": "7000.00", "computed_line_total": "7000.00"},
     "result": "false"},
    ... (one entry per arithmetic operation)
  ],
  "coverage_note": "Benchmark coverage: 0 of 4 lines (0% by dollar value) had local data. Lines without data were excluded from the overall rating: line 1 (\"Tear off old shingles\"), line 2 (\"Install architectural shingles\"), line 3 (\"Replace pipe flashing\"), line 4 (\"Dumpster and disposal\")."
}
When units match a benchmark, per-line comparison works. Request {"estimate_text": "1. Install asphalt shingles 2000 sq ft @ $5.50 = $11,000.00", "zip": "80202", "trade": "roofing", "quoted_total": 11000.00} returned (actual, excerpts): per_line_variance: [{"description": "Install asphalt shingles", "quoted_unit_price": "5.50", "benchmark_median": "5.50", "variance_pct": "0.0000", "flag": "normal", "sample_size": 0, "provenance_ref": 0}] — the $5.50/sq ft quote sits exactly on the Denver median. sample_size is 0 because this is a seed benchmark, not observed job data. benchmark_provenance carries the full provenance text (identical to the provenance string in the get_cost_range example above); provenance_ref: 0 points at entry 0 in that list. Lines with no benchmark data get "flag": "no_data" and neither field.

Health endpoint — GET /health

Not an MCP tool: a plain HTTPS endpoint for monitoring. It never throws — a dead database still returns HTTP 200 with "connected": false and a reason. The probe is constant-cost no matter how many observations are stored. Actual response:

{
  "status": "ok",
  "service": "estimateguard-mcp",
  "db": {
    "connected": true,
    "observation_rows": 54,
    "last_write_at": "2026-09-20T01:16:10.451019+00:00",
    "benchmark_rows": 271
  }
}

observation_rows counts stored PII-stripped line items and grows as estimates are evaluated; benchmark_rows is the seed-benchmark count (271 across 12 metros).