@matchuplabs/nyc-api-mcp 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.
@@ -0,0 +1,190 @@
1
+ import { NycApiClient } from "../lib/nyc-api-client.js";
2
+ import { mcpError, mapApiErrorToMcp, ErrorCodes } from "../lib/errors.js";
3
+ export const TOOL_NAME = "get_restaurant_venue_intel";
4
+ export const TOOL_DEFINITION = {
5
+ name: TOOL_NAME,
6
+ description: "Get health inspection and compliance data for a NYC restaurant or venue. Returns current health grade, inspection score, and last inspection date. Optionally include sidewalk cafe permits and business license detail. Costs 1 credit. For name-only searches, provide borough to improve match accuracy.",
7
+ inputSchema: {
8
+ type: "object",
9
+ properties: {
10
+ name: {
11
+ type: "string",
12
+ description: "Restaurant or venue name",
13
+ },
14
+ address: {
15
+ type: "string",
16
+ description: "Street address of the restaurant or venue",
17
+ },
18
+ borough: {
19
+ type: "string",
20
+ description: "Borough name to improve match accuracy",
21
+ },
22
+ include_liquor_license: {
23
+ type: "boolean",
24
+ description: "Include liquor/business license detail (default false)",
25
+ },
26
+ include_sidewalk_cafe: {
27
+ type: "boolean",
28
+ description: "Include sidewalk cafe permit detail (default false)",
29
+ },
30
+ },
31
+ },
32
+ };
33
+ function isApiError(result) {
34
+ return (typeof result === "object" &&
35
+ result !== null &&
36
+ "error" in result &&
37
+ typeof result.error === "object");
38
+ }
39
+ function unwrapData(raw) {
40
+ if (!raw || typeof raw !== "object")
41
+ return null;
42
+ const rec = raw;
43
+ // Direct record with name/camis
44
+ if (rec.camis || rec.dba || rec.restaurant_name)
45
+ return rec;
46
+ // { data: { ... } } wrapper
47
+ if (rec.data && typeof rec.data === "object" && !Array.isArray(rec.data)) {
48
+ return rec.data;
49
+ }
50
+ // { data: [ ... ] } wrapper — take first element
51
+ if (Array.isArray(rec.data) && rec.data.length > 0) {
52
+ return rec.data[0];
53
+ }
54
+ // Array at top level — take first element
55
+ if (Array.isArray(rec) && rec.length > 0) {
56
+ return rec[0];
57
+ }
58
+ return null;
59
+ }
60
+ function str(val) {
61
+ if (val == null)
62
+ return "";
63
+ if (typeof val === "object" && !Array.isArray(val)) {
64
+ // Handle nested objects like address: { full, borough, zipcode }
65
+ const obj = val;
66
+ return String(obj.full || obj.address || obj.name || JSON.stringify(val));
67
+ }
68
+ return String(val);
69
+ }
70
+ function num(val) {
71
+ if (val == null)
72
+ return null;
73
+ const n = Number(val);
74
+ return Number.isNaN(n) ? null : n;
75
+ }
76
+ function computeConfidence(input) {
77
+ if (input.address && input.name)
78
+ return "high";
79
+ if (input.address)
80
+ return "medium";
81
+ if (input.name && input.borough)
82
+ return "medium";
83
+ if (input.name)
84
+ return "low";
85
+ return "low";
86
+ }
87
+ function buildSourceCoverage(data, includePermits, includeLicenses) {
88
+ return {
89
+ health_inspection: Boolean(data.grade || data.score != null || data.inspection_date),
90
+ inspection_history: Boolean(data.total_inspections != null || data.inspections),
91
+ sidewalk_cafe_permits: includePermits,
92
+ business_licenses: includeLicenses,
93
+ };
94
+ }
95
+ export async function getRestaurantVenueIntel(args) {
96
+ const name = args.name?.trim() || undefined;
97
+ const address = args.address?.trim() || undefined;
98
+ const borough = args.borough?.trim() || undefined;
99
+ const includeSidewalkCafe = args.include_sidewalk_cafe ?? false;
100
+ const includeLiquorLicense = args.include_liquor_license ?? false;
101
+ if (!name && !address) {
102
+ return JSON.stringify(mcpError(ErrorCodes.MISSING_REQUIRED_IDENTIFIER, "At least name or address is required."));
103
+ }
104
+ const client = new NycApiClient();
105
+ // Fetch health data
106
+ const healthParams = {};
107
+ if (name)
108
+ healthParams.name = name;
109
+ if (address)
110
+ healthParams.address = address;
111
+ if (borough)
112
+ healthParams.borough = borough;
113
+ let healthRaw;
114
+ try {
115
+ healthRaw = await client.venueHealth(healthParams);
116
+ }
117
+ catch (err) {
118
+ console.error("get_restaurant_venue_intel error:", err);
119
+ return JSON.stringify(mcpError(ErrorCodes.UPSTREAM_TIMEOUT, `Unexpected error: ${err instanceof Error ? err.message : String(err)}`));
120
+ }
121
+ if (isApiError(healthRaw)) {
122
+ return JSON.stringify(mapApiErrorToMcp(healthRaw));
123
+ }
124
+ const data = unwrapData(healthRaw);
125
+ if (!data) {
126
+ const notFound = mcpError(ErrorCodes.NOT_FOUND, `No venue data returned for ${name ? `"${name}"` : `address "${address}"`}.`);
127
+ return JSON.stringify(notFound);
128
+ }
129
+ // Optionally fetch permits (sidewalk cafe + business licenses)
130
+ let permitsData = null;
131
+ if (includeSidewalkCafe || includeLiquorLicense) {
132
+ const permitsParams = {};
133
+ if (name)
134
+ permitsParams.name = name;
135
+ if (address)
136
+ permitsParams.address = address;
137
+ try {
138
+ const permitsRaw = await client.venuePermits(permitsParams);
139
+ if (!isApiError(permitsRaw)) {
140
+ permitsData = unwrapData(permitsRaw);
141
+ }
142
+ }
143
+ catch (err) {
144
+ console.error("get_restaurant_venue_intel permits sub-call error:", err);
145
+ // Non-fatal: continue without permits data
146
+ }
147
+ }
148
+ const sourceCoverage = buildSourceCoverage(data, includeSidewalkCafe, includeLiquorLicense);
149
+ // Extract address and borough from potentially nested address object
150
+ let venueAddress = "";
151
+ let venueBoro = "";
152
+ const addrField = data.address;
153
+ if (addrField && typeof addrField === "object" && !Array.isArray(addrField)) {
154
+ const addrObj = addrField;
155
+ venueAddress = String(addrObj.full || addrObj.address || addrObj.building || "");
156
+ venueBoro = String(addrObj.borough || data.boro || data.borough || "");
157
+ }
158
+ else {
159
+ venueAddress = str(data.address || data.building_address || data.street);
160
+ venueBoro = str(data.boro || data.borough);
161
+ }
162
+ const profile = {
163
+ matched_entity: {
164
+ name: str(data.dba || data.restaurant_name || data.name),
165
+ address: venueAddress,
166
+ borough: venueBoro,
167
+ camis: str(data.camis),
168
+ cuisine: str(data.cuisine_description || data.cuisine),
169
+ confidence: computeConfidence({ name, address, borough }),
170
+ },
171
+ health_inspection: {
172
+ grade: str(data.grade || data.current_grade),
173
+ score: num(data.score || data.inspection_score),
174
+ date: str(data.inspection_date || data.grade_date || data.last_inspection_date),
175
+ action: str(data.action || data.inspection_action),
176
+ critical_violations: num(data.critical_flag_count ?? data.critical_violations),
177
+ },
178
+ total_inspections: num(data.total_inspections ?? data.inspection_count),
179
+ source_coverage: sourceCoverage,
180
+ };
181
+ if (includeSidewalkCafe) {
182
+ const permits = permitsData?.sidewalk_cafe_permits ?? permitsData?.cafe_permits;
183
+ profile.sidewalk_cafe_permits = Array.isArray(permits) ? permits : [];
184
+ }
185
+ if (includeLiquorLicense) {
186
+ const licenses = permitsData?.business_licenses ?? permitsData?.liquor_licenses ?? permitsData?.licenses;
187
+ profile.business_licenses = Array.isArray(licenses) ? licenses : [];
188
+ }
189
+ return JSON.stringify(profile);
190
+ }
@@ -0,0 +1,27 @@
1
+ export declare const TOOL_NAME = "resolve_property_identifier";
2
+ export declare const TOOL_DEFINITION: {
3
+ name: string;
4
+ description: string;
5
+ inputSchema: {
6
+ type: "object";
7
+ properties: {
8
+ address: {
9
+ type: string;
10
+ description: string;
11
+ };
12
+ bbl: {
13
+ type: string;
14
+ description: string;
15
+ };
16
+ bin: {
17
+ type: string;
18
+ description: string;
19
+ };
20
+ };
21
+ };
22
+ };
23
+ export declare function resolvePropertyIdentifier(args: {
24
+ address?: string;
25
+ bbl?: string;
26
+ bin?: string;
27
+ }): Promise<string>;
@@ -0,0 +1,256 @@
1
+ import { NycApiClient } from "../lib/nyc-api-client.js";
2
+ import { mcpError, mapApiErrorToMcp, ErrorCodes } from "../lib/errors.js";
3
+ export const TOOL_NAME = "resolve_property_identifier";
4
+ export const TOOL_DEFINITION = {
5
+ name: TOOL_NAME,
6
+ description: "Normalize and validate a NYC property identifier. Pass a partial address, BBL, or BIN and get back the normalized address, BBL, BIN, borough, and a confidence score. Use this BEFORE calling get_property_intelligence or get_building_violations to ensure you have the correct property. Returns multiple candidates if the input is ambiguous. Free or near-free — always resolve first.",
7
+ inputSchema: {
8
+ type: "object",
9
+ properties: {
10
+ address: {
11
+ type: "string",
12
+ description: "Partial or full NYC street address",
13
+ },
14
+ bbl: {
15
+ type: "string",
16
+ description: "Borough-Block-Lot (10 digits)",
17
+ },
18
+ bin: {
19
+ type: "string",
20
+ description: "Building Identification Number",
21
+ },
22
+ },
23
+ },
24
+ };
25
+ function isApiError(result) {
26
+ return (typeof result === "object" &&
27
+ result !== null &&
28
+ "error" in result &&
29
+ typeof result.error === "object");
30
+ }
31
+ function extractCandidate(record, confidence) {
32
+ // Handle nested address object from PropertyProfile: { full, borough, zipcode }
33
+ let normalizedAddress = "";
34
+ let borough = "";
35
+ const addr = record.address;
36
+ if (addr && typeof addr === "object" && !Array.isArray(addr)) {
37
+ const addrObj = addr;
38
+ normalizedAddress = String(addrObj.full || addrObj.address || "");
39
+ borough = String(addrObj.borough || record.borough || "");
40
+ }
41
+ else {
42
+ normalizedAddress = String(addr || record.normalized_address || "");
43
+ borough = String(record.borough || "");
44
+ }
45
+ return {
46
+ normalized_address: normalizedAddress,
47
+ bbl: String(record.bbl || "").replace(/\.0+$/, ""),
48
+ bin: record.bin ? String(record.bin) : null,
49
+ borough,
50
+ confidence,
51
+ };
52
+ }
53
+ function computeConfidence(input) {
54
+ if (input.bbl)
55
+ return 1.0;
56
+ if (input.bin)
57
+ return 0.95;
58
+ if (input.address && input.borough)
59
+ return 0.9;
60
+ return 0.7;
61
+ }
62
+ /** Known NYC borough names and abbreviations */
63
+ const BOROUGH_ALIASES = {
64
+ manhattan: "Manhattan",
65
+ bronx: "Bronx",
66
+ brooklyn: "Brooklyn",
67
+ queens: "Queens",
68
+ "staten island": "Staten Island",
69
+ mn: "Manhattan",
70
+ bx: "Bronx",
71
+ bk: "Brooklyn",
72
+ qn: "Queens",
73
+ si: "Staten Island",
74
+ "new york": "Manhattan",
75
+ nyc: "Manhattan",
76
+ ny: "Manhattan",
77
+ };
78
+ /**
79
+ * Parse a free-text address into street address + borough.
80
+ * Handles formats like:
81
+ * "100 Broadway, Manhattan"
82
+ * "100 Broadway Manhattan"
83
+ * "100 Broadway, Brooklyn, NY"
84
+ * "100 Broadway New York"
85
+ */
86
+ function parseAddressAndBorough(rawAddress) {
87
+ const trimmed = rawAddress.trim();
88
+ // Try comma-separated parts
89
+ const parts = trimmed.split(",").map((p) => p.trim()).filter(Boolean);
90
+ if (parts.length >= 2) {
91
+ // Check each part after the first for a borough match
92
+ for (let i = 1; i < parts.length; i++) {
93
+ const normalized = parts[i].toLowerCase().replace(/\s+/g, " ");
94
+ if (BOROUGH_ALIASES[normalized]) {
95
+ const street = parts.slice(0, i).join(", ");
96
+ return { street, borough: BOROUGH_ALIASES[normalized] };
97
+ }
98
+ }
99
+ // No borough found in comma parts — use first part as street
100
+ return { street: parts[0] };
101
+ }
102
+ // No commas — check if the last word(s) are a borough
103
+ const words = trimmed.split(/\s+/);
104
+ // Try last two words (e.g., "Staten Island")
105
+ if (words.length >= 3) {
106
+ const lastTwo = words.slice(-2).join(" ").toLowerCase();
107
+ if (BOROUGH_ALIASES[lastTwo]) {
108
+ return { street: words.slice(0, -2).join(" "), borough: BOROUGH_ALIASES[lastTwo] };
109
+ }
110
+ }
111
+ // Try last word (e.g., "Manhattan")
112
+ if (words.length >= 2) {
113
+ const lastOne = words[words.length - 1].toLowerCase();
114
+ if (BOROUGH_ALIASES[lastOne]) {
115
+ return { street: words.slice(0, -1).join(" "), borough: BOROUGH_ALIASES[lastOne] };
116
+ }
117
+ }
118
+ return { street: trimmed };
119
+ }
120
+ export async function resolvePropertyIdentifier(args) {
121
+ const rawAddress = args.address?.trim() || undefined;
122
+ const bbl = args.bbl?.trim() || undefined;
123
+ const bin = args.bin?.trim() || undefined;
124
+ if (!rawAddress && !bbl && !bin) {
125
+ return JSON.stringify(mcpError(ErrorCodes.MISSING_REQUIRED_IDENTIFIER));
126
+ }
127
+ // Parse address into street + borough
128
+ let address;
129
+ let borough;
130
+ if (rawAddress) {
131
+ const parsed = parseAddressAndBorough(rawAddress);
132
+ address = parsed.street;
133
+ borough = parsed.borough;
134
+ }
135
+ try {
136
+ const client = new NycApiClient();
137
+ // Step 1: Try /api/property/lookup
138
+ const lookupParams = {};
139
+ if (bbl)
140
+ lookupParams.bbl = bbl;
141
+ if (address)
142
+ lookupParams.address = address;
143
+ if (borough)
144
+ lookupParams.borough = borough;
145
+ if (bin)
146
+ lookupParams.bin = bin;
147
+ const lookupResult = await client.propertyLookup(lookupParams);
148
+ if (isApiError(lookupResult)) {
149
+ // For non-404 errors, return immediately
150
+ const mapped = mapApiErrorToMcp(lookupResult);
151
+ if (mapped.error.code !== ErrorCodes.NOT_FOUND) {
152
+ return JSON.stringify(mapped);
153
+ }
154
+ // For 404, fall through to search
155
+ }
156
+ else if (lookupResult) {
157
+ const record = lookupResult;
158
+ // Single match from lookup
159
+ if (record.bbl || record.address) {
160
+ const confidence = computeConfidence({ address, bbl, bin, borough });
161
+ const candidate = extractCandidate(record, confidence);
162
+ const result = {
163
+ candidates: [candidate],
164
+ match_count: 1,
165
+ recommendation: "Exact match found. Proceed with get_property_intelligence or get_building_violations.",
166
+ };
167
+ return JSON.stringify(result);
168
+ }
169
+ // Lookup returned an array of results (some APIs do this)
170
+ if (Array.isArray(record)) {
171
+ const candidates = record.map((r, i) => extractCandidate(r, i === 0 ? 0.9 : 0.9 - i * 0.1));
172
+ if (candidates.length === 1) {
173
+ const result = {
174
+ candidates,
175
+ match_count: 1,
176
+ recommendation: "Single match found. Proceed with get_property_intelligence or get_building_violations.",
177
+ };
178
+ return JSON.stringify(result);
179
+ }
180
+ if (candidates.length > 1) {
181
+ const result = {
182
+ candidates: candidates.slice(0, 10),
183
+ match_count: candidates.length,
184
+ recommendation: "Multiple candidates found. Add borough or zipcode to narrow the match, or select a candidate by BBL.",
185
+ };
186
+ return JSON.stringify(result);
187
+ }
188
+ }
189
+ // Check if lookup returned a data wrapper
190
+ if (record.data) {
191
+ const data = record.data;
192
+ if (Array.isArray(data) && data.length > 0) {
193
+ const candidates = data.map((r, i) => extractCandidate(r, i === 0 ? computeConfidence({ address, bbl, bin, borough }) : 0.7 - i * 0.1));
194
+ const result = {
195
+ candidates: candidates.slice(0, 10),
196
+ match_count: candidates.length,
197
+ recommendation: candidates.length === 1
198
+ ? "Exact match found. Proceed with get_property_intelligence or get_building_violations."
199
+ : "Multiple candidates found. Add borough or zipcode to narrow the match, or select a candidate by BBL.",
200
+ };
201
+ return JSON.stringify(result);
202
+ }
203
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) {
204
+ const candidate = extractCandidate(data, computeConfidence({ address, bbl, bin, borough }));
205
+ const result = {
206
+ candidates: [candidate],
207
+ match_count: 1,
208
+ recommendation: "Exact match found. Proceed with get_property_intelligence or get_building_violations.",
209
+ };
210
+ return JSON.stringify(result);
211
+ }
212
+ }
213
+ }
214
+ // Step 2: If lookup failed/empty and we have an address, try search
215
+ if (address) {
216
+ const searchResult = await client.propertySearch({ address, borough });
217
+ if (isApiError(searchResult)) {
218
+ const mapped = mapApiErrorToMcp(searchResult);
219
+ if (mapped.error.code !== ErrorCodes.NOT_FOUND) {
220
+ return JSON.stringify(mapped);
221
+ }
222
+ }
223
+ else if (searchResult) {
224
+ const searchRecord = searchResult;
225
+ let results = [];
226
+ if (Array.isArray(searchRecord)) {
227
+ results = searchRecord;
228
+ }
229
+ else if (Array.isArray(searchRecord.data)) {
230
+ results = searchRecord.data;
231
+ }
232
+ else if (Array.isArray(searchRecord.results)) {
233
+ results = searchRecord.results;
234
+ }
235
+ if (results.length > 0) {
236
+ const candidates = results.slice(0, 10).map((r, i) => extractCandidate(r, i === 0 ? 0.7 : Math.max(0.3, 0.7 - i * 0.1)));
237
+ const result = {
238
+ candidates,
239
+ match_count: results.length,
240
+ recommendation: candidates.length === 1
241
+ ? "Single search match found. Verify the address and proceed."
242
+ : "Multiple search results found. Refine with borough or zipcode, or select a candidate by BBL.",
243
+ };
244
+ return JSON.stringify(result);
245
+ }
246
+ }
247
+ }
248
+ // Step 3: Nothing found
249
+ const notFound = mcpError(ErrorCodes.NOT_FOUND, `No property found for the given identifier${address ? ` (address: "${address}"${borough ? `, borough: ${borough}` : ""})` : ""}${bbl ? ` (BBL: ${bbl})` : ""}${bin ? ` (BIN: ${bin})` : ""}. Try providing both address and borough (Manhattan, Bronx, Brooklyn, Queens, or Staten Island).`);
250
+ return JSON.stringify(notFound);
251
+ }
252
+ catch (err) {
253
+ console.error("resolve_property_identifier error:", err);
254
+ return JSON.stringify(mcpError(ErrorCodes.UPSTREAM_TIMEOUT, `Unexpected error: ${err instanceof Error ? err.message : String(err)}`));
255
+ }
256
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@matchuplabs/nyc-api-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for NYC property intelligence, building violations, and restaurant/venue compliance data via nycapi.app",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "nyc-api-mcp": "dist/index.js"
9
+ },
10
+ "mcpName": "io.github.matchuplabs/nyc-api",
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/MATCHUP-LABS/nycapi.git"
15
+ },
16
+ "keywords": ["mcp", "nyc", "property", "violations", "restaurant", "real-estate", "api", "ai-agent"],
17
+ "files": ["dist", "server.json", "README.md"],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "dev": "tsx src/index.ts",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "latest",
25
+ "node-fetch": "^3.3.2"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "tsx": "^4.19.0",
30
+ "typescript": "^5.7.0"
31
+ }
32
+ }
package/server.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "io.github.matchuplabs/nyc-api",
3
+ "title": "NYC Property & Venue API",
4
+ "description": "NYC property intelligence, building violations, and restaurant/venue compliance — unified, agent-ready MCP tools.",
5
+ "version": "0.1.0",
6
+ "packages": [
7
+ {
8
+ "registryType": "npm",
9
+ "name": "@matchuplabs/nyc-api-mcp"
10
+ }
11
+ ]
12
+ }