Skip to main content
Glama
ccedacero

nyc-property-intel

by ccedacero

get_property_issues

Get HPD housing, DOB building code, and ECB/OATH violations for a property. Assess regulatory risk with summary counts and detailed violation history.

Instructions

Get HPD housing violations, DOB building code violations, and ECB/OATH violations for a property.

HPD Class C violations are immediately hazardous. ECB violations include
penalties and balances due. Returns both summary counts and violation
details. Use this to assess a building's regulatory risk profile.

Note on historical depth: our local DB retains all historical HPD
violations and complaints, while NYC's live Socrata API rolls older
records out of its public feed. As a result, the 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
sourceNoALL
statusNo
severityNo
since_dateNo
limitNo
include_summaryNo

Output Schema

TableJSON Schema
NameRequiredDescriptionDefault

No arguments

Implementation Reference

  • The get_property_issues async function is the actual tool handler, decorated with @mcp.tool(). It fetches HPD housing violations, DOB building code violations, and ECB/OATH violations for a property by BBL, with optional filtering by source, status, severity, and date range. Returns summary counts (from materialized view) plus violation details, with a data freshness note.
    @mcp.tool()
    async def get_property_issues(
        bbl: str,
        source: str = "ALL",
        status: str | None = None,
        severity: str | None = None,
        since_date: str | None = None,
        limit: int = 25,
        include_summary: bool = True,
    ) -> dict[str, Any]:
        """Get HPD housing violations, DOB building code violations, and ECB/OATH violations for a property.
    
        HPD Class C violations are immediately hazardous. ECB violations include
        penalties and balances due. Returns both summary counts and violation
        details. Use this to assess a building's regulatory risk profile.
    
        Note on historical depth: our local DB retains all historical HPD
        violations and complaints, while NYC's live Socrata API rolls older
        records out of its public feed. As a result, the 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.
        """
        # ── Validate inputs ──────────────────────────────────────────────
        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.")
    
        source_upper = source.upper()
        if source_upper not in _VALID_SOURCES:
            raise ToolError(
                f"Invalid source: {source!r}. Must be one of: HPD, DOB, ECB, ALL."
            )
    
        # Normalize optional filters — DB stores uppercase values ('OPEN', 'CLOSE', 'A', 'B', 'C').
        # Accepting mixed-case from callers prevents silent empty-result bugs.
        normalized_status = normalize_filter(status)
        normalized_severity = normalize_filter(severity)
    
        # Parse since_date if provided.
        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}. "
                    "Please use ISO 8601 format: YYYY-MM-DD."
                ) from exc
    
        # ── Summary from materialized view ───────────────────────────────
        # Always return a fully-populated summary dict so callers can rely on
        # numeric fields without null-checks. Clean buildings (no row in the
        # materialized view) get zeroed counts.
        summary: dict[str, Any] | None = None
        if include_summary:
            try:
                summary = await fetch_one(_SQL_SUMMARY, bbl)
            except asyncpg.UndefinedTableError:
                logger.info("mv_violation_summary not available, skipping summary")
                summary = None
    
            if summary is None:
                summary = {
                    "bbl": bbl,
                    "hpd_total": 0, "hpd_class_a": 0, "hpd_class_b": 0,
                    "hpd_class_c": 0, "hpd_open": 0, "hpd_most_recent": None,
                    "dob_total": 0, "dob_no_disposition": 0,
                    "dob_has_disposition": 0, "dob_most_recent": None,
                }
    
            # Augment with ECB stats — not in the materialized view.
            if source_upper in ("ECB", "ALL"):
                try:
                    ecb_stats = await fetch_one(_SQL_ECB_SUMMARY, bbl)
                    bal = (ecb_stats or {}).get("ecb_balance_due_total")
                    summary["ecb_total"] = int((ecb_stats or {}).get("ecb_total") or 0)
                    summary["ecb_active"] = int((ecb_stats or {}).get("ecb_active") or 0)
                    summary["ecb_balance_due_total"] = float(bal) if bal is not None else 0.0
                    summary["ecb_most_recent"] = (ecb_stats or {}).get("ecb_most_recent")
                except asyncpg.UndefinedTableError:
                    logger.info("ecb_violations table not loaded, skipping ECB summary")
    
        # ── HPD violations ───────────────────────────────────────────────
        hpd_violations: list[dict[str, Any]] = []
        if source_upper in ("HPD", "ALL"):
            try:
                hpd_violations = await fetch_all(
                    _SQL_HPD,
                    bbl,
                    normalized_severity,  # $2 — class filter (A/B/C), uppercased
                    normalized_status,    # $3 — currentstatus filter, uppercased
                    since,                # $4 — date filter
                    limit,                # $5 — row limit
                )
            except asyncpg.UndefinedTableError:
                logger.info("hpd_violations table not loaded, skipping HPD section")
    
        # ── DOB violations ───────────────────────────────────────────────
        dob_violations: list[dict[str, Any]] = []
        if source_upper in ("DOB", "ALL"):
            try:
                dob_violations = await fetch_all(
                    _SQL_DOB,
                    bbl,
                    since,      # $2 — date filter
                    limit,      # $3 — row limit
                )
            except asyncpg.UndefinedTableError:
                logger.info("dob_violations table not loaded, skipping DOB section")
    
        # ── ECB violations ──────────────────────────────────────────────
        ecb_violations: list[dict[str, Any]] = []
        if source_upper in ("ECB", "ALL"):
            try:
                ecb_violations = await fetch_all(
                    _SQL_ECB,
                    bbl,
                    since,      # $2 — date filter
                    limit,      # $3 — row limit
                )
            except asyncpg.UndefinedTableError:
                logger.info("ecb_violations table not loaded, skipping ECB section")
    
        # ── Cross-validation guard ───────────────────────────────────────
        # Warn if summary says open violations exist but filter returned nothing —
        # catches future regressions where filter normalization silently drops data.
        if (
            summary
            and normalized_status == "OPEN"
            and source_upper in ("HPD", "ALL")
            and (summary.get("hpd_open") or 0) > 0
            and len(hpd_violations) == 0
        ):
            logger.warning(
                "get_property_issues: summary reports %d open HPD violations for BBL %s "
                "but query returned 0 rows (status=%r, severity=%r, since=%r) — "
                "possible filter mismatch or stale materialized view",
                summary["hpd_open"], bbl, normalized_status, normalized_severity, since,
            )
    
        # ── Build response ───────────────────────────────────────────────
        total_returned = len(hpd_violations) + len(dob_violations) + len(ecb_violations)
    
        freshness_parts: list[str] = []
        if source_upper in ("HPD", "ALL"):
            freshness_parts.append(data_freshness_note("hpd_violations"))
        if source_upper in ("DOB", "ALL"):
            freshness_parts.append(data_freshness_note("dob_violations"))
        if source_upper in ("ECB", "ALL"):
            freshness_parts.append(data_freshness_note("ecb_violations"))
    
        return {
            "bbl": bbl,
            "summary": summary,
            "hpd_violations": hpd_violations,
            "dob_violations": dob_violations,
            "ecb_violations": ecb_violations,
            "total_returned": total_returned,
            "data_as_of": " | ".join(freshness_parts),
        }
  • Function signature defines the input schema: bbl (str, required), source (str, default 'ALL'), status (str|None), severity (str|None), since_date (str|None), limit (int, default 25), include_summary (bool, default True). Output is dict[str, Any] with keys: bbl, summary, hpd_violations, dob_violations, ecb_violations, total_returned, data_as_of.
    async def get_property_issues(
        bbl: str,
        source: str = "ALL",
        status: str | None = None,
        severity: str | None = None,
        since_date: str | None = None,
        limit: int = 25,
        include_summary: bool = True,
    ) -> dict[str, Any]:
  • Registration via @mcp.tool() decorator on line 75. The mcp instance is imported from nyc_property_intel.app (line 17). The tool module is imported in server.py line 337, which triggers registration at module load.
    @mcp.tool()
  • data_freshness_note helper used in get_property_issues to annotate returned data with source/cadence info for hpd_violations, dob_violations, ecb_violations tables.
    def data_freshness_note(table_name: str) -> str:
        """Return a human-readable note about the freshness of a data table.
    
        Args:
            table_name: Internal table/dataset name (e.g., "pluto", "rpad").
    
        Returns:
            A string like "Source: NYC DCP PLUTO, updated quarterly."
        """
        info = _DATA_SOURCES.get(table_name.lower())
        if info is None:
            return f"Source: NYC Open Data ({table_name})."
        return f"Source: {info['source']}, {info['cadence']}."
  • normalize_filter helper used by get_property_issues to uppercase user-supplied status/severity filters so they match DB values.
    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()
Behavior4/5

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

With no annotations, the description carries full burden. It includes a detailed note about historical data retention explaining why totals may exceed NYC's public data, which is excellent transparency. However, it does not explicitly state that it is a read-only operation or mention authentication requirements.

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 first paragraph is concise. The second paragraph, while important for transparency, is somewhat lengthy but earned. Overall, minimal redundancy and front-loaded with key purpose.

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

Completeness3/5

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

Despite having an output schema, the description fails to cover parameter semantics, which are important for correct invocation. It provides good behavioral context and purpose, but the parameter gap lowers completeness given the tool's complexity (7 parameters).

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

Parameters1/5

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

Schema description coverage is 0%, meaning the description must compensate. It does not describe any of the 7 parameters (bbl, source, status, etc.) or their meanings, leaving the agent to infer from schema names and types alone.

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 it retrieves HPD, DOB, and ECB/OATH violations for a property, and mentions it returns summary counts and details. This distinguishes it from sibling tools like get_hpd_complaints which focus on single complaint types.

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

Usage Guidelines4/5

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

The description recommends using this to assess a building's regulatory risk profile, providing a clear use case. However, it does not explicitly state when not to use it or mention alternatives like get_dob_complaints for specific violations.

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