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.
https://estimateguard-mcp.onrender.com/mcphttps://estimateguard-mcp.onrender.com/healthAll examples below are real requests and responses captured from the live server.
get_cost_rangeWhat 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.
| Name | Type | Required | Meaning |
|---|---|---|---|
trade | string | yes | e.g. "roofing", "interior painting", "hvac" |
scope | string | yes | short job description, e.g. "per sq ft", "full replacement"; used to pick the right pricing basis |
zip | string | yes | 5-digit US ZIP code, e.g. "80202" |
Either a range (reason is null) or all
nulls with a reason string. Fields:
| Field | Meaning |
|---|---|
low / median / high | price bounds in USD, as decimal strings (null when the source published no bound) |
unit | what the prices are per, e.g. "per square foot" |
sample_size | how many past jobs the range is based on — always 0 for seed benchmarks (see provenance note) |
as_of_date | date the figures were compiled (YYYY-MM-DD) |
provenance | where the figures came from (verbatim below) |
service_type | the specific benchmarked job, e.g. "Cost per Sq Ft (Asphalt)" |
region | the metro the benchmark covers, e.g. "Denver" — city-level data is never presented as ZIP-level data |
labor_rate_low / labor_rate_high | hourly labor-rate bounds when the source published them, else null |
permit_cost_low / permit_cost_high | permit-cost bounds when the source published them, else null |
reason | null when a range is returned; otherwise why no range is returned |
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
}
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."
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.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.
evaluate_estimateWhat 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).
| Name | Type | Required | Meaning |
|---|---|---|---|
estimate_text | string | yes | the estimate; each priced line should look like "Install shingles 20 squares @ $425.00 = $8,500.00" |
zip | string | yes | 5-digit US ZIP code |
trade | string | no | e.g. "roofing"; inferred from the text when omitted |
quoted_total | number | no | the total printed on the estimate; converted to exact decimal on arrival |
| Field | Meaning |
|---|---|
parsed_line_items | each line: description, quantity, unit, unit_price, computed_line_total, stated_line_total, mismatch (true when the printed total ≠ recomputed total) |
computed_total | sum of recomputed line totals, decimal string |
quoted_total | echo of the quoted total, decimal string (null when not given/found) |
total_discrepancy | computed_total − quoted_total, decimal string (null when no quoted total) |
per_line_variance | per 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_provenance | deduplicated list of the full benchmark provenance strings cited by per_line_variance[].provenance_ref; empty when no line had benchmark data |
overall_flag | below_range / within_range / above_range / insufficient_data |
findings | plain-language findings a homeowner can act on |
calculation_trail | every arithmetic step with inputs and results, so any number can be checked |
coverage_note | how 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.
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\")."
}
{"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.GET /healthNot 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).