nyrezone-mcp 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 - planned
4
+
5
+ - Optional packaging: MCPB bundle and Smithery listing (removed from 0.1.0 until they can be built, signed and checksummed for real).
6
+
7
+ ## 0.1.0 - 2026-10-15
8
+
9
+ Initial local build (not published).
10
+
11
+ - Tools: `lookup_lot`, `get_zoning_district`, `list_zoning_districts`.
12
+ - Lot data from NYRezone `/api/pluto`; owner name/type and assessed values are dropped before any result is returned.
13
+ - Address lookup resolves through NYC Planning Labs Geosearch (NYRezone has no address endpoint) and errors on ambiguous or unknown addresses.
14
+ - District rules parsed from the server-rendered nyrezone.com district pages; parsers fail loudly if the page format changes.
15
+ - Client: descriptive User-Agent, 1 h cache, serialized requests, 20 req/min cap, 10 s timeout, 2 MB cap.
16
+ - Tests: node:test with recorded fixtures, malformed-input cases, and a stdio end-to-end test against a local fixture server.
17
+ - Skipped: borough-level underbuilt summary (no public data endpoint).
18
+ - 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,84 @@
1
- # Temporary Holding Version
1
+ # nyrezone-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read-only [MCP](https://modelcontextprotocol.io) server that gives AI assistants NYC zoning facts from the public data on [NYRezone](https://nyrezone.com): which zoning district a tax lot is in, built vs. allowed FAR, lot and building area, an estimated unused floor area, and the rules for each zoning district.
4
+
5
+ It exposes only data that is already public and unauthenticated. No owner names, no assessed values, no paid report data, no account access.
6
+
7
+ ## Install
8
+
9
+ Requires Node 20+.
10
+
11
+ ```bash
12
+ npx -y nyrezone-mcp
13
+ ```
14
+
15
+ ### Claude Desktop
16
+
17
+ Add to `claude_desktop_config.json`:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "nyrezone": { "command": "npx", "args": ["-y", "nyrezone-mcp"] }
23
+ }
24
+ }
25
+ ```
26
+
27
+ ### Claude Code
28
+
29
+ ```bash
30
+ claude mcp add nyrezone -- npx -y nyrezone-mcp
31
+ ```
32
+
33
+ ## Tools
34
+
35
+ All tools are read-only (`readOnlyHint: true`).
36
+
37
+ | Tool | Input | Returns |
38
+ |---|---|---|
39
+ | `lookup_lot` | `address` (starts with a house number, include the borough) **or** `bbl` (10-digit borough-block-lot) | Zoning district(s), overlays and special districts, built FAR vs. maximum FAR, lot area, building area, estimated unused floor area, year built, land use. Ambiguous or unknown input returns an error, never a guess. |
40
+ | `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. |
41
+ | `list_zoning_districts` | optional `family`, e.g. `R6` | The 51 districts NYRezone documents, each with its district-family description. |
42
+
43
+ Every result ends with a source line (MapPLUTO release as stated by NYRezone, the matching nyrezone.com page, and for lots a `Full report` link) and a disclaimer.
44
+
45
+ Not included: a borough-level "underbuilt" summary. NYRezone publishes that only as web pages, not as a data endpoint.
46
+
47
+ ### Example
48
+
49
+ `lookup_lot` with `bbl: "3052410015"` returns, in part: zoning R7A, lot area 4,675 sq ft, building area 2,606 sq ft, built FAR 0.56 against a 4.0 residential maximum, and an estimated 16,094 sq ft unused.
50
+
51
+ ## How the unused-floor-area estimate works
52
+
53
+ `maximum FAR x lot area - existing building area`, using MapPLUTO's residential FAR (commercial or community-facility FAR if there is no residential FAR). It is a screening number only. It does not account for street width, special districts, overlays, split-zoned lots, mapped streets, or City of Yes / Universal Affordability Preference bonuses. If the building already exceeds the maximum, the tool reports 0 and says the lot is over-built.
54
+
55
+ ## Data sources and what leaves your machine
56
+
57
+ - **nyrezone.com** (`/api/pluto`, `/zoning`, `/zoning/<code>`, `/methodology`): lot data and district pages. Data is NYC DCP MapPLUTO; the release is read from NYRezone's own methodology page.
58
+ - **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.
59
+
60
+ The owner name and owner type that NYRezone's lot API returns are dropped in this server and never appear in a tool result.
61
+
62
+ ## Limits and politeness
63
+
64
+ - Descriptive `User-Agent` (`nyrezone-mcp/<version> (+repo URL)`).
65
+ - 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.
66
+ - Responses are cached in memory for 1 hour (not persisted).
67
+ - 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.
68
+
69
+ ## Disclaimer
70
+
71
+ 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.
72
+
73
+ ## Development
74
+
75
+ ```bash
76
+ npm install
77
+ npm test # builds, then runs node:test against recorded fixtures (no live network)
78
+ ```
79
+
80
+ 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.
81
+
82
+ ## License
83
+
84
+ MIT. See [LICENSE](LICENSE).
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }
@@ -0,0 +1,48 @@
1
+ export declare const VERSION = "0.1.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.1.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>;
@@ -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,54 @@
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
+ /** Estimated unused floor area in sq ft, or null when the inputs are missing. */
40
+ estimate_sqft: number | null;
41
+ /** True when existing building area already exceeds the FAR-based maximum. */
42
+ overbuilt: boolean;
43
+ allowed_floor_area_sqft: number | null;
44
+ governing_far: number | null;
45
+ basis: string;
46
+ caveats: string[];
47
+ }
48
+ export declare function computeUnusedFloorArea(lotArea: number | null, buildingArea: number | null, far: {
49
+ residential: number | null;
50
+ commercial: number | null;
51
+ community_facility: number | null;
52
+ }, splitZone: boolean): UnusedFloorArea;
53
+ /** Allow-list mapping from the raw API row. Throws InputError-free; callers validate the BBL first. */
54
+ export declare function mapPlutoRow(raw: unknown, bbl: string): LotRecord;
package/dist/lot.js ADDED
@@ -0,0 +1,165 @@
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
+ export function computeUnusedFloorArea(lotArea, buildingArea, far, splitZone) {
64
+ const caveats = [
65
+ 'Simple MapPLUTO-based estimate: maximum FAR x lot area minus existing building area.',
66
+ 'Building area is MapPLUTO gross area, which is larger than zoning floor area.',
67
+ 'Ignores street width, special districts, overlays, lot splits, mapped streets, and City of Yes / Universal Affordability Preference bonuses, which can change the true maximum.',
68
+ ];
69
+ if (splitZone)
70
+ caveats.push('This lot spans more than one zoning district; MapPLUTO reports a single set of FAR values, so the estimate may be off.');
71
+ // Highest listed FAR governs: a commercial or community-facility FAR can exceed residential
72
+ // (e.g. C5-3 lists residential 10 but commercial 15), and using residential alone would
73
+ // wrongly flag such lots as overbuilt.
74
+ const candidates = [
75
+ ['residential', far.residential ?? 0],
76
+ ['commercial', far.commercial ?? 0],
77
+ ['community-facility', far.community_facility ?? 0],
78
+ ];
79
+ const top = Math.max(...candidates.map(([, v]) => v));
80
+ let governing = null;
81
+ let basis;
82
+ if (top > 0) {
83
+ governing = top;
84
+ const names = candidates.filter(([, v]) => v === top).map(([n]) => n).join(' / ');
85
+ basis = `Highest FAR MapPLUTO lists is ${top} (${names}).`;
86
+ }
87
+ else {
88
+ basis = 'MapPLUTO lists no maximum FAR for this lot, so no estimate is possible.';
89
+ }
90
+ if (governing === null || lotArea === null || lotArea <= 0 || buildingArea === null) {
91
+ if (governing !== null)
92
+ basis += ' Lot area or building area is missing, so no estimate is possible.';
93
+ return { estimate_sqft: null, overbuilt: false, allowed_floor_area_sqft: null, governing_far: governing, basis, caveats };
94
+ }
95
+ // Headline estimate uses residential FAR when listed (matches NYRezone's methodology); the
96
+ // overbuilt flag uses the highest FAR so community-facility or commercial uses never make a
97
+ // lot look non-compliant. Community-facility FAR alone must not inflate a residential lot's headroom.
98
+ const resid = far.residential ?? 0;
99
+ const estFar = resid > 0 ? resid : governing;
100
+ if (resid > 0 && resid < governing) {
101
+ basis = `Estimate uses residential FAR ${resid}; MapPLUTO also lists up to ${governing} for other uses (commercial / community-facility).`;
102
+ }
103
+ const allowed = estFar * lotArea;
104
+ const overbuilt = buildingArea > governing * lotArea;
105
+ return {
106
+ estimate_sqft: Math.max(0, Math.round(allowed - buildingArea)),
107
+ overbuilt,
108
+ allowed_floor_area_sqft: overbuilt ? Math.round(governing * lotArea) : Math.round(allowed),
109
+ governing_far: overbuilt ? governing : estFar,
110
+ basis,
111
+ caveats,
112
+ };
113
+ }
114
+ /** Allow-list mapping from the raw API row. Throws InputError-free; callers validate the BBL first. */
115
+ export function mapPlutoRow(raw, bbl) {
116
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
117
+ throw new InputError('Unexpected lot data format from upstream.');
118
+ const r = raw;
119
+ const zoning = ['zonedist1', 'zonedist2', 'zonedist3', 'zonedist4'].map((k) => str(r[k])).filter((v) => v !== null);
120
+ const overlays = ['overlay1', 'overlay2'].map((k) => str(r[k])).filter((v) => v !== null);
121
+ const special = ['spdist1', 'spdist2', 'spdist3'].map((k) => str(r[k])).filter((v) => v !== null);
122
+ const luRaw = str(r.landuse);
123
+ const luCode = luRaw && /^\d{1,2}$/.test(luRaw) ? luRaw.padStart(2, '0') : null;
124
+ const yearBuilt = num(r.yearbuilt);
125
+ const splitZone = r.splitzone === true || r.splitzone === 'true';
126
+ const lotArea = num(r.lotarea);
127
+ const buildingArea = num(r.bldgarea);
128
+ const far = {
129
+ residential: num(r.residfar),
130
+ commercial: num(r.commfar),
131
+ community_facility: num(r.facilfar),
132
+ };
133
+ const farRounded = {
134
+ residential: far.residential === null ? null : round(far.residential, 2),
135
+ commercial: far.commercial === null ? null : round(far.commercial, 2),
136
+ community_facility: far.community_facility === null ? null : round(far.community_facility, 2),
137
+ };
138
+ const builtFar = num(r.builtfar);
139
+ const boroCode = str(r.borough);
140
+ return {
141
+ bbl,
142
+ address: str(r.address),
143
+ borough: boroCode ? (BOROUGHS[boroCode.toUpperCase()] ?? boroCode) : null,
144
+ zip: str(r.zipcode),
145
+ community_district: str(r.cd),
146
+ zoning_districts: zoning,
147
+ commercial_overlays: overlays,
148
+ special_districts: special,
149
+ split_zone: splitZone,
150
+ historic_district: str(r.histdist),
151
+ landmark: str(r.landmark),
152
+ land_use: luCode ? { code: luCode, label: LAND_USE[luCode] ?? 'Unknown land use code' } : null,
153
+ building_class: str(r.bldgclass),
154
+ lot_area_sqft: lotArea,
155
+ building_area_sqft: buildingArea,
156
+ residential_units: num(r.unitsres),
157
+ total_units: num(r.unitstotal),
158
+ num_floors: num(r.numfloors) === null ? null : round(num(r.numfloors), 1),
159
+ // MapPLUTO uses 0 for "unknown".
160
+ year_built: yearBuilt && yearBuilt > 0 ? yearBuilt : null,
161
+ built_far: builtFar === null ? null : round(builtFar, 2),
162
+ max_far: farRounded,
163
+ unused_floor_area: computeUnusedFloorArea(lotArea, buildingArea, far, splitZone),
164
+ };
165
+ }
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { type ClientOptions } from './client.js';
3
+ export declare function createServer(clientOptions?: ClientOptions): McpServer;
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 (rules 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
+ 'Unused floor area is a simplified FAR-based estimate, not a legal buildable-area determination; say so.',
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.number().nullable(),
43
+ overbuilt: z.boolean(),
44
+ allowed_floor_area_sqft: z.number().nullable(),
45
+ governing_far: z.number().nullable(),
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 zoning district(s), built vs. allowed FAR, lot and building area, an estimated unused floor area, year built and land use, from MapPLUTO via NYRezone. 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(),
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 rules 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. Height/setback detail is only what NYRezone publishes for that district. 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>>;
@@ -0,0 +1,203 @@
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
+ const FALLBACK_VINTAGE = 'NYC MapPLUTO (release as stated at https://nyrezone.com/methodology)';
9
+ const fail = (error) => ({ ok: false, error });
10
+ function upstreamMessage(err) {
11
+ if (err instanceof UpstreamError) {
12
+ switch (err.kind) {
13
+ case 'timeout': return `The data source timed out. Try again in a moment. (${err.message})`;
14
+ case 'rate_limited': return err.message;
15
+ case 'too_large': return 'The data source returned an unexpectedly large response, so it was rejected.';
16
+ case 'network': return 'Could not reach the data source (nyrezone.com or NYC Geosearch). Check connectivity and try again.';
17
+ case 'parse': return err.message;
18
+ default: return `The data source returned an error. ${err.message}`;
19
+ }
20
+ }
21
+ if (err instanceof ParseError)
22
+ return `NYRezone's page format looks different than expected, so nothing was returned rather than guessing. (${err.message})`;
23
+ if (err instanceof InputError)
24
+ return err.message;
25
+ return 'Unexpected error while fetching data.';
26
+ }
27
+ async function vintage(client) {
28
+ try {
29
+ const res = await client.getText(`${client.nyrezoneBase}/methodology`, 'text/html');
30
+ if (res.status === 200) {
31
+ const v = parseVintage(res.body);
32
+ if (v.release)
33
+ return v.checkedOn ? `NYC ${v.release} (checked ${v.checkedOn})` : `NYC ${v.release}`;
34
+ }
35
+ }
36
+ catch {
37
+ // The vintage line is a nicety; never fail a lookup over it.
38
+ }
39
+ return FALLBACK_VINTAGE;
40
+ }
41
+ const districtUrl = (client, slug) => `${client.nyrezoneBase}/zoning/${slug}`;
42
+ // ---------- districts ----------
43
+ export async function loadIndex(client) {
44
+ const res = await client.getText(`${client.nyrezoneBase}/zoning`, 'text/html');
45
+ if (res.status !== 200)
46
+ throw new UpstreamError('http', `NYRezone zoning index returned HTTP ${res.status}.`, res.status);
47
+ return parseZoningIndex(res.body);
48
+ }
49
+ export async function listDistricts(client, family) {
50
+ try {
51
+ let wanted = null;
52
+ if (family !== undefined && family.trim() !== '') {
53
+ wanted = family.trim().toUpperCase();
54
+ if (!/^[A-Z][0-9]{0,2}$/.test(wanted))
55
+ return fail('family must be a district family like R6, C4, or M1.');
56
+ }
57
+ const index = await loadIndex(client);
58
+ const rows = index.filter((d) => wanted === null || d.family.toUpperCase() === wanted);
59
+ if (rows.length === 0) {
60
+ const families = [...new Set(index.map((d) => d.family))].join(', ');
61
+ return fail(`No districts in family "${wanted}". Available families: ${families}.`);
62
+ }
63
+ const districts = rows.map((d) => ({ code: d.code, family: d.family, summary: d.familyLabel, url: districtUrl(client, d.slug) }));
64
+ const source = `Source: NYRezone zoning index, ${client.nyrezoneBase}/zoning. ${DISCLAIMER}`;
65
+ const lines = districts.map((d) => `${d.code} - ${d.summary}`);
66
+ 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}`;
67
+ return { ok: true, text, data: { count: districts.length, districts, source } };
68
+ }
69
+ catch (err) {
70
+ return fail(upstreamMessage(err));
71
+ }
72
+ }
73
+ export function normalizeCodeInput(code) {
74
+ const c = code.trim().toUpperCase().replace(/\s+/g, '').replace(/\//g, '-');
75
+ return /^[A-Z][A-Z0-9-]{0,14}$/.test(c) ? c : null;
76
+ }
77
+ export async function getDistrict(client, rawCode) {
78
+ try {
79
+ const norm = normalizeCodeInput(rawCode);
80
+ const index = await loadIndex(client);
81
+ const valid = () => index.map((d) => d.code).join(', ');
82
+ if (norm === null)
83
+ return fail(`"${rawCode.slice(0, 40)}" is not a valid district code. Valid codes: ${valid()}.`);
84
+ const entry = index.find((d) => d.slug === norm.toLowerCase());
85
+ if (!entry)
86
+ return fail(`Unknown zoning district "${norm}". NYRezone documents these ${index.length}: ${valid()}.`);
87
+ const url = districtUrl(client, entry.slug);
88
+ const res = await client.getText(url, 'text/html');
89
+ if (res.status === 404)
90
+ return fail(`NYRezone has no page for ${entry.code} right now.`);
91
+ const page = parseDistrictPage(res.body);
92
+ const rulesNote = page.rules.length === 0
93
+ ? 'NYRezone publishes parcel statistics but no verified bulk/height/setback rules for this district; consult the Zoning Resolution.'
94
+ : null;
95
+ const vint = page.dataVintage ? `NYC ${page.dataVintage}${page.dataCheckedOn ? ` (checked ${page.dataCheckedOn})` : ''}` : FALLBACK_VINTAGE;
96
+ const source = `Source: ${vint} via NYRezone; rule values from the NYC Zoning Resolution as shown on ${url}. ${DISCLAIMER}`;
97
+ const out = [`${page.code} - ${page.summary}`, ''];
98
+ if (page.rules.length) {
99
+ out.push('Rules (as published by NYRezone):');
100
+ for (const r of page.rules)
101
+ out.push(`- ${r.rule}: ${r.value}${r.note ? ` - ${r.note}` : ''}`);
102
+ }
103
+ else {
104
+ out.push(rulesNote);
105
+ }
106
+ const statKeys = Object.keys(page.stats);
107
+ if (statKeys.length) {
108
+ out.push('', 'Citywide stats: ' + statKeys.map((k) => `${k} ${page.stats[k]}`).join('; '));
109
+ }
110
+ if (page.boroughs.length)
111
+ out.push('Lots by borough: ' + page.boroughs.map((b) => `${b.borough} ${b.parcels}`).join(', '));
112
+ out.push('', source);
113
+ return { ok: true, text: out.join('\n'), data: { ...page, url, rules_note: rulesNote, source } };
114
+ }
115
+ catch (err) {
116
+ return fail(upstreamMessage(err));
117
+ }
118
+ }
119
+ const fmt = (n, unit = '') => (n === null ? 'unknown' : `${n.toLocaleString('en-US')}${unit}`);
120
+ export async function lookupLot(client, q) {
121
+ try {
122
+ const hasBbl = q.bbl !== undefined && q.bbl.trim() !== '';
123
+ const hasAddr = q.address !== undefined && q.address.trim() !== '';
124
+ if (hasBbl === hasAddr)
125
+ return fail('Provide exactly one of bbl (10-digit borough-block-lot) or address (street address with house number).');
126
+ let bbl;
127
+ let matched = null;
128
+ if (hasBbl) {
129
+ bbl = parseBbl(q.bbl);
130
+ }
131
+ else {
132
+ try {
133
+ const c = await resolveAddress(client, q.address);
134
+ bbl = c.bbl;
135
+ matched = c.label;
136
+ }
137
+ catch (err) {
138
+ if (err instanceof AmbiguousAddressError) {
139
+ const list = err.candidates.map((c) => `${c.label} (BBL ${c.bbl})`).join('; ');
140
+ return fail(`That address matches more than one tax lot, so no guess was made. Candidates: ${list}. Call again with the bbl you want.`);
141
+ }
142
+ if (err instanceof AddressNotFoundError) {
143
+ 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.');
144
+ }
145
+ throw err;
146
+ }
147
+ }
148
+ const res = await client.getText(`${client.nyrezoneBase}/api/pluto?bbl=${encodeURIComponent(bbl)}`, 'application/json');
149
+ if (res.status === 404)
150
+ return fail(`No MapPLUTO record found for BBL ${bbl}. Check the borough, block and lot numbers.`);
151
+ let raw;
152
+ try {
153
+ raw = JSON.parse(res.body);
154
+ }
155
+ catch {
156
+ throw new UpstreamError('parse', 'Lot data came back in an unreadable format.');
157
+ }
158
+ // A 200 with an error body or an empty object must not render as a lot full of "unknown".
159
+ const rr = (typeof raw === 'object' && raw !== null ? raw : {});
160
+ const has = (k) => rr[k] !== undefined && rr[k] !== null && rr[k] !== '';
161
+ if (rr.error !== undefined || (!has('zonedist1') && !has('lotarea'))) {
162
+ return fail(`The data source returned no usable lot data for BBL ${bbl}.`);
163
+ }
164
+ const lot = mapPlutoRow(raw, bbl);
165
+ const vint = await vintage(client);
166
+ const reportUrl = `${client.nyrezoneBase}/map?bbl=${bbl}`;
167
+ // Only link district pages that exist; the index is a nicety, so its failure must not fail the lot.
168
+ let known = null;
169
+ try {
170
+ known = new Set((await loadIndex(client)).map((d) => d.slug));
171
+ }
172
+ catch {
173
+ known = null;
174
+ }
175
+ const slugs = lot.zoning_districts.map((z) => z.toLowerCase().replace(/\//g, '-')).filter((s) => known === null || known.has(s));
176
+ const pages = slugs.map((s) => districtUrl(client, s));
177
+ const source = `Source: ${vint} via ${client.nyrezoneBase}. District pages: ${pages.join(', ') || 'n/a'}. Full report: ${reportUrl}. ${DISCLAIMER}`;
178
+ const u = lot.unused_floor_area;
179
+ const lines = [
180
+ `BBL ${lot.bbl}${lot.address ? ` - ${lot.address}` : ''}${lot.borough ? `, ${lot.borough}` : ''}${lot.zip ? ` ${lot.zip}` : ''}`,
181
+ matched ? `Matched from your address to: ${matched}` : '',
182
+ `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(', ')}` : ''}`,
183
+ lot.historic_district || lot.landmark ? `Historic: ${[lot.historic_district, lot.landmark].filter(Boolean).join('; ')}` : '',
184
+ `Land use: ${lot.land_use ? `${lot.land_use.code} ${lot.land_use.label}` : 'unknown'}${lot.building_class ? `; building class ${lot.building_class}` : ''}`,
185
+ `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)}`,
186
+ `FAR - built: ${fmt(lot.built_far)}; max residential: ${fmt(lot.max_far.residential)}; max commercial: ${fmt(lot.max_far.commercial)}; max community facility: ${fmt(lot.max_far.community_facility)}`,
187
+ u.estimate_sqft === null
188
+ ? `Unused floor area: not estimable. ${u.basis}`
189
+ : u.overbuilt
190
+ ? `Unused floor area: about 0 sq ft; existing gross building area (${fmt(lot.building_area_sqft)} sq ft) exceeds the highest FAR MapPLUTO lists (${u.governing_far} x lot area = ${fmt(u.allowed_floor_area_sqft)} sq ft). MapPLUTO building area is gross, not zoning floor area, so this is not evidence of non-compliance. ${u.basis}`
191
+ : `Unused floor area (estimate): about ${fmt(u.estimate_sqft, ' sq ft')} of ${fmt(u.allowed_floor_area_sqft)} sq ft allowed. ${u.basis}`,
192
+ `Caveat: ${u.caveats.join(' ')}`,
193
+ '',
194
+ source,
195
+ ];
196
+ // Optional lines are empty strings; drop them but keep the blank line before the source line.
197
+ const text = lines.filter((l, i) => l !== '' || i === lines.length - 2).join('\n');
198
+ return { ok: true, text, data: { lot, resolved_from: hasBbl ? 'bbl' : 'address', matched_address: matched, full_report_url: reportUrl, district_pages: pages, source } };
199
+ }
200
+ catch (err) {
201
+ return fail(upstreamMessage(err));
202
+ }
203
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "nyrezone-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "NYRezone MCP - NYC zoning districts and tax-lot lookups (zoning, FAR, unused floor area) from public NYRezone / MapPLUTO data",
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.0.0",
19
+ "zod": "4.6.5"
20
+ },
21
+ "devDependencies": {
22
+ "@modelcontextprotocol/client": "2.0.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
+ }