nyrezone-mcp 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +19 -0
- package/dist/client.d.ts +48 -0
- package/dist/client.js +152 -0
- package/dist/geocode.d.ts +16 -0
- package/dist/geocode.js +107 -0
- package/dist/lot.d.ts +48 -0
- package/dist/lot.js +129 -0
- package/dist/parse.d.ts +36 -0
- package/dist/parse.js +80 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +100 -0
- package/dist/service.d.ts +44 -0
- package/dist/service.js +200 -0
- package/package.json +30 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 - 2026-10-29 (unpublished)
|
|
4
|
+
|
|
5
|
+
- Remove the prior FAR-times-lot-area-minus-gross-building-area calculation for
|
|
6
|
+
every parcel. Recorded MapPLUTO FAR, area and district facts remain available;
|
|
7
|
+
they do not establish parcel capacity, unused development rights or compliance.
|
|
8
|
+
- Retain deprecated `unused_floor_area.estimate_sqft`, `allowed_floor_area_sqft`,
|
|
9
|
+
`governing_far` and `overbuilt` keys as null, with basis
|
|
10
|
+
`not determined from recorded parcel data` and explicit limitations. The tool
|
|
11
|
+
output schema now declares these keys null; consumers must accept null.
|
|
12
|
+
- Remove calculated capacity and compliance inferences from lot prose, tool
|
|
13
|
+
descriptions, server instructions and package/registry descriptions.
|
|
14
|
+
- Label the public map link `Public lot map`; preserve `full_report_url` as a
|
|
15
|
+
documented legacy field name that does not provide paid report data.
|
|
16
|
+
- Identify district output as scraped NYRezone HTML summaries, without claiming
|
|
17
|
+
direct verification against the NYC Zoning Resolution.
|
|
18
|
+
- Identify release labels and check stamps as site-reported, not independently
|
|
19
|
+
validated for each result; use unknown when the release stamp is missing or
|
|
20
|
+
unavailable. Align package, lockfile, runtime and registry manifest at 0.2.0.
|
|
21
|
+
- Expand existing fixture and real stdio tests for the former 16,094 sq ft example,
|
|
22
|
+
the formerly flagged Empire State Building, false split flags with multiple
|
|
23
|
+
districts, unknown source labels, null compatibility and owner-field exclusion.
|
|
24
|
+
- Update existing exact dependency pins to MCP server/client SDK 2.4.0 and Zod
|
|
25
|
+
4.6.7 to resolve native npm audit advisories affecting the previous pins.
|
|
26
|
+
- Reuse the existing SDK, test suite and documentation; no new service, framework,
|
|
27
|
+
schedule or publishing-trust change.
|
|
28
|
+
|
|
29
|
+
## 2026-10-28 — Documentation accuracy
|
|
30
|
+
|
|
31
|
+
Clarify that the published 0.1.0 stdio package is separate from the hosted ChatGPT
|
|
32
|
+
facts app. Recorded FAR fields, calculated screening outputs and district summaries
|
|
33
|
+
parsed from NYRezone are distinguished from parcel-specific legal capacity. The
|
|
34
|
+
README now documents the separate residential-estimate/highest-FAR flag behavior,
|
|
35
|
+
the legacy `allowed_floor_area_sqft`/`sq ft allowed` wording, split-lot limits and
|
|
36
|
+
the public map destination of the `Full report` link. No runtime, version or
|
|
37
|
+
dependency changed; these disclosures do not certify capacity or all district rules.
|
|
38
|
+
|
|
39
|
+
Verification: 39 existing fixture/stdio tests passed. Published npm JavaScript
|
|
40
|
+
matches the fresh source build. Three public BBL examples matched selected city
|
|
41
|
+
record fields, and the city records' 26v2 version matched the package's methodology
|
|
42
|
+
label on October 28. These observations establish neither legal allowance nor
|
|
43
|
+
exhaustive API/district correctness.
|
|
44
|
+
|
|
45
|
+
## 0.1.1 - planned
|
|
46
|
+
|
|
47
|
+
- Optional packaging: MCPB bundle and Smithery listing (removed from 0.1.0 until they can be built, signed and checksummed for real).
|
|
48
|
+
|
|
49
|
+
## 0.1.0 - 2026-10-15
|
|
50
|
+
|
|
51
|
+
Initial build; version 0.1.0 was subsequently published to npm and the official MCP
|
|
52
|
+
registry on October 16, 2026.
|
|
53
|
+
|
|
54
|
+
- Tools: `lookup_lot`, `get_zoning_district`, `list_zoning_districts`.
|
|
55
|
+
- Lot data from NYRezone `/api/pluto`; owner name/type and assessed values are dropped before any result is returned.
|
|
56
|
+
- Address lookup resolves through NYC Planning Labs Geosearch (NYRezone has no address endpoint) and errors on ambiguous or unknown addresses.
|
|
57
|
+
- District rules parsed from the server-rendered nyrezone.com district pages; parsers fail loudly if the page format changes.
|
|
58
|
+
- Client: descriptive User-Agent, 1 h cache, serialized requests, 20 req/min cap, 10 s timeout, 2 MB cap.
|
|
59
|
+
- Tests: node:test with recorded fixtures, malformed-input cases, and a stdio end-to-end test against a local fixture server.
|
|
60
|
+
- Skipped: borough-level underbuilt summary (no public data endpoint).
|
|
61
|
+
- Review fixes: borough hint read only from the parsed locality and text after the first comma; governing FAR is the highest of residential/commercial/community-facility, and gross building area is no longer presented as evidence of non-compliance; error and empty 200 bodies rejected; district links filtered against the live index; cache bounded at 500 entries; off-origin redirects rejected; publish workflow pins npm and actions by SHA; server.json gets runtimeHint npx.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Giani Brussich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nyrezone-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Read-only [MCP](https://modelcontextprotocol.io) server that gives AI assistants recorded NYC lot fields from public [NYRezone](https://nyrezone.com) pages and APIs: zoning districts, MapPLUTO FAR fields, lot and gross building area, and district summaries as published by NYRezone.
|
|
4
|
+
|
|
5
|
+
The legacy stdio package is separate from NYRezone's hosted ChatGPT facts app.
|
|
6
|
+
Version 0.2.0 removes the previous floor-area calculations. Recorded parcel data
|
|
7
|
+
and scraped district summaries do not determine parcel-specific legal capacity,
|
|
8
|
+
unused development rights or zoning compliance, or certify that every district
|
|
9
|
+
rule is current.
|
|
10
|
+
|
|
11
|
+
It exposes only data that is already public and unauthenticated. No owner names, no assessed values, no paid report data, no account access.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Requires Node 20+.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx -y nyrezone-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Claude Desktop
|
|
22
|
+
|
|
23
|
+
Add to `claude_desktop_config.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"nyrezone": { "command": "npx", "args": ["-y", "nyrezone-mcp"] }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Claude Code
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
claude mcp add nyrezone -- npx -y nyrezone-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Tools
|
|
40
|
+
|
|
41
|
+
All tools are read-only (`readOnlyHint: true`).
|
|
42
|
+
|
|
43
|
+
| Tool | Input | Returns |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `lookup_lot` | `address` (starts with a house number, include the borough) **or** `bbl` (10-digit borough-block-lot) | Recorded zoning district(s), overlays, special districts and FAR fields, lot/gross building area, year built and land use. Deprecated capacity calculation fields are retained as null. Ambiguous or unknown address matches return an error. |
|
|
46
|
+
| `get_zoning_district` | `code`, e.g. `R6A`, `C4-2`, `M1-2/R6A` | FAR, height rules and bonuses as NYRezone publishes them, citywide lot counts, average built FAR. Unknown code returns the list of valid codes. |
|
|
47
|
+
| `list_zoning_districts` | optional `family`, e.g. `R6` | The 51 districts NYRezone documents, each with its district-family description. |
|
|
48
|
+
|
|
49
|
+
Every successful result includes a source line and disclaimer. The MapPLUTO release
|
|
50
|
+
label for lot lookups is reported by NYRezone's methodology page; district lookups
|
|
51
|
+
use the label on the district page. Neither is independently validated for the
|
|
52
|
+
returned parcel or result. A missing or unavailable release stamp is `unknown`. District rules are parsed from NYRezone's pages, not fetched directly
|
|
53
|
+
from the Zoning Resolution. The visible `Public lot map` link and legacy `full_report_url` field both open
|
|
54
|
+
the public lot map; neither returns paid report data.
|
|
55
|
+
|
|
56
|
+
Not included: a borough-level "underbuilt" summary. NYRezone publishes that only as web pages, not as a data endpoint.
|
|
57
|
+
|
|
58
|
+
### Example
|
|
59
|
+
|
|
60
|
+
`lookup_lot` with `bbl: "3052410015"` returns, in part: recorded zoning R7A,
|
|
61
|
+
lot area 4,675 sq ft, gross building area 2,606 sq ft, built FAR 0.56 and a
|
|
62
|
+
MapPLUTO residential FAR field of 4.0. It does not calculate capacity from those
|
|
63
|
+
fields. Its `unused_floor_area` compatibility object includes:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"estimate_sqft": null,
|
|
68
|
+
"allowed_floor_area_sqft": null,
|
|
69
|
+
"governing_far": null,
|
|
70
|
+
"overbuilt": null,
|
|
71
|
+
"basis": "not determined from recorded parcel data"
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The object also includes `caveats` describing the limits below.
|
|
76
|
+
|
|
77
|
+
## Capacity is not determined
|
|
78
|
+
|
|
79
|
+
The previous release multiplied recorded FAR by lot area and subtracted gross
|
|
80
|
+
building area. Version 0.2.0 removes that arithmetic for every lot, including
|
|
81
|
+
split-zoned lots and records with multiple districts but no split-zone flag.
|
|
82
|
+
The four deprecated fields above remain present and always null; their output
|
|
83
|
+
schema accepts null. Consumers that assumed a numeric estimate or boolean flag
|
|
84
|
+
must handle null.
|
|
85
|
+
|
|
86
|
+
Gross building area is not zoning floor area. Recorded FAR fields alone do not
|
|
87
|
+
establish applicable FAR or legally permitted floor area. District boundaries,
|
|
88
|
+
split-zone geometry, applicable uses, special districts, overlays, bonuses,
|
|
89
|
+
zoning-lot history and current rules require separate verification. The tool
|
|
90
|
+
returns the recorded district and FAR facts without deciding capacity, unused
|
|
91
|
+
development rights or compliance.
|
|
92
|
+
|
|
93
|
+
## Data sources and what leaves your machine
|
|
94
|
+
|
|
95
|
+
- **nyrezone.com** (`/api/pluto`, `/zoning`, `/zoning/<code>`, `/methodology`): lot data and district pages. The lot endpoint supplies MapPLUTO fields; HTML district summaries are NYRezone's presentation, not direct Zoning Resolution verification. Release labels and check stamps are reported by the site, not independently validated for each returned parcel.
|
|
96
|
+
- **NYC Planning Labs Geosearch** (`geosearch.planninglabs.nyc`): used only to turn a street address into a BBL, because NYRezone has no address endpoint. This is the one third-party host; the address you ask about is sent to it. BBL lookups and district lookups never contact it.
|
|
97
|
+
|
|
98
|
+
The owner name and owner type that NYRezone's lot API returns are dropped in this server and never appear in a tool result.
|
|
99
|
+
|
|
100
|
+
## Limits and politeness
|
|
101
|
+
|
|
102
|
+
- Descriptive `User-Agent` (`nyrezone-mcp/<version> (+repo URL)`).
|
|
103
|
+
- Requests are serialized, at least 1 second apart, capped at 20 upstream requests per minute, with a 10 second timeout and a 2 MB response cap.
|
|
104
|
+
- Responses are cached in memory for 1 hour (not persisted).
|
|
105
|
+
- Input is validated before any request: addresses 5-150 characters starting with a house number; BBLs must be 10 digits starting 1-5. Everything sent upstream is URL-encoded.
|
|
106
|
+
|
|
107
|
+
## Disclaimer
|
|
108
|
+
|
|
109
|
+
Informational only. Not legal, architectural, or zoning-compliance advice. Confirm with the NYC Department of City Planning or a licensed professional before relying on it. Not affiliated with the City of New York.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm install
|
|
115
|
+
npm test # builds, then runs node:test against recorded fixtures (no live network)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Fixtures in `fixtures/` are recorded public responses; the lot fixtures keep the raw owner fields on purpose so the tests prove they are dropped. Environment variables `NYREZONE_MCP_BASE_URL`, `NYREZONE_MCP_GEOSEARCH_URL` and `NYREZONE_MCP_MIN_INTERVAL_MS` exist for the end-to-end test only.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT. See [LICENSE](LICENSE).
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
3
|
+
import { createServer } from './server.js';
|
|
4
|
+
import { VERSION } from './client.js';
|
|
5
|
+
if (process.argv.includes('--version')) {
|
|
6
|
+
console.log(VERSION);
|
|
7
|
+
}
|
|
8
|
+
else if (process.argv.includes('--help')) {
|
|
9
|
+
console.error('nyrezone-mcp: MCP server (stdio) for public NYC zoning data via nyrezone.com. No flags; configure it in your MCP client.');
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
// Test-only overrides so the end-to-end test can point at a local fixture server.
|
|
13
|
+
const intervalEnv = Number(process.env.NYREZONE_MCP_MIN_INTERVAL_MS);
|
|
14
|
+
serveStdio(() => createServer({
|
|
15
|
+
nyrezoneBase: process.env.NYREZONE_MCP_BASE_URL || undefined,
|
|
16
|
+
geosearchBase: process.env.NYREZONE_MCP_GEOSEARCH_URL || undefined,
|
|
17
|
+
minIntervalMs: Number.isFinite(intervalEnv) && process.env.NYREZONE_MCP_MIN_INTERVAL_MS ? intervalEnv : undefined,
|
|
18
|
+
}));
|
|
19
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare const VERSION = "0.2.0";
|
|
2
|
+
export declare const REPO_URL = "https://github.com/gbrussich52/nyrezone-mcp";
|
|
3
|
+
export type UpstreamKind = 'timeout' | 'http' | 'network' | 'rate_limited' | 'too_large' | 'parse';
|
|
4
|
+
export declare class UpstreamError extends Error {
|
|
5
|
+
readonly kind: UpstreamKind;
|
|
6
|
+
readonly status?: number | undefined;
|
|
7
|
+
constructor(kind: UpstreamKind, message: string, status?: number | undefined);
|
|
8
|
+
}
|
|
9
|
+
export interface ClientOptions {
|
|
10
|
+
fetchImpl?: typeof fetch;
|
|
11
|
+
/** Origin serving /api/pluto and /zoning pages. Overridable for tests only. */
|
|
12
|
+
nyrezoneBase?: string;
|
|
13
|
+
/** Origin serving the NYC Planning Labs Geosearch API. Overridable for tests only. */
|
|
14
|
+
geosearchBase?: string;
|
|
15
|
+
minIntervalMs?: number;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
cacheTtlMs?: number;
|
|
18
|
+
maxRequestsPerMinute?: number;
|
|
19
|
+
maxBytes?: number;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
export interface FetchedText {
|
|
23
|
+
status: number;
|
|
24
|
+
body: string;
|
|
25
|
+
}
|
|
26
|
+
export declare class HttpClient {
|
|
27
|
+
readonly nyrezoneBase: string;
|
|
28
|
+
readonly geosearchBase: string;
|
|
29
|
+
private readonly fetchImpl;
|
|
30
|
+
private readonly minIntervalMs;
|
|
31
|
+
private readonly timeoutMs;
|
|
32
|
+
private readonly cacheTtlMs;
|
|
33
|
+
private readonly maxPerMinute;
|
|
34
|
+
private readonly maxBytes;
|
|
35
|
+
private readonly now;
|
|
36
|
+
private readonly cache;
|
|
37
|
+
private readonly sent;
|
|
38
|
+
private queue;
|
|
39
|
+
private lastSentAt;
|
|
40
|
+
constructor(opts?: ClientOptions);
|
|
41
|
+
/** GET a URL (cached). 404 is returned as a value, not thrown; other non-2xx throw. */
|
|
42
|
+
getText(url: string, accept: string): Promise<FetchedText>;
|
|
43
|
+
private fetchOnce;
|
|
44
|
+
/** Bound memory: past MAX_CACHE entries drop expired ones first, then the oldest. */
|
|
45
|
+
private evict;
|
|
46
|
+
get cacheSize(): number;
|
|
47
|
+
private readCapped;
|
|
48
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// HTTP access for the two public hosts this server reads. Everything that touches
|
|
2
|
+
// the network goes through here so the politeness rules (User-Agent, cache,
|
|
3
|
+
// serialized requests, request cap, timeout, size cap) live in one place.
|
|
4
|
+
export const VERSION = '0.2.0';
|
|
5
|
+
export const REPO_URL = 'https://github.com/gbrussich52/nyrezone-mcp';
|
|
6
|
+
export class UpstreamError extends Error {
|
|
7
|
+
kind;
|
|
8
|
+
status;
|
|
9
|
+
constructor(kind, message, status) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.kind = kind;
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.name = 'UpstreamError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const MAX_CACHE = 500;
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
export class HttpClient {
|
|
19
|
+
nyrezoneBase;
|
|
20
|
+
geosearchBase;
|
|
21
|
+
fetchImpl;
|
|
22
|
+
minIntervalMs;
|
|
23
|
+
timeoutMs;
|
|
24
|
+
cacheTtlMs;
|
|
25
|
+
maxPerMinute;
|
|
26
|
+
maxBytes;
|
|
27
|
+
now;
|
|
28
|
+
cache = new Map();
|
|
29
|
+
sent = [];
|
|
30
|
+
queue = Promise.resolve();
|
|
31
|
+
lastSentAt = 0;
|
|
32
|
+
constructor(opts = {}) {
|
|
33
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
34
|
+
this.nyrezoneBase = (opts.nyrezoneBase ?? 'https://nyrezone.com').replace(/\/+$/, '');
|
|
35
|
+
this.geosearchBase = (opts.geosearchBase ?? 'https://geosearch.planninglabs.nyc').replace(/\/+$/, '');
|
|
36
|
+
this.minIntervalMs = opts.minIntervalMs ?? 1000;
|
|
37
|
+
this.timeoutMs = opts.timeoutMs ?? 10_000;
|
|
38
|
+
this.cacheTtlMs = opts.cacheTtlMs ?? 60 * 60 * 1000;
|
|
39
|
+
this.maxPerMinute = opts.maxRequestsPerMinute ?? 20;
|
|
40
|
+
this.maxBytes = opts.maxBytes ?? 2 * 1024 * 1024;
|
|
41
|
+
this.now = opts.now ?? Date.now;
|
|
42
|
+
}
|
|
43
|
+
/** GET a URL (cached). 404 is returned as a value, not thrown; other non-2xx throw. */
|
|
44
|
+
getText(url, accept) {
|
|
45
|
+
const hit = this.cache.get(url);
|
|
46
|
+
if (hit && this.now() - hit.at < this.cacheTtlMs)
|
|
47
|
+
return Promise.resolve(hit.value);
|
|
48
|
+
// Serialize so concurrent tool calls never burst the upstream hosts.
|
|
49
|
+
const run = this.queue.then(() => this.fetchOnce(url, accept));
|
|
50
|
+
this.queue = run.catch(() => undefined);
|
|
51
|
+
return run;
|
|
52
|
+
}
|
|
53
|
+
async fetchOnce(url, accept) {
|
|
54
|
+
const cached = this.cache.get(url);
|
|
55
|
+
if (cached && this.now() - cached.at < this.cacheTtlMs)
|
|
56
|
+
return cached.value;
|
|
57
|
+
const t = this.now();
|
|
58
|
+
while (this.sent.length && t - this.sent[0] > 60_000)
|
|
59
|
+
this.sent.shift();
|
|
60
|
+
if (this.sent.length >= this.maxPerMinute) {
|
|
61
|
+
throw new UpstreamError('rate_limited', `nyrezone-mcp is limiting itself to ${this.maxPerMinute} upstream requests per minute; try again shortly.`);
|
|
62
|
+
}
|
|
63
|
+
const wait = this.lastSentAt + this.minIntervalMs - t;
|
|
64
|
+
if (wait > 0)
|
|
65
|
+
await sleep(wait);
|
|
66
|
+
this.lastSentAt = this.now();
|
|
67
|
+
this.sent.push(this.lastSentAt);
|
|
68
|
+
const ctrl = new AbortController();
|
|
69
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
70
|
+
try {
|
|
71
|
+
const res = await this.fetchImpl(url, {
|
|
72
|
+
headers: {
|
|
73
|
+
'user-agent': `nyrezone-mcp/${VERSION} (+${REPO_URL}; read-only MCP client)`,
|
|
74
|
+
accept,
|
|
75
|
+
},
|
|
76
|
+
signal: ctrl.signal,
|
|
77
|
+
});
|
|
78
|
+
if (res.status === 429) {
|
|
79
|
+
throw new UpstreamError('rate_limited', 'The upstream service asked us to slow down (HTTP 429); try again in a minute.', 429);
|
|
80
|
+
}
|
|
81
|
+
// A redirect must not carry us (and our User-Agent) off the two hosts we were pointed at.
|
|
82
|
+
if (res.url) {
|
|
83
|
+
let origin = '';
|
|
84
|
+
try {
|
|
85
|
+
origin = new URL(res.url).origin;
|
|
86
|
+
}
|
|
87
|
+
catch { /* handled below */ }
|
|
88
|
+
const allowed = [this.nyrezoneBase, this.geosearchBase].map((b) => new URL(b).origin);
|
|
89
|
+
if (!allowed.includes(origin))
|
|
90
|
+
throw new UpstreamError('http', 'Upstream redirected to an unexpected host; response rejected.');
|
|
91
|
+
}
|
|
92
|
+
const body = await this.readCapped(res);
|
|
93
|
+
if (res.status !== 404 && !res.ok) {
|
|
94
|
+
throw new UpstreamError('http', `Upstream returned HTTP ${res.status}.`, res.status);
|
|
95
|
+
}
|
|
96
|
+
const value = { status: res.status, body };
|
|
97
|
+
this.cache.set(url, { at: this.now(), value });
|
|
98
|
+
this.evict();
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (err instanceof UpstreamError)
|
|
103
|
+
throw err;
|
|
104
|
+
if (ctrl.signal.aborted)
|
|
105
|
+
throw new UpstreamError('timeout', `Upstream did not answer within ${this.timeoutMs / 1000}s.`);
|
|
106
|
+
throw new UpstreamError('network', 'Could not reach the upstream service.');
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Bound memory: past MAX_CACHE entries drop expired ones first, then the oldest. */
|
|
113
|
+
evict() {
|
|
114
|
+
if (this.cache.size <= MAX_CACHE)
|
|
115
|
+
return;
|
|
116
|
+
const t = this.now();
|
|
117
|
+
for (const [k, v] of this.cache)
|
|
118
|
+
if (t - v.at >= this.cacheTtlMs)
|
|
119
|
+
this.cache.delete(k);
|
|
120
|
+
while (this.cache.size > MAX_CACHE) {
|
|
121
|
+
const oldest = this.cache.keys().next().value;
|
|
122
|
+
if (oldest === undefined)
|
|
123
|
+
break;
|
|
124
|
+
this.cache.delete(oldest);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
get cacheSize() {
|
|
128
|
+
return this.cache.size;
|
|
129
|
+
}
|
|
130
|
+
async readCapped(res) {
|
|
131
|
+
const declared = Number(res.headers.get('content-length') ?? '0');
|
|
132
|
+
if (declared > this.maxBytes)
|
|
133
|
+
throw new UpstreamError('too_large', 'Upstream response is larger than the safety cap.');
|
|
134
|
+
if (!res.body)
|
|
135
|
+
return '';
|
|
136
|
+
const reader = res.body.getReader();
|
|
137
|
+
const chunks = [];
|
|
138
|
+
let total = 0;
|
|
139
|
+
for (;;) {
|
|
140
|
+
const { done, value } = await reader.read();
|
|
141
|
+
if (done)
|
|
142
|
+
break;
|
|
143
|
+
total += value.byteLength;
|
|
144
|
+
if (total > this.maxBytes) {
|
|
145
|
+
await reader.cancel();
|
|
146
|
+
throw new UpstreamError('too_large', 'Upstream response is larger than the safety cap.');
|
|
147
|
+
}
|
|
148
|
+
chunks.push(value);
|
|
149
|
+
}
|
|
150
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HttpClient } from './client.js';
|
|
2
|
+
export interface Candidate {
|
|
3
|
+
label: string;
|
|
4
|
+
bbl: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class AmbiguousAddressError extends Error {
|
|
7
|
+
readonly candidates: Candidate[];
|
|
8
|
+
constructor(candidates: Candidate[]);
|
|
9
|
+
}
|
|
10
|
+
export declare class AddressNotFoundError extends Error {
|
|
11
|
+
constructor();
|
|
12
|
+
}
|
|
13
|
+
export declare function normalizeStreet(s: string): string;
|
|
14
|
+
/** Validates raw address input. Everything later is URL-encoded, so this is about clear errors, not safety. */
|
|
15
|
+
export declare function validateAddress(input: string): string;
|
|
16
|
+
export declare function resolveAddress(client: HttpClient, rawAddress: string): Promise<Candidate>;
|
package/dist/geocode.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Address -> BBL via NYC Planning Labs Geosearch (public, no key). NYRezone exposes no
|
|
2
|
+
// address endpoint of its own; its browser search uses this same service. We never guess:
|
|
3
|
+
// anything other than exactly one distinct BBL for the same house number and street is an error.
|
|
4
|
+
import { UpstreamError } from './client.js';
|
|
5
|
+
import { InputError } from './lot.js';
|
|
6
|
+
export class AmbiguousAddressError extends Error {
|
|
7
|
+
candidates;
|
|
8
|
+
constructor(candidates) {
|
|
9
|
+
super('Address matches more than one tax lot.');
|
|
10
|
+
this.candidates = candidates;
|
|
11
|
+
this.name = 'AmbiguousAddressError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class AddressNotFoundError extends Error {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('No tax lot found for that address.');
|
|
17
|
+
this.name = 'AddressNotFoundError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const SUFFIX = {
|
|
21
|
+
AVENUE: 'AVE', AV: 'AVE', STREET: 'ST', BOULEVARD: 'BLVD', ROAD: 'RD', DRIVE: 'DR', PLACE: 'PL', LANE: 'LN',
|
|
22
|
+
COURT: 'CT', PARKWAY: 'PKWY', TERRACE: 'TER', EAST: 'E', WEST: 'W', NORTH: 'N', SOUTH: 'S',
|
|
23
|
+
};
|
|
24
|
+
export function normalizeStreet(s) {
|
|
25
|
+
return s
|
|
26
|
+
.toUpperCase()
|
|
27
|
+
.replace(/[^A-Z0-9 ]+/g, ' ')
|
|
28
|
+
.split(/\s+/)
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((t) => {
|
|
31
|
+
const ord = t.match(/^(\d+)(ST|ND|RD|TH)$/);
|
|
32
|
+
return ord ? ord[1] : (SUFFIX[t] ?? t);
|
|
33
|
+
})
|
|
34
|
+
.join(' ');
|
|
35
|
+
}
|
|
36
|
+
const BOROUGH_WORDS = {
|
|
37
|
+
MANHATTAN: 'Manhattan', BROOKLYN: 'Brooklyn', BRONX: 'Bronx', 'THE BRONX': 'Bronx',
|
|
38
|
+
QUEENS: 'Queens', 'STATEN ISLAND': 'Staten Island',
|
|
39
|
+
};
|
|
40
|
+
const CTRL = /[\u0000-\u001f\u007f]/;
|
|
41
|
+
/** Validates raw address input. Everything later is URL-encoded, so this is about clear errors, not safety. */
|
|
42
|
+
export function validateAddress(input) {
|
|
43
|
+
const a = input.replace(/\s+/g, ' ').trim();
|
|
44
|
+
if (a.length === 0)
|
|
45
|
+
throw new InputError('address is empty. Provide a street address with a house number, e.g. "1187 Ocean Ave, Brooklyn".');
|
|
46
|
+
if (a.length < 5 || a.length > 150)
|
|
47
|
+
throw new InputError('address must be between 5 and 150 characters.');
|
|
48
|
+
if (CTRL.test(a))
|
|
49
|
+
throw new InputError('address contains control characters.');
|
|
50
|
+
if (!/^\d+[A-Za-z]?(-\d+)?\s+\S/.test(a)) {
|
|
51
|
+
throw new InputError('address must start with a house number followed by a street name, e.g. "1187 Ocean Ave, Brooklyn". For a block/lot, pass bbl instead.');
|
|
52
|
+
}
|
|
53
|
+
return a;
|
|
54
|
+
}
|
|
55
|
+
export async function resolveAddress(client, rawAddress) {
|
|
56
|
+
const address = validateAddress(rawAddress);
|
|
57
|
+
const url = `${client.geosearchBase}/v2/search?text=${encodeURIComponent(address)}&size=10`;
|
|
58
|
+
const res = await client.getText(url, 'application/json');
|
|
59
|
+
if (res.status !== 200)
|
|
60
|
+
throw new UpstreamError('http', `Address search returned HTTP ${res.status}.`, res.status);
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = JSON.parse(res.body);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new UpstreamError('parse', 'Address search returned an unreadable response.');
|
|
67
|
+
}
|
|
68
|
+
const features = Array.isArray(data.features) ? data.features : [];
|
|
69
|
+
const parsed = data.geocoding?.query?.parsed_text ?? {};
|
|
70
|
+
const fallback = address.match(/^(\S+)\s+([^,]+)/);
|
|
71
|
+
const house = (parsed.housenumber ?? fallback?.[1] ?? '').toUpperCase();
|
|
72
|
+
const street = normalizeStreet(parsed.street ?? fallback?.[2] ?? '');
|
|
73
|
+
if (!house || !street)
|
|
74
|
+
throw new AddressNotFoundError();
|
|
75
|
+
// Borough hint only from Pelias' parsed locality/admin and the text after the first comma.
|
|
76
|
+
// Scanning the whole string would read "Manhattan" in "100 Manhattan Ave, Brooklyn" as the borough.
|
|
77
|
+
const tail = address.split(',').slice(1).join(' ');
|
|
78
|
+
const hintText = `${parsed.locality ?? ''} ${parsed.admin ?? ''} ${tail}`.toUpperCase();
|
|
79
|
+
let boroughHint = null;
|
|
80
|
+
for (const [word, name] of Object.entries(BOROUGH_WORDS)) {
|
|
81
|
+
if (new RegExp(`(^|[^A-Z])${word}([^A-Z]|$)`).test(hintText)) {
|
|
82
|
+
boroughHint = name;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const byBbl = new Map();
|
|
87
|
+
for (const f of features) {
|
|
88
|
+
const p = f.properties;
|
|
89
|
+
const bbl = p?.addendum?.pad?.bbl;
|
|
90
|
+
if (!p || !bbl || !/^[1-5]\d{9}$/.test(bbl))
|
|
91
|
+
continue;
|
|
92
|
+
if ((p.housenumber ?? '').toUpperCase() !== house)
|
|
93
|
+
continue;
|
|
94
|
+
if (normalizeStreet(p.street ?? '') !== street)
|
|
95
|
+
continue;
|
|
96
|
+
if (boroughHint && p.borough && p.borough !== boroughHint)
|
|
97
|
+
continue;
|
|
98
|
+
if (!byBbl.has(bbl))
|
|
99
|
+
byBbl.set(bbl, { label: p.label ?? `${p.housenumber} ${p.street}`, bbl });
|
|
100
|
+
}
|
|
101
|
+
const found = [...byBbl.values()];
|
|
102
|
+
if (found.length === 0)
|
|
103
|
+
throw new AddressNotFoundError();
|
|
104
|
+
if (found.length > 1)
|
|
105
|
+
throw new AmbiguousAddressError(found.slice(0, 5));
|
|
106
|
+
return found[0];
|
|
107
|
+
}
|
package/dist/lot.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare class InputError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
/** Accepts "3052410015", "3-05241-0015", "3/5241/15" style input and a trailing ".0". */
|
|
5
|
+
export declare function parseBbl(input: string): string;
|
|
6
|
+
export declare const LAND_USE: Record<string, string>;
|
|
7
|
+
export interface LotRecord {
|
|
8
|
+
bbl: string;
|
|
9
|
+
address: string | null;
|
|
10
|
+
borough: string | null;
|
|
11
|
+
zip: string | null;
|
|
12
|
+
community_district: string | null;
|
|
13
|
+
zoning_districts: string[];
|
|
14
|
+
commercial_overlays: string[];
|
|
15
|
+
special_districts: string[];
|
|
16
|
+
split_zone: boolean;
|
|
17
|
+
historic_district: string | null;
|
|
18
|
+
landmark: string | null;
|
|
19
|
+
land_use: {
|
|
20
|
+
code: string;
|
|
21
|
+
label: string;
|
|
22
|
+
} | null;
|
|
23
|
+
building_class: string | null;
|
|
24
|
+
lot_area_sqft: number | null;
|
|
25
|
+
building_area_sqft: number | null;
|
|
26
|
+
residential_units: number | null;
|
|
27
|
+
total_units: number | null;
|
|
28
|
+
num_floors: number | null;
|
|
29
|
+
year_built: number | null;
|
|
30
|
+
built_far: number | null;
|
|
31
|
+
max_far: {
|
|
32
|
+
residential: number | null;
|
|
33
|
+
commercial: number | null;
|
|
34
|
+
community_facility: number | null;
|
|
35
|
+
};
|
|
36
|
+
unused_floor_area: UnusedFloorArea;
|
|
37
|
+
}
|
|
38
|
+
export interface UnusedFloorArea {
|
|
39
|
+
/** Deprecated compatibility fields: recorded parcel data does not determine capacity. */
|
|
40
|
+
estimate_sqft: null;
|
|
41
|
+
overbuilt: null;
|
|
42
|
+
allowed_floor_area_sqft: null;
|
|
43
|
+
governing_far: null;
|
|
44
|
+
basis: string;
|
|
45
|
+
caveats: string[];
|
|
46
|
+
}
|
|
47
|
+
/** Allow-list mapping from the raw API row. Throws InputError-free; callers validate the BBL first. */
|
|
48
|
+
export declare function mapPlutoRow(raw: unknown, bbl: string): LotRecord;
|
package/dist/lot.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// BBL handling and the mapping from NYRezone's raw /api/pluto row to the small,
|
|
2
|
+
// PII-free lot record this server returns. The mapping is an allow-list: any field
|
|
3
|
+
// not named here (owner name/type, assessed values, ...) can never reach a tool result.
|
|
4
|
+
export class InputError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'InputError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Accepts "3052410015", "3-05241-0015", "3/5241/15" style input and a trailing ".0". */
|
|
11
|
+
export function parseBbl(input) {
|
|
12
|
+
const raw = input.trim();
|
|
13
|
+
if (raw.length === 0 || raw.length > 40)
|
|
14
|
+
throw new InputError('bbl must be a 10-digit NYC borough-block-lot number, e.g. 3052410015.');
|
|
15
|
+
const dotted = raw.replace(/\.0+$/, '');
|
|
16
|
+
const parts = dotted.split(/[-/\s]+/);
|
|
17
|
+
let digits;
|
|
18
|
+
if (parts.length === 3 && parts.every((p) => /^\d+$/.test(p))) {
|
|
19
|
+
const [b, block, lot] = parts;
|
|
20
|
+
if (b.length !== 1 || block.length > 5 || lot.length > 4)
|
|
21
|
+
throw new InputError('bbl parts must be borough (1 digit), block (up to 5), lot (up to 4).');
|
|
22
|
+
digits = b + block.padStart(5, '0') + lot.padStart(4, '0');
|
|
23
|
+
}
|
|
24
|
+
else if (/^\d{10}$/.test(dotted)) {
|
|
25
|
+
digits = dotted;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
throw new InputError('bbl must be a 10-digit NYC borough-block-lot number, e.g. 3052410015 (or borough-block-lot like 3-05241-0015).');
|
|
29
|
+
}
|
|
30
|
+
if (!/^[1-5]/.test(digits))
|
|
31
|
+
throw new InputError('bbl must start with a borough digit 1-5 (1 Manhattan, 2 Bronx, 3 Brooklyn, 4 Queens, 5 Staten Island).');
|
|
32
|
+
return digits;
|
|
33
|
+
}
|
|
34
|
+
const BOROUGHS = { MN: 'Manhattan', BX: 'Bronx', BK: 'Brooklyn', QN: 'Queens', SI: 'Staten Island' };
|
|
35
|
+
export const LAND_USE = {
|
|
36
|
+
'01': 'One & Two Family Buildings',
|
|
37
|
+
'02': 'Multi-Family Walk-Up Buildings',
|
|
38
|
+
'03': 'Multi-Family Elevator Buildings',
|
|
39
|
+
'04': 'Mixed Residential & Commercial Buildings',
|
|
40
|
+
'05': 'Commercial & Office Buildings',
|
|
41
|
+
'06': 'Industrial & Manufacturing',
|
|
42
|
+
'07': 'Transportation & Utility',
|
|
43
|
+
'08': 'Public Facilities & Institutions',
|
|
44
|
+
'09': 'Open Space & Outdoor Recreation',
|
|
45
|
+
'10': 'Parking Facilities',
|
|
46
|
+
'11': 'Vacant Land',
|
|
47
|
+
};
|
|
48
|
+
function num(v) {
|
|
49
|
+
if (typeof v === 'number')
|
|
50
|
+
return Number.isFinite(v) ? v : null;
|
|
51
|
+
if (typeof v !== 'string' || v.trim() === '')
|
|
52
|
+
return null;
|
|
53
|
+
const n = Number(v);
|
|
54
|
+
return Number.isFinite(n) ? n : null;
|
|
55
|
+
}
|
|
56
|
+
function str(v) {
|
|
57
|
+
if (typeof v !== 'string')
|
|
58
|
+
return null;
|
|
59
|
+
const s = v.trim();
|
|
60
|
+
return s === '' ? null : s;
|
|
61
|
+
}
|
|
62
|
+
const round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
|
|
63
|
+
function undeterminedFloorArea() {
|
|
64
|
+
return {
|
|
65
|
+
estimate_sqft: null,
|
|
66
|
+
overbuilt: null,
|
|
67
|
+
allowed_floor_area_sqft: null,
|
|
68
|
+
governing_far: null,
|
|
69
|
+
basis: 'not determined from recorded parcel data',
|
|
70
|
+
caveats: [
|
|
71
|
+
'MapPLUTO building area is gross building area, not zoning floor area.',
|
|
72
|
+
'Recorded FAR fields do not establish an applicable FAR or legally permitted floor area for this parcel.',
|
|
73
|
+
'District boundaries and split-zone geometry, applicable uses, special districts, overlays, bonuses, zoning-lot history and current rules require separate verification.',
|
|
74
|
+
'Unused development rights, development capacity and zoning compliance are not determined by this tool.',
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Allow-list mapping from the raw API row. Throws InputError-free; callers validate the BBL first. */
|
|
79
|
+
export function mapPlutoRow(raw, bbl) {
|
|
80
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
81
|
+
throw new InputError('Unexpected lot data format from upstream.');
|
|
82
|
+
const r = raw;
|
|
83
|
+
const zoning = ['zonedist1', 'zonedist2', 'zonedist3', 'zonedist4'].map((k) => str(r[k])).filter((v) => v !== null);
|
|
84
|
+
const overlays = ['overlay1', 'overlay2'].map((k) => str(r[k])).filter((v) => v !== null);
|
|
85
|
+
const special = ['spdist1', 'spdist2', 'spdist3'].map((k) => str(r[k])).filter((v) => v !== null);
|
|
86
|
+
const luRaw = str(r.landuse);
|
|
87
|
+
const luCode = luRaw && /^\d{1,2}$/.test(luRaw) ? luRaw.padStart(2, '0') : null;
|
|
88
|
+
const yearBuilt = num(r.yearbuilt);
|
|
89
|
+
const splitZone = r.splitzone === true || r.splitzone === 'true';
|
|
90
|
+
const lotArea = num(r.lotarea);
|
|
91
|
+
const buildingArea = num(r.bldgarea);
|
|
92
|
+
const far = {
|
|
93
|
+
residential: num(r.residfar),
|
|
94
|
+
commercial: num(r.commfar),
|
|
95
|
+
community_facility: num(r.facilfar),
|
|
96
|
+
};
|
|
97
|
+
const farRounded = {
|
|
98
|
+
residential: far.residential === null ? null : round(far.residential, 2),
|
|
99
|
+
commercial: far.commercial === null ? null : round(far.commercial, 2),
|
|
100
|
+
community_facility: far.community_facility === null ? null : round(far.community_facility, 2),
|
|
101
|
+
};
|
|
102
|
+
const builtFar = num(r.builtfar);
|
|
103
|
+
const boroCode = str(r.borough);
|
|
104
|
+
return {
|
|
105
|
+
bbl,
|
|
106
|
+
address: str(r.address),
|
|
107
|
+
borough: boroCode ? (BOROUGHS[boroCode.toUpperCase()] ?? boroCode) : null,
|
|
108
|
+
zip: str(r.zipcode),
|
|
109
|
+
community_district: str(r.cd),
|
|
110
|
+
zoning_districts: zoning,
|
|
111
|
+
commercial_overlays: overlays,
|
|
112
|
+
special_districts: special,
|
|
113
|
+
split_zone: splitZone,
|
|
114
|
+
historic_district: str(r.histdist),
|
|
115
|
+
landmark: str(r.landmark),
|
|
116
|
+
land_use: luCode ? { code: luCode, label: LAND_USE[luCode] ?? 'Unknown land use code' } : null,
|
|
117
|
+
building_class: str(r.bldgclass),
|
|
118
|
+
lot_area_sqft: lotArea,
|
|
119
|
+
building_area_sqft: buildingArea,
|
|
120
|
+
residential_units: num(r.unitsres),
|
|
121
|
+
total_units: num(r.unitstotal),
|
|
122
|
+
num_floors: num(r.numfloors) === null ? null : round(num(r.numfloors), 1),
|
|
123
|
+
// MapPLUTO uses 0 for "unknown".
|
|
124
|
+
year_built: yearBuilt && yearBuilt > 0 ? yearBuilt : null,
|
|
125
|
+
built_far: builtFar === null ? null : round(builtFar, 2),
|
|
126
|
+
max_far: farRounded,
|
|
127
|
+
unused_floor_area: undeterminedFloorArea(),
|
|
128
|
+
};
|
|
129
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare class ParseError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare function decodeText(html: string): string;
|
|
5
|
+
export interface DistrictIndexEntry {
|
|
6
|
+
code: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
family: string;
|
|
9
|
+
familyLabel: string;
|
|
10
|
+
}
|
|
11
|
+
/** Parses https://nyrezone.com/zoning: family headings, each followed by its district links. */
|
|
12
|
+
export declare function parseZoningIndex(html: string): DistrictIndexEntry[];
|
|
13
|
+
export interface RuleRow {
|
|
14
|
+
rule: string;
|
|
15
|
+
value: string;
|
|
16
|
+
note: string;
|
|
17
|
+
}
|
|
18
|
+
export interface DistrictPage {
|
|
19
|
+
code: string;
|
|
20
|
+
summary: string;
|
|
21
|
+
rules: RuleRow[];
|
|
22
|
+
stats: Record<string, string>;
|
|
23
|
+
boroughs: {
|
|
24
|
+
borough: string;
|
|
25
|
+
parcels: string;
|
|
26
|
+
}[];
|
|
27
|
+
dataVintage: string | null;
|
|
28
|
+
dataCheckedOn: string | null;
|
|
29
|
+
}
|
|
30
|
+
/** Parses https://nyrezone.com/zoning/<slug>. Not every district has a rules table. */
|
|
31
|
+
export declare function parseDistrictPage(html: string): DistrictPage;
|
|
32
|
+
/** Extracts "MapPLUTO 26v2" and the "checked <date>" stamp from a nyrezone page, if present. */
|
|
33
|
+
export declare function parseVintage(html: string): {
|
|
34
|
+
release: string | null;
|
|
35
|
+
checkedOn: string | null;
|
|
36
|
+
};
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Parsers for the server-rendered nyrezone.com pages. They fail loudly (ParseError)
|
|
2
|
+
// when the markup no longer has the expected landmarks, so a site redesign surfaces
|
|
3
|
+
// as a clear error instead of silently wrong zoning numbers.
|
|
4
|
+
export class ParseError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'ParseError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
11
|
+
export function decodeText(html) {
|
|
12
|
+
return html
|
|
13
|
+
.replace(/<!--.*?-->/gs, '')
|
|
14
|
+
.replace(/<[^>]+>/g, '')
|
|
15
|
+
.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
16
|
+
if (e[0] === '#') {
|
|
17
|
+
const code = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
18
|
+
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
|
|
19
|
+
}
|
|
20
|
+
return ENTITIES[e.toLowerCase()] ?? m;
|
|
21
|
+
})
|
|
22
|
+
.replace(/\s+/g, ' ')
|
|
23
|
+
.trim();
|
|
24
|
+
}
|
|
25
|
+
/** Parses https://nyrezone.com/zoning: family headings, each followed by its district links. */
|
|
26
|
+
export function parseZoningIndex(html) {
|
|
27
|
+
const out = [];
|
|
28
|
+
const re = /<h2[^>]*>(.*?)<\/h2>\s*<p[^>]*>(.*?)<\/p>/gs;
|
|
29
|
+
for (const m of html.matchAll(re)) {
|
|
30
|
+
const heading = decodeText(m[1]);
|
|
31
|
+
const parts = heading.split(/\s+[—–-]\s+/);
|
|
32
|
+
if (parts.length < 2)
|
|
33
|
+
continue;
|
|
34
|
+
const links = [...m[2].matchAll(/<a[^>]*href="\/zoning\/([a-z0-9-]+)"[^>]*>(.*?)<\/a>/gs)];
|
|
35
|
+
for (const l of links) {
|
|
36
|
+
out.push({ code: decodeText(l[2]), slug: l[1], family: parts[0], familyLabel: parts.slice(1).join(' - ') });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (out.length === 0)
|
|
40
|
+
throw new ParseError('The NYRezone zoning index no longer has the expected structure.');
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
const STAT_LABELS = ['Parcels citywide', 'High-opportunity lots', 'Avg built FAR', 'Avg max resid. FAR'];
|
|
44
|
+
/** Parses https://nyrezone.com/zoning/<slug>. Not every district has a rules table. */
|
|
45
|
+
export function parseDistrictPage(html) {
|
|
46
|
+
const h1 = html.match(/<h1[^>]*>(.*?)<\/h1>/s);
|
|
47
|
+
if (!h1)
|
|
48
|
+
throw new ParseError('District page has no heading; the page format may have changed.');
|
|
49
|
+
const code = decodeText(h1[1]).replace(/\s+Zoning in NYC\s*$/i, '').trim();
|
|
50
|
+
const after = html.slice(h1.index + h1[0].length);
|
|
51
|
+
const intro = after.match(/<p[^>]*>(.*?)<\/p>/s);
|
|
52
|
+
if (!code || !intro)
|
|
53
|
+
throw new ParseError('District page is missing its summary; the page format may have changed.');
|
|
54
|
+
const rules = [];
|
|
55
|
+
const table = html.match(/<table[\s\S]*?<\/table>/);
|
|
56
|
+
if (table) {
|
|
57
|
+
for (const row of table[0].matchAll(/<tr[^>]*>\s*<th[^>]*scope="row"[^>]*>(.*?)<\/th>\s*<td[^>]*>(.*?)<\/td>\s*<td[^>]*>(.*?)<\/td>\s*<\/tr>/gs)) {
|
|
58
|
+
rules.push({ rule: decodeText(row[1]), value: decodeText(row[2]), note: decodeText(row[3]) });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const stats = {};
|
|
62
|
+
for (const m of html.matchAll(/<div[^>]*>([^<]{1,20})<\/div><div[^>]*>([^<]{1,40})<\/div>/g)) {
|
|
63
|
+
const label = decodeText(m[2]);
|
|
64
|
+
if (STAT_LABELS.includes(label))
|
|
65
|
+
stats[label] = decodeText(m[1]);
|
|
66
|
+
}
|
|
67
|
+
const boroughs = [];
|
|
68
|
+
for (const m of html.matchAll(/<li><a[^>]*href="\/boroughs\/[a-z-]+"[^>]*>(.*?)<\/a>:\s*(?:<!-- -->)?([\d,]+)(?:<!-- -->)?\s*parcels<\/li>/gs)) {
|
|
69
|
+
boroughs.push({ borough: decodeText(m[1]), parcels: m[2] });
|
|
70
|
+
}
|
|
71
|
+
const vintage = parseVintage(html);
|
|
72
|
+
return { code, summary: decodeText(intro[1]), rules, stats, boroughs, dataVintage: vintage.release, dataCheckedOn: vintage.checkedOn };
|
|
73
|
+
}
|
|
74
|
+
/** Extracts "MapPLUTO 26v2" and the "checked <date>" stamp from a nyrezone page, if present. */
|
|
75
|
+
export function parseVintage(html) {
|
|
76
|
+
const text = decodeText(html.replace(/<script[\s\S]*?<\/script>/g, ''));
|
|
77
|
+
const rel = text.match(/MapPLUTO\s+(\d\d[vV]\d)/);
|
|
78
|
+
const checked = text.match(/MapPLUTO\s+\d\d[vV]\d\s*\(checked\s+([^)]{3,30})\)/);
|
|
79
|
+
return { release: rel ? `MapPLUTO ${rel[1]}` : null, checkedOn: checked ? checked[1].trim() : null };
|
|
80
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
3
|
+
import { HttpClient, VERSION } from './client.js';
|
|
4
|
+
import { getDistrict, listDistricts, lookupLot } from './service.js';
|
|
5
|
+
const INSTRUCTIONS = [
|
|
6
|
+
'Read-only access to public NYC zoning data via NYRezone (nyrezone.com), which is built on NYC MapPLUTO.',
|
|
7
|
+
'Tools: lookup_lot (a tax lot by address or 10-digit BBL), get_zoning_district (NYRezone HTML summary for one zoning code), list_zoning_districts.',
|
|
8
|
+
'Report only what the tools return and always keep the source line and disclaimer with it.',
|
|
9
|
+
'Recorded FAR and gross building area do not determine development capacity, unused development rights or zoning compliance. Deprecated unused_floor_area calculation fields are always null.',
|
|
10
|
+
'If a tool reports an ambiguous or unknown input, ask the user to clarify rather than guessing.',
|
|
11
|
+
].join(' ');
|
|
12
|
+
// openWorldHint is true because every call reaches out to public web services.
|
|
13
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
14
|
+
function respond(o) {
|
|
15
|
+
if (!o.ok)
|
|
16
|
+
return { isError: true, content: [{ type: 'text', text: o.error }] };
|
|
17
|
+
return { content: [{ type: 'text', text: o.text }], structuredContent: o.data };
|
|
18
|
+
}
|
|
19
|
+
const lotSchema = z.object({
|
|
20
|
+
bbl: z.string(),
|
|
21
|
+
address: z.string().nullable(),
|
|
22
|
+
borough: z.string().nullable(),
|
|
23
|
+
zip: z.string().nullable(),
|
|
24
|
+
community_district: z.string().nullable(),
|
|
25
|
+
zoning_districts: z.array(z.string()),
|
|
26
|
+
commercial_overlays: z.array(z.string()),
|
|
27
|
+
special_districts: z.array(z.string()),
|
|
28
|
+
split_zone: z.boolean(),
|
|
29
|
+
historic_district: z.string().nullable(),
|
|
30
|
+
landmark: z.string().nullable(),
|
|
31
|
+
land_use: z.object({ code: z.string(), label: z.string() }).nullable(),
|
|
32
|
+
building_class: z.string().nullable(),
|
|
33
|
+
lot_area_sqft: z.number().nullable(),
|
|
34
|
+
building_area_sqft: z.number().nullable(),
|
|
35
|
+
residential_units: z.number().nullable(),
|
|
36
|
+
total_units: z.number().nullable(),
|
|
37
|
+
num_floors: z.number().nullable(),
|
|
38
|
+
year_built: z.number().nullable(),
|
|
39
|
+
built_far: z.number().nullable(),
|
|
40
|
+
max_far: z.object({ residential: z.number().nullable(), commercial: z.number().nullable(), community_facility: z.number().nullable() }),
|
|
41
|
+
unused_floor_area: z.object({
|
|
42
|
+
estimate_sqft: z.null().describe('Deprecated; capacity is not determined from recorded parcel data.'),
|
|
43
|
+
overbuilt: z.null().describe('Deprecated; zoning compliance is not determined from gross building area.'),
|
|
44
|
+
allowed_floor_area_sqft: z.null().describe('Deprecated; legally permitted floor area is not determined.'),
|
|
45
|
+
governing_far: z.null().describe('Deprecated; applicable FAR is not determined.'),
|
|
46
|
+
basis: z.string(),
|
|
47
|
+
caveats: z.array(z.string()),
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
export function createServer(clientOptions = {}) {
|
|
51
|
+
const client = new HttpClient(clientOptions);
|
|
52
|
+
const server = new McpServer({ name: 'nyrezone-mcp', version: VERSION, title: 'NYRezone - NYC zoning lookups' }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
53
|
+
server.registerTool('lookup_lot', {
|
|
54
|
+
title: 'Look up an NYC tax lot',
|
|
55
|
+
description: 'Look up one NYC tax lot by street address OR 10-digit BBL (borough-block-lot). Returns recorded zoning district(s), MapPLUTO FAR fields, lot and gross building area, year built and land use via NYRezone. Does not determine development capacity, unused development rights, applicable FAR or zoning compliance; deprecated calculation fields are null. Provide exactly one of address or bbl. Addresses must start with a house number and should include the borough; an ambiguous or unknown address returns an error instead of a guess. Owner and assessment data are never returned.',
|
|
56
|
+
inputSchema: z.object({
|
|
57
|
+
address: z.string().optional().describe('Street address starting with a house number, e.g. "1187 Ocean Ave, Brooklyn".'),
|
|
58
|
+
bbl: z.string().optional().describe('10-digit BBL, e.g. 3052410015 (1 Manhattan, 2 Bronx, 3 Brooklyn, 4 Queens, 5 Staten Island).'),
|
|
59
|
+
}),
|
|
60
|
+
outputSchema: z.object({
|
|
61
|
+
lot: lotSchema,
|
|
62
|
+
resolved_from: z.enum(['bbl', 'address']),
|
|
63
|
+
matched_address: z.string().nullable(),
|
|
64
|
+
full_report_url: z.string().describe('Legacy field name: public lot map URL; no paid report data'),
|
|
65
|
+
district_pages: z.array(z.string()),
|
|
66
|
+
source: z.string(),
|
|
67
|
+
}),
|
|
68
|
+
annotations: { title: 'Look up an NYC tax lot', ...READ_ONLY },
|
|
69
|
+
}, async (args) => respond(await lookupLot(client, args)));
|
|
70
|
+
server.registerTool('get_zoning_district', {
|
|
71
|
+
title: 'Get an NYC zoning district',
|
|
72
|
+
description: 'Get the HTML district summary NYRezone publishes for one NYC zoning district code (e.g. R6A, C4-2, M1-2/R6A): max FAR, base and maximum height, bonuses, citywide lot counts and average built FAR. Values are scraped NYRezone summaries, not independently verified against the Zoning Resolution or applied to a parcel. An unknown code returns the list of valid codes.',
|
|
73
|
+
inputSchema: z.object({ code: z.string().describe('Zoning district code such as R6A, R7-2, C4-2 or M1-2/R6A (case-insensitive).') }),
|
|
74
|
+
outputSchema: z.object({
|
|
75
|
+
code: z.string(),
|
|
76
|
+
summary: z.string(),
|
|
77
|
+
rules: z.array(z.object({ rule: z.string(), value: z.string(), note: z.string() })),
|
|
78
|
+
rules_note: z.string().nullable(),
|
|
79
|
+
stats: z.record(z.string(), z.string()),
|
|
80
|
+
boroughs: z.array(z.object({ borough: z.string(), parcels: z.string() })),
|
|
81
|
+
dataVintage: z.string().nullable(),
|
|
82
|
+
dataCheckedOn: z.string().nullable(),
|
|
83
|
+
url: z.string(),
|
|
84
|
+
source: z.string(),
|
|
85
|
+
}),
|
|
86
|
+
annotations: { title: 'Get an NYC zoning district', ...READ_ONLY },
|
|
87
|
+
}, async ({ code }) => respond(await getDistrict(client, code)));
|
|
88
|
+
server.registerTool('list_zoning_districts', {
|
|
89
|
+
title: 'List NYC zoning districts',
|
|
90
|
+
description: 'List the NYC zoning district codes NYRezone documents, each with a one-line summary (its district family description). Optionally filter by family, e.g. R6 or C4.',
|
|
91
|
+
inputSchema: z.object({ family: z.string().optional().describe('Optional family filter such as R6, C4, M1.') }),
|
|
92
|
+
outputSchema: z.object({
|
|
93
|
+
count: z.number(),
|
|
94
|
+
districts: z.array(z.object({ code: z.string(), family: z.string(), summary: z.string(), url: z.string() })),
|
|
95
|
+
source: z.string(),
|
|
96
|
+
}),
|
|
97
|
+
annotations: { title: 'List NYC zoning districts', ...READ_ONLY },
|
|
98
|
+
}, async ({ family }) => respond(await listDistricts(client, family)));
|
|
99
|
+
return server;
|
|
100
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HttpClient } from './client.js';
|
|
2
|
+
import { type LotRecord } from './lot.js';
|
|
3
|
+
import { type DistrictIndexEntry, type DistrictPage } from './parse.js';
|
|
4
|
+
export declare const DISCLAIMER = "Informational only; not legal, architectural, or zoning-compliance advice. Confirm with NYC Department of City Planning / a licensed professional before relying on it.";
|
|
5
|
+
export type Outcome<T> = {
|
|
6
|
+
ok: true;
|
|
7
|
+
text: string;
|
|
8
|
+
data: T;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
error: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function loadIndex(client: HttpClient): Promise<DistrictIndexEntry[]>;
|
|
14
|
+
export interface DistrictList {
|
|
15
|
+
count: number;
|
|
16
|
+
districts: {
|
|
17
|
+
code: string;
|
|
18
|
+
family: string;
|
|
19
|
+
summary: string;
|
|
20
|
+
url: string;
|
|
21
|
+
}[];
|
|
22
|
+
source: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function listDistricts(client: HttpClient, family?: string): Promise<Outcome<DistrictList>>;
|
|
25
|
+
export declare function normalizeCodeInput(code: string): string | null;
|
|
26
|
+
export interface DistrictResult extends DistrictPage {
|
|
27
|
+
url: string;
|
|
28
|
+
rules_note: string | null;
|
|
29
|
+
source: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function getDistrict(client: HttpClient, rawCode: string): Promise<Outcome<DistrictResult>>;
|
|
32
|
+
export interface LotResult {
|
|
33
|
+
lot: LotRecord;
|
|
34
|
+
resolved_from: 'bbl' | 'address';
|
|
35
|
+
matched_address: string | null;
|
|
36
|
+
full_report_url: string;
|
|
37
|
+
district_pages: string[];
|
|
38
|
+
source: string;
|
|
39
|
+
}
|
|
40
|
+
export interface LotQuery {
|
|
41
|
+
bbl?: string;
|
|
42
|
+
address?: string;
|
|
43
|
+
}
|
|
44
|
+
export declare function lookupLot(client: HttpClient, q: LotQuery): Promise<Outcome<LotResult>>;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Tool logic, free of MCP types so it can be tested directly with a fixture-backed client.
|
|
2
|
+
// Each function returns { text, data } on success or { error } for a clear user-facing failure.
|
|
3
|
+
import { UpstreamError } from './client.js';
|
|
4
|
+
import { AddressNotFoundError, AmbiguousAddressError, resolveAddress } from './geocode.js';
|
|
5
|
+
import { InputError, mapPlutoRow, parseBbl } from './lot.js';
|
|
6
|
+
import { ParseError, parseDistrictPage, parseVintage, parseZoningIndex } from './parse.js';
|
|
7
|
+
export const DISCLAIMER = 'Informational only; not legal, architectural, or zoning-compliance advice. Confirm with NYC Department of City Planning / a licensed professional before relying on it.';
|
|
8
|
+
function reportedVintage(release, checkedOn) {
|
|
9
|
+
return `MapPLUTO release reported by NYRezone: ${release ?? 'unknown'}${checkedOn ? ` (site check stamp: ${checkedOn})` : ''}; not independently validated for this result`;
|
|
10
|
+
}
|
|
11
|
+
const fail = (error) => ({ ok: false, error });
|
|
12
|
+
function upstreamMessage(err) {
|
|
13
|
+
if (err instanceof UpstreamError) {
|
|
14
|
+
switch (err.kind) {
|
|
15
|
+
case 'timeout': return `The data source timed out. Try again in a moment. (${err.message})`;
|
|
16
|
+
case 'rate_limited': return err.message;
|
|
17
|
+
case 'too_large': return 'The data source returned an unexpectedly large response, so it was rejected.';
|
|
18
|
+
case 'network': return 'Could not reach the data source (nyrezone.com or NYC Geosearch). Check connectivity and try again.';
|
|
19
|
+
case 'parse': return err.message;
|
|
20
|
+
default: return `The data source returned an error. ${err.message}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (err instanceof ParseError)
|
|
24
|
+
return `NYRezone's page format looks different than expected, so nothing was returned rather than guessing. (${err.message})`;
|
|
25
|
+
if (err instanceof InputError)
|
|
26
|
+
return err.message;
|
|
27
|
+
return 'Unexpected error while fetching data.';
|
|
28
|
+
}
|
|
29
|
+
async function vintage(client) {
|
|
30
|
+
try {
|
|
31
|
+
const res = await client.getText(`${client.nyrezoneBase}/methodology`, 'text/html');
|
|
32
|
+
if (res.status === 200) {
|
|
33
|
+
const v = parseVintage(res.body);
|
|
34
|
+
return reportedVintage(v.release, v.checkedOn);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Preserve the lookup, but explicitly report an unknown release when the source is unavailable.
|
|
39
|
+
}
|
|
40
|
+
return reportedVintage(null, null);
|
|
41
|
+
}
|
|
42
|
+
const districtUrl = (client, slug) => `${client.nyrezoneBase}/zoning/${slug}`;
|
|
43
|
+
// ---------- districts ----------
|
|
44
|
+
export async function loadIndex(client) {
|
|
45
|
+
const res = await client.getText(`${client.nyrezoneBase}/zoning`, 'text/html');
|
|
46
|
+
if (res.status !== 200)
|
|
47
|
+
throw new UpstreamError('http', `NYRezone zoning index returned HTTP ${res.status}.`, res.status);
|
|
48
|
+
return parseZoningIndex(res.body);
|
|
49
|
+
}
|
|
50
|
+
export async function listDistricts(client, family) {
|
|
51
|
+
try {
|
|
52
|
+
let wanted = null;
|
|
53
|
+
if (family !== undefined && family.trim() !== '') {
|
|
54
|
+
wanted = family.trim().toUpperCase();
|
|
55
|
+
if (!/^[A-Z][0-9]{0,2}$/.test(wanted))
|
|
56
|
+
return fail('family must be a district family like R6, C4, or M1.');
|
|
57
|
+
}
|
|
58
|
+
const index = await loadIndex(client);
|
|
59
|
+
const rows = index.filter((d) => wanted === null || d.family.toUpperCase() === wanted);
|
|
60
|
+
if (rows.length === 0) {
|
|
61
|
+
const families = [...new Set(index.map((d) => d.family))].join(', ');
|
|
62
|
+
return fail(`No districts in family "${wanted}". Available families: ${families}.`);
|
|
63
|
+
}
|
|
64
|
+
const districts = rows.map((d) => ({ code: d.code, family: d.family, summary: d.familyLabel, url: districtUrl(client, d.slug) }));
|
|
65
|
+
const source = `Source: NYRezone zoning index, ${client.nyrezoneBase}/zoning. ${DISCLAIMER}`;
|
|
66
|
+
const lines = districts.map((d) => `${d.code} - ${d.summary}`);
|
|
67
|
+
const text = `${districts.length} NYC zoning districts documented on NYRezone${wanted ? ` (family ${wanted})` : ''}. Summaries are the district family description; use get_zoning_district for a code's rules.\n\n${lines.join('\n')}\n\n${source}`;
|
|
68
|
+
return { ok: true, text, data: { count: districts.length, districts, source } };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return fail(upstreamMessage(err));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function normalizeCodeInput(code) {
|
|
75
|
+
const c = code.trim().toUpperCase().replace(/\s+/g, '').replace(/\//g, '-');
|
|
76
|
+
return /^[A-Z][A-Z0-9-]{0,14}$/.test(c) ? c : null;
|
|
77
|
+
}
|
|
78
|
+
export async function getDistrict(client, rawCode) {
|
|
79
|
+
try {
|
|
80
|
+
const norm = normalizeCodeInput(rawCode);
|
|
81
|
+
const index = await loadIndex(client);
|
|
82
|
+
const valid = () => index.map((d) => d.code).join(', ');
|
|
83
|
+
if (norm === null)
|
|
84
|
+
return fail(`"${rawCode.slice(0, 40)}" is not a valid district code. Valid codes: ${valid()}.`);
|
|
85
|
+
const entry = index.find((d) => d.slug === norm.toLowerCase());
|
|
86
|
+
if (!entry)
|
|
87
|
+
return fail(`Unknown zoning district "${norm}". NYRezone documents these ${index.length}: ${valid()}.`);
|
|
88
|
+
const url = districtUrl(client, entry.slug);
|
|
89
|
+
const res = await client.getText(url, 'text/html');
|
|
90
|
+
if (res.status === 404)
|
|
91
|
+
return fail(`NYRezone has no page for ${entry.code} right now.`);
|
|
92
|
+
const page = parseDistrictPage(res.body);
|
|
93
|
+
const rulesNote = page.rules.length === 0
|
|
94
|
+
? 'NYRezone publishes parcel statistics but no bulk/height/setback rules table for this district; consult the Zoning Resolution.'
|
|
95
|
+
: null;
|
|
96
|
+
const vint = reportedVintage(page.dataVintage, page.dataCheckedOn);
|
|
97
|
+
const source = `Source: NYRezone HTML district summary, ${url}. ${vint}. Rule values are as published by NYRezone, not independently verified against the NYC Zoning Resolution. ${DISCLAIMER}`;
|
|
98
|
+
const out = [`${page.code} - ${page.summary}`, ''];
|
|
99
|
+
if (page.rules.length) {
|
|
100
|
+
out.push('Rules (as published by NYRezone):');
|
|
101
|
+
for (const r of page.rules)
|
|
102
|
+
out.push(`- ${r.rule}: ${r.value}${r.note ? ` - ${r.note}` : ''}`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
out.push(rulesNote);
|
|
106
|
+
}
|
|
107
|
+
const statKeys = Object.keys(page.stats);
|
|
108
|
+
if (statKeys.length) {
|
|
109
|
+
out.push('', 'Citywide stats: ' + statKeys.map((k) => `${k} ${page.stats[k]}`).join('; '));
|
|
110
|
+
}
|
|
111
|
+
if (page.boroughs.length)
|
|
112
|
+
out.push('Lots by borough: ' + page.boroughs.map((b) => `${b.borough} ${b.parcels}`).join(', '));
|
|
113
|
+
out.push('', source);
|
|
114
|
+
return { ok: true, text: out.join('\n'), data: { ...page, url, rules_note: rulesNote, source } };
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return fail(upstreamMessage(err));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const fmt = (n, unit = '') => (n === null ? 'unknown' : `${n.toLocaleString('en-US')}${unit}`);
|
|
121
|
+
export async function lookupLot(client, q) {
|
|
122
|
+
try {
|
|
123
|
+
const hasBbl = q.bbl !== undefined && q.bbl.trim() !== '';
|
|
124
|
+
const hasAddr = q.address !== undefined && q.address.trim() !== '';
|
|
125
|
+
if (hasBbl === hasAddr)
|
|
126
|
+
return fail('Provide exactly one of bbl (10-digit borough-block-lot) or address (street address with house number).');
|
|
127
|
+
let bbl;
|
|
128
|
+
let matched = null;
|
|
129
|
+
if (hasBbl) {
|
|
130
|
+
bbl = parseBbl(q.bbl);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
try {
|
|
134
|
+
const c = await resolveAddress(client, q.address);
|
|
135
|
+
bbl = c.bbl;
|
|
136
|
+
matched = c.label;
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
if (err instanceof AmbiguousAddressError) {
|
|
140
|
+
const list = err.candidates.map((c) => `${c.label} (BBL ${c.bbl})`).join('; ');
|
|
141
|
+
return fail(`That address matches more than one tax lot, so no guess was made. Candidates: ${list}. Call again with the bbl you want.`);
|
|
142
|
+
}
|
|
143
|
+
if (err instanceof AddressNotFoundError) {
|
|
144
|
+
return fail('No tax lot found for that address. Include the house number and street, and the borough (e.g. "1187 Ocean Ave, Brooklyn"), or pass a bbl.');
|
|
145
|
+
}
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const res = await client.getText(`${client.nyrezoneBase}/api/pluto?bbl=${encodeURIComponent(bbl)}`, 'application/json');
|
|
150
|
+
if (res.status === 404)
|
|
151
|
+
return fail(`No MapPLUTO record found for BBL ${bbl}. Check the borough, block and lot numbers.`);
|
|
152
|
+
let raw;
|
|
153
|
+
try {
|
|
154
|
+
raw = JSON.parse(res.body);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new UpstreamError('parse', 'Lot data came back in an unreadable format.');
|
|
158
|
+
}
|
|
159
|
+
// A 200 with an error body or an empty object must not render as a lot full of "unknown".
|
|
160
|
+
const rr = (typeof raw === 'object' && raw !== null ? raw : {});
|
|
161
|
+
const has = (k) => rr[k] !== undefined && rr[k] !== null && rr[k] !== '';
|
|
162
|
+
if (rr.error !== undefined || (!has('zonedist1') && !has('lotarea'))) {
|
|
163
|
+
return fail(`The data source returned no usable lot data for BBL ${bbl}.`);
|
|
164
|
+
}
|
|
165
|
+
const lot = mapPlutoRow(raw, bbl);
|
|
166
|
+
const vint = await vintage(client);
|
|
167
|
+
const reportUrl = `${client.nyrezoneBase}/map?bbl=${bbl}`;
|
|
168
|
+
// Only link district pages that exist; the index is a nicety, so its failure must not fail the lot.
|
|
169
|
+
let known = null;
|
|
170
|
+
try {
|
|
171
|
+
known = new Set((await loadIndex(client)).map((d) => d.slug));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
known = null;
|
|
175
|
+
}
|
|
176
|
+
const slugs = lot.zoning_districts.map((z) => z.toLowerCase().replace(/\//g, '-')).filter((s) => known === null || known.has(s));
|
|
177
|
+
const pages = slugs.map((s) => districtUrl(client, s));
|
|
178
|
+
const source = `Source: NYRezone /api/pluto parcel records via ${client.nyrezoneBase}. ${vint}. District pages: ${pages.join(', ') || 'n/a'}. Public lot map: ${reportUrl}. ${DISCLAIMER}`;
|
|
179
|
+
const u = lot.unused_floor_area;
|
|
180
|
+
const lines = [
|
|
181
|
+
`BBL ${lot.bbl}${lot.address ? ` - ${lot.address}` : ''}${lot.borough ? `, ${lot.borough}` : ''}${lot.zip ? ` ${lot.zip}` : ''}`,
|
|
182
|
+
matched ? `Matched from your address to: ${matched}` : '',
|
|
183
|
+
`Zoning: ${lot.zoning_districts.join(' + ') || 'unknown'}${lot.split_zone ? ' (split-zoned lot)' : ''}${lot.commercial_overlays.length ? `; overlay ${lot.commercial_overlays.join(', ')}` : ''}${lot.special_districts.length ? `; special district ${lot.special_districts.join(', ')}` : ''}`,
|
|
184
|
+
lot.historic_district || lot.landmark ? `Historic: ${[lot.historic_district, lot.landmark].filter(Boolean).join('; ')}` : '',
|
|
185
|
+
`Land use: ${lot.land_use ? `${lot.land_use.code} ${lot.land_use.label}` : 'unknown'}${lot.building_class ? `; building class ${lot.building_class}` : ''}`,
|
|
186
|
+
`Lot area: ${fmt(lot.lot_area_sqft, ' sq ft')}; building area: ${fmt(lot.building_area_sqft, ' sq ft')}; floors: ${fmt(lot.num_floors)}; year built: ${lot.year_built ?? 'unknown'}; residential units: ${fmt(lot.residential_units)}`,
|
|
187
|
+
`Recorded MapPLUTO FAR - built: ${fmt(lot.built_far)}; residential: ${fmt(lot.max_far.residential)}; commercial: ${fmt(lot.max_far.commercial)}; community facility: ${fmt(lot.max_far.community_facility)}`,
|
|
188
|
+
`Unused floor area / development capacity: ${u.basis}.`,
|
|
189
|
+
`Caveat: ${u.caveats.join(' ')}`,
|
|
190
|
+
'',
|
|
191
|
+
source,
|
|
192
|
+
];
|
|
193
|
+
// Optional lines are empty strings; drop them but keep the blank line before the source line.
|
|
194
|
+
const text = lines.filter((l, i) => l !== '' || i === lines.length - 2).join('\n');
|
|
195
|
+
return { ok: true, text, data: { lot, resolved_from: hasBbl ? 'bbl' : 'address', matched_address: matched, full_report_url: reportUrl, district_pages: pages, source } };
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
return fail(upstreamMessage(err));
|
|
199
|
+
}
|
|
200
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nyrezone-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "NYRezone MCP - recorded NYC parcel and FAR fields plus NYRezone HTML district summaries",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "nyrezone-mcp": "./dist/cli.js" },
|
|
7
|
+
"main": "./dist/server.js",
|
|
8
|
+
"types": "./dist/server.d.ts",
|
|
9
|
+
"files": ["dist", "!dist/test", "README.md", "LICENSE", "CHANGELOG.md"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json",
|
|
12
|
+
"test": "npm run build && node --test dist/test/*.test.js",
|
|
13
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
14
|
+
"prepare": "tsc -p tsconfig.json"
|
|
15
|
+
},
|
|
16
|
+
"engines": { "node": ">=20" },
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/server": "2.4.0",
|
|
19
|
+
"zod": "4.6.7"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@modelcontextprotocol/client": "2.4.0",
|
|
23
|
+
"@types/node": "22.20.3",
|
|
24
|
+
"typescript": "7.0.2"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": { "type": "git", "url": "git+https://github.com/gbrussich52/nyrezone-mcp.git" },
|
|
28
|
+
"homepage": "https://github.com/gbrussich52/nyrezone-mcp#readme",
|
|
29
|
+
"bugs": { "url": "https://github.com/gbrussich52/nyrezone-mcp/issues" },
|
|
30
|
+
"keywords": ["mcp", "model-context-protocol", "zoning", "nyc", "real-estate", "far", "mappluto", "ai-agent", "claude"],
|
|
31
|
+
"mcpName": "io.github.gbrussich52/nyrezone-mcp"
|
|
32
|
+
}
|