get_hpd_complaints
Retrieve HPD tenant complaints for a property to assess building distress, with categories like plumbing and heat. Data includes older records no longer available on NYC Open Data.
Instructions
Get HPD tenant complaints and reported problems for a property.
Complaints are leading indicators of building distress — they show what
tenants are reporting before formal violations are issued. Categories
include PLUMBING, PAINT/PLASTER, HEAT/HOT WATER, PEST CONTROL, etc.
Use this alongside violations to assess a building's condition.
Note on historical depth: our local DB retains all historical HPD
complaints, while NYC's live Socrata API rolls older records out of
its public feed. As a result, totals reported here may exceed what
data.cityofnewyork.us shows for the same BBL — the extra rows are
real, just no longer surfaced by NYC Open Data.Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| bbl | Yes | ||
| status | No | ||
| category | No | ||
| since_date | No | ||
| limit | No | ||
| include_summary | No |
Output Schema
| Name | Required | Description | Default |
|---|---|---|---|
No arguments | |||
Implementation Reference
- The main handler function for the 'get_hpd_complaints' MCP tool. Validates BBL, parses optional filters (status, category, since_date, limit), queries the hpd_complaints_and_problems table for both a summary (aggregate stats) and detail records, and returns the result as JSON.
@mcp.tool() async def get_hpd_complaints( bbl: str, status: str | None = None, category: str | None = None, since_date: str | None = None, limit: int = 25, include_summary: bool = True, ) -> dict[str, Any]: """Get HPD tenant complaints and reported problems for a property. Complaints are leading indicators of building distress — they show what tenants are reporting before formal violations are issued. Categories include PLUMBING, PAINT/PLASTER, HEAT/HOT WATER, PEST CONTROL, etc. Use this alongside violations to assess a building's condition. Note on historical depth: our local DB retains all historical HPD complaints, while NYC's live Socrata API rolls older records out of its public feed. As a result, totals reported here may exceed what data.cityofnewyork.us shows for the same BBL — the extra rows are real, just no longer surfaced by NYC Open Data. """ try: validate_bbl(bbl) except ValueError as exc: raise ToolError(str(exc)) from exc if limit < 1 or limit > 200: raise ToolError("limit must be between 1 and 200.") since: datetime.date | None = None if since_date is not None: try: since = datetime.date.fromisoformat(since_date) except ValueError as exc: raise ToolError( f"Invalid date format: {since_date!r}. Use YYYY-MM-DD." ) from exc safe_category = escape_like(category) if category else None normalized_status = normalize_filter(status) result: dict[str, Any] = {"bbl": bbl} try: # Summary if include_summary: summary_rows = await fetch_all(_SQL_COMPLAINT_SUMMARY, bbl) if summary_rows: summary = dict(summary_rows[0]) # asyncpg returns jsonb_agg subqueries as a JSON string in some # fetch paths — parse it back to a native list when needed. raw = summary.get("top_categories") if isinstance(raw, str): try: summary["top_categories"] = json.loads(raw) except json.JSONDecodeError: pass result["summary"] = summary else: result["summary"] = None # Detail records complaints = await fetch_all( _SQL_COMPLAINTS, bbl, normalized_status, safe_category, since, limit ) result["complaints"] = complaints result["total_returned"] = len(complaints) result["data_as_of"] = data_freshness_note("hpd_complaints") except asyncpg.UndefinedTableError: result["complaints"] = [] result["total_returned"] = 0 result["note"] = "HPD complaints data not loaded." return result - Input schema/parameters for get_hpd_complaints: bbl (str, required), status (str|None), category (str|None), since_date (str|None), limit (int, default 25), include_summary (bool, default True). Validation enforces BBL format, limit 1-200, and ISO date format.
async def get_hpd_complaints( bbl: str, status: str | None = None, category: str | None = None, since_date: str | None = None, limit: int = 25, include_summary: bool = True, ) -> dict[str, Any]: - src/nyc_property_intel/tools/hpd_complaints.py:55-55 (registration)Tool registration via @mcp.tool() decorator. The FastMCP instance 'mcp' is imported from nyc_property_intel.app (line 18), and the decorator registers get_hpd_complaints as an MCP tool.
@mcp.tool() - validate_bbl (line 88) validates the 10-digit BBL string; data_freshness_note (line 274) returns a human-readable source/cadence string (used for result['data_as_of']); escape_like (line 258) escapes SQL LIKE metacharacters; normalize_filter (line 241) uppercases filter values. Also the _DATA_SOURCES dict (line 210) defines the 'hpd_complaints' entry: 'NYC HPD Complaints and Problems, updated daily'.
def validate_bbl(bbl: str) -> tuple[str, str, str]: """Validate a 10-digit BBL string and split it into components. A BBL (Borough-Block-Lot) is a 10-digit identifier used by NYC: - Digit 1: borough code (1-5) - Digits 2-6: tax block (5 digits, zero-padded) - Digits 7-10: tax lot (4 digits, zero-padded) Args: bbl: A 10-character numeric string, e.g. "1008350001". Returns: Tuple of (borough, block, lot) as strings: ("1", "00835", "0001"). Raises: ValueError: If the BBL is malformed. """ cleaned = bbl.strip().replace("-", "") if not _BBL_PATTERN.match(cleaned): raise ValueError( f"Invalid BBL: {bbl!r}. Must be exactly 10 digits, " f"starting with a borough code 1-5." ) borough = cleaned[0] block = cleaned[1:6] lot = cleaned[6:10] return borough, block, lot def parse_bbl(bbl: str) -> dict[str, str]: """Parse a BBL string into a labeled dictionary. Args: bbl: A 10-digit BBL string. Returns: Dict with keys: borough, block, lot, borough_name, bbl_formatted. Raises: ValueError: If the BBL is malformed. """ borough, block, lot = validate_bbl(bbl) return { "borough": borough, "block": block, "lot": lot, "borough_name": borough_code_to_name(borough), "bbl_formatted": f"{borough}-{block}-{lot}", } # ── Currency formatting ────────────────────────────────────────────── def format_currency(amount: int | float | None) -> str: """Format a numeric amount as a US dollar string. Args: amount: The dollar amount, or None. Returns: Formatted string like "$1,250,000" or "$0" or "N/A". """ if amount is None: return "N/A" if amount < 0: if isinstance(amount, float) and amount != int(amount): return f"-${abs(amount):,.2f}" return f"-${abs(int(amount)):,}" if isinstance(amount, float) and amount != int(amount): return f"${amount:,.2f}" return f"${int(amount):,}" # ── Data freshness ─────────────────────────────────────────────────── # Map table names to their typical data sources and update cadence. _DATA_SOURCES: dict[str, dict[str, str]] = { "pluto": { "source": "NYC DCP PLUTO", "cadence": "updated quarterly", }, "rpad": { "source": "NYC DOF RPAD", "cadence": "updated annually (tentative roll in January, final in May)", }, "acris_legals": { "source": "NYC DOF ACRIS", "cadence": "updated daily with ~2 week recording lag", }, "acris_real_property_parties": { "source": "NYC DOF ACRIS", "cadence": "updated daily with ~2 week recording lag", }, "dob_violations": { "source": "NYC DOB BIS", "cadence": "updated daily", }, "hpd_violations": { "source": "NYC HPD", "cadence": "updated daily", }, "dob_permits": { "source": "NYC DOB NOW / BIS", "cadence": "updated daily", }, "ecb_violations": { "source": "NYC OATH/ECB", "cadence": "updated daily", }, "pad": { "source": "NYC DCP PAD (Property Address Directory)", "cadence": "updated quarterly", }, "rentstab": { "source": "Rent Stabilization Unit Counts (taxbills.nyc)", "cadence": "updated annually", }, "hpd_registrations": { "source": "NYC HPD Building Registrations", "cadence": "updated daily", }, "hpd_complaints": { "source": "NYC HPD Complaints and Problems", "cadence": "updated daily", }, "hpd_litigations": { "source": "NYC HPD Litigations", "cadence": "updated monthly", }, "dof_tax_liens": { "source": "NYC DOF Tax Lien Sale List", "cadence": "updated annually (prior to lien sale)", }, "dof_valuation": { "source": "NYC DOF Property Valuation & Assessments (RPAD)", "cadence": "updated annually (tentative roll in January, final in May)", }, "dof_exemptions": { "source": "NYC DOF Tax Exemptions", "cadence": "updated annually with assessment roll", }, "fdny_fire_incidents": { "source": "FDNY Fire Incident Reporting System (NYC Open Data 8m42-w767)", "cadence": "real-time via Socrata API (data from 2013)", }, "dof_sales": { "source": "NYC DOF Rolling Sales", "cadence": "updated monthly", }, } def normalize_filter(value: str | None) -> str | None: """Normalize an optional filter string to uppercase, treating empty string as None. Used by tools that accept status/class/type filter params so that 'Open', 'open', and 'OPEN' all match the uppercase DB values. Args: value: Raw user-supplied filter string, or None. Returns: Uppercased string, or None if value is None or empty. """ if not value or not value.strip(): return None return value.strip().upper() def escape_like(value: str) -> str: """Escape LIKE/ILIKE metacharacters in a user-supplied string. Replaces backslash, ``%``, and ``_`` with their escaped equivalents so that they are treated as literals rather than wildcards in PostgreSQL LIKE/ILIKE patterns. PostgreSQL uses backslash as the default LIKE escape. Args: value: Raw user-supplied string. Returns: String safe for use as a LIKE pattern fragment. """ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - src/nyc_property_intel/app.py:32-240 (registration)MCP instructions in app.py reference get_hpd_complaints: 'If a user asks about HPD complaints → call get_hpd_complaints.' This is the high-level tool registration/instruction context.
If a user asks about HPD complaints → call get_hpd_complaints. If a user asks about 311 complaints → call get_311_complaints. If a user asks about sales history → call get_property_history. If a user asks about liens → call get_liens_and_encumbrances. If a user gives an address → call lookup_property first to get the BBL. Saying "I'll pull the data" and then NOT calling a tool is strictly forbidden. Every data point in your response must come from a tool result in this turn. ═══════════════════════════════════════════════════════════════════ WORKFLOW — How to Use These Tools ═══════════════════════════════════════════════════════════════════ 1. **Start with `lookup_property`** Every query begins here. The user gives you an address or BBL (Borough-Block-Lot). This tool returns the canonical BBL, owner name, zoning, lot dimensions, and tax class. You MUST have a valid BBL before calling any other tool. 2. **Expand with detail tools** (use as many as relevant) - `get_property_issues` — HPD + DOB violations, open vs. closed, severity, penalty amounts - `get_property_history` — DOF sales history, ACRIS deed transfers, ownership changes - `search_comps` — comparable sales in the same zip code, neighborhood stats 3. **Get the full picture — always use ALL of these together:** When a user asks for a full due diligence report, run ALL of the following in sequence (analyze_property first, then the three supplemental tools): - `analyze_property` — runs 14 sub-queries concurrently (violations, sales, ownership, tax, mortgages, rent stabilization, permits, 311, evictions, comps) - `get_nypd_crime` — crime data is NOT included in analyze_property - `get_fdny_fire_incidents` — fire history is NOT included in analyze_property - `get_dob_complaints` — DOB complaint pre-violation signals, also NOT included NEVER call analyze_property alone for a "full report" — always follow with the three supplemental tools above. The user asked for everything; give them everything. ═══════════════════════════════════════════════════════════════════ FULL DUE DILIGENCE REPORT — REQUIRED FORMAT ═══════════════════════════════════════════════════════════════════ When generating a full due diligence report, ALWAYS use this exact markdown structure. Every section must appear in this order, even if the data is empty (write "No data on record" rather than skipping a section). Consistency lets users compare reports across properties. ``` # Due Diligence Report: [Full Address], [Borough] *BBL: [X-XXXXX-XXXX] · Generated [Month DD, YYYY] · Source: NYC Public Records* --- ## 🏢 Property Profile | Field | Value | |-------|-------| | Owner | ... | | Building Class | ... | | Zoning | ... | | Year Built | ... | | Floors / Units | ... | | Lot Area / Bldg Area | ... | | Landmark / Historic | ... | ## 💰 Financial Snapshot | Field | Value | |-------|-------| | Assessed Value (Land) | ... | | Assessed Value (Total) | ... | | Tax Class | ... | | Active Exemptions | ... | | FAR Built / Allowed | ... / ... (X% utilized) | ## ⚠️ Violations & Compliance **HPD Housing Violations:** X total (X open) — Class A: X · Class B: X · Class C: X **DOB Building Violations:** X total **ECB/OATH Penalties:** $X outstanding [Table of open Class C and Class B violations if any] ## 🔑 Ownership & Debt **Current Owner:** [name from ACRIS/PLUTO] **Last Sale:** [date] at [price] **Recorded Mortgages:** X active ($X total) **Tax Liens:** [Yes/No — detail if yes] ## 🏠 Rental Status **Rent-Stabilized Units:** [count] (as of 2017) **Trend:** [declining/stable/increasing] — [2007 count] → [2017 count] ## 📋 Complaints & Tenant Issues **HPD Complaints:** X total (X open) — most recent: [date] **311 Service Requests:** X total (X open) — most recent: [date] **DOB Complaints:** X total — most recent: [date] ## ⚖️ Legal Actions **HPD Litigations:** X cases (X open) — harassment findings: [Yes/No] ## 🏗️ Permits & Development **DOB Filings:** X total — [X new buildings / X alterations / X demolitions] **FAR Analysis:** [X of Y available, Z% used — development upside: High/Medium/Low/None] ## 📈 Market & Comparables **Recent Sales (this property):** [date] at [price] **Comparable Sales (last 12 months, same zip):** [X sales, median $X/SF] ## 🚨 Neighborhood Risk **NYPD Crime (300m radius, last 12 months):** X complaints — X felonies / X misdemeanors **FDNY Fire Incidents (zip area, last 3 years):** X incidents **Evictions:** X total (X residential / X commercial) ## 🚩 Red Flags & Key Observations [Bullet list of anything material — open Class C violations, tax liens, HPD litigation, stop-work orders, FAR maxed out, unusual ownership structure, etc.] [Write "No material red flags identified." if clean] --- *Data sourced from NYC public records (HPD, DOB, DOF, ACRIS, NYPD, FDNY, 311). Not legal, tax, or investment advice. Verify independently before financial decisions.* ``` ═══════════════════════════════════════════════════════════════════ DATA PRESENTATION RULES ═══════════════════════════════════════════════════════════════════ - **Always cite the data source** (e.g., "NYC DOF RPAD, as of Jan 2025"). - **Always include a `data_as_of` date** so users know how fresh the data is. - **Format currency** with dollar signs and commas ($1,250,000). - **Format BBLs** as Borough-Block-Lot (e.g., 1-00835-0001 for Manhattan). - **Use tables** for multi-row data (violations, sales comps, permits). - **Flag anomalies** — e.g., a sale at $0 is likely an LLC transfer, not a market sale. - When data is missing or a tool returns no results, say so clearly. Do NOT hallucinate property details. ═══════════════════════════════════════════════════════════════════ FAIR HOUSING & LEGAL GUARDRAILS (NON-NEGOTIABLE) ═══════════════════════════════════════════════════════════════════ You MUST refuse any request that: - Asks about the **demographics, race, ethnicity, religion, or national origin** of a neighborhood's residents or a building's tenants. - Asks you to **screen tenants** or assess whether a person would be a "good" or "bad" tenant based on any characteristic. - Asks for **redlining-style analysis** (e.g., "which neighborhoods are gentrifying?" framed around demographic change). - Requests **income profiling** of residents or speculation about the socioeconomic status of occupants. If a query touches these topics, respond: "I provide property and building data from public City records. I'm not \ able to provide demographic information, tenant screening, or analysis \ based on the characteristics of residents, as this could facilitate \ housing discrimination prohibited under the Fair Housing Act." ═══════════════════════════════════════════════════════════════════ DATA LIMITATIONS ═══════════════════════════════════════════════════════════════════ - **Title search**: This tool does NOT perform a title search. ACRIS data shows recorded documents (deeds, mortgages, liens) but is NOT a substitute for a proper title search. Always recommend a title company for transaction closings. - **Not yet loaded**: DOB permits (DOB NOW/BIS jobs) and ACRIS document data (Phase C) may not be available yet. If a tool returns empty results, note which data sources are missing. - **Condo BBLs**: Condo unit BBLs (lot >= 7501) may not appear in DOF sales data because sales are recorded against the unit lot, not the building lot. - **Staleness**: PLUTO is updated quarterly. DOF sales are ~2 months behind. HPD/DOB violations are near real-time. ═══════════════════════════════════════════════════════════════════ DISCLAIMERS ═══════════════════════════════════════════════════════════════════ When presenting property data, always include this footer: "This information is sourced from NYC public records and is provided \ for informational purposes only. It does not constitute legal, tax, \ or investment advice. Verify all data independently before making \ financial decisions. Data may not reflect the most recent filings \ or recordings." ═══════════════════════════════════════════════════════════════════ SCOPE — STAY ON TOPIC ═══════════════════════════════════════════════════════════════════ You ONLY answer questions about NYC properties, buildings, real estate transactions, and NYC public record data. If a user asks about anything unrelated to NYC real estate due diligence — weather, general knowledge, coding, personal advice, etc. — decline politely and redirect: "I'm specialized for NYC property due diligence. Try asking me about a specific address or BBL — I can pull violations, sales history, ownership records, liens, permits, and more from official city databases." Do NOT answer the off-topic question first and then redirect. Simply decline. ═══════════════════════════════════════════════════════════════════ TONE & EXPERTISE ═══════════════════════════════════════════════════════════════════ - Be concise and professional — your users are busy deal-makers, not tourists. - Lead with the most important numbers (price, assessed value, violations count). - When you spot a red flag (open violations, lien on title, FAR already maxed), call it out proactively with context on why it matters for a deal. - Use NYC real estate terminology naturally: "C of O", "TCO", "as-of-right", "air rights", "FAR", "bulk", "tax lot", "condo lot", "ACRIS", "DOF". """ mcp = FastMCP( "NYC Property Intel", instructions=MCP_INSTRUCTIONS, )