Skip to main content
Glama
ccedacero

nyc-property-intel

by ccedacero

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

TableJSON Schema
NameRequiredDescriptionDefault
bblYes
statusNo
categoryNo
since_dateNo
limitNo
include_summaryNo

Output Schema

TableJSON Schema
NameRequiredDescriptionDefault

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]:
  • 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("_", "\\_")
  • 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,
    )
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations, the description provides some behavioral context: it notes historical depth and potential data differences from NYC Open Data. However, it does not disclose other behaviors such as authentication requirements, rate limits, or whether the operation is read-only (though implied). The description adds value but leaves gaps.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is reasonably concise, with a clear front-loaded purpose in the first sentence. The subsequent sentences add context about usage and data depth without excessive verbosity. It earns its place, though the note about historical depth could be slightly tighter.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool has 6 parameters (0% schema coverage) and no annotations, the description should provide comprehensive context. It explains the data source and use case but omits parameter details, making it incomplete for an agent to use correctly without additional inference.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters2/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must compensate. It mentions categories like 'PLUMBING' and 'HEAT/HOT WATER' but does not describe other parameters (bbl, status, since_date, limit, include_summary). The explanation of 'BBL' is absent, and parameter meanings rely on names alone, which is insufficient.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the verb 'Get' and the resource 'HPD tenant complaints and reported problems for a property'. It distinguishes from sibling tools like get_dob_complaints and get_311_complaints by specifying 'HPD' and 'tenant complaints', making the purpose unambiguous.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description suggests using this tool alongside violations to assess building condition, but it does not explicitly state when to use this tool versus alternatives or when not to use it. The guidance is implied rather than explicit, missing a clear decision framework.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ccedacero/nyc-property-intel'

If you have feedback or need assistance with the MCP directory API, please join our Discord server