sg-property-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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mukul Varshney
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 ADDED
@@ -0,0 +1,91 @@
1
+ # sg-property-mcp
2
+
3
+ **Singapore property prices, land use, and neighborhood amenities via MCP — no API keys needed.**
4
+
5
+ An [MCP](https://modelcontextprotocol.io/) server for Singapore property research. Works out of the box with zero configuration.
6
+
7
+ ## What it does
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+ | **search_area** | Land use and zoning info near a Singapore address |
12
+ | **search_area_by_coords** | Same as above, using coordinates directly |
13
+ | **search_hdb_resale** | Recent HDB resale flat prices by town (2017 onwards) |
14
+ | **search_nearby_amenities** | Schools, MRT, parks, hawker centres, hospitals within a radius |
15
+ | **analyze_results** | AI-powered analysis of your last search |
16
+ | **export_csv** / **export_md** | Save results to CSV or Markdown files |
17
+
18
+ **8 tools. Zero API keys. Just works.**
19
+
20
+ ## Quick start
21
+
22
+ ### Desktop app
23
+
24
+ Add to your MCP client config:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "sg-property": {
30
+ "command": "npx",
31
+ "args": ["-y", "sg-property-mcp"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ ### CLI (MCP-compatible)
38
+
39
+ ```bash
40
+ # Example for CLI tools that support MCP:
41
+ npx sg-property-mcp
42
+ ```
43
+
44
+ ### Manual install
45
+
46
+ ```bash
47
+ npm install -g sg-property-mcp
48
+ ```
49
+
50
+ Then add to your MCP client config:
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "sg-property": {
56
+ "command": "sg-property-mcp"
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Example conversations
63
+
64
+ > **"What's the land use around Bishan MRT?"**
65
+ > → `search_area` returns zoning types, plot ratios, planning areas
66
+
67
+ > **"Show me recent 4-room HDB prices in Tampines"**
68
+ > → `search_hdb_resale` returns transactions with prices, block, storey, lease info
69
+
70
+ > **"What amenities are within 500m of 1.3521, 103.8198?"**
71
+ > → `search_nearby_amenities` finds schools, MRT stations, parks, hawker centres with distances
72
+
73
+ > **"Analyze the price patterns in those results"**
74
+ > → `analyze_results` provides AI-generated insights on pricing trends
75
+
76
+ ## Data sources
77
+
78
+ All data comes from free, public APIs. No registration or API keys required.
79
+
80
+ - Land use zoning — (c) Urban Redevelopment Authority
81
+ - HDB resale prices — Contains information from data.gov.sg accessed under the Singapore Open Data Licence
82
+ - Geocoding & amenities — Data (c) OpenStreetMap contributors
83
+
84
+ ## Requirements
85
+
86
+ - Node.js 20.6 or later
87
+ - An MCP-compatible client (any MCP desktop app or CLI)
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,2 @@
1
+ import { LandParcel } from "../types.js";
2
+ export declare function queryLandUse(lat: number, lon: number, radiusMeters: number): Promise<LandParcel[]>;
@@ -0,0 +1,58 @@
1
+ // URA ArcGIS client — Master Plan 2019 land use spatial query.
2
+ // Finds land parcels within a radius of a point.
3
+ // Free, no API key. Geometry format: longitude,latitude (not lat,lon).
4
+ import { ARCGIS_LAND_USE_URL, ARCGIS_RESULT_LIMIT } from "../config.js";
5
+ export async function queryLandUse(lat, lon, radiusMeters) {
6
+ // ArcGIS expects longitude,latitude order
7
+ const body = new URLSearchParams({
8
+ geometry: `${lon},${lat}`,
9
+ geometryType: "esriGeometryPoint",
10
+ inSR: "4326",
11
+ outSR: "4326",
12
+ distance: radiusMeters.toString(),
13
+ units: "esriSRUnit_Meter",
14
+ spatialRel: "esriSpatialRelIntersects",
15
+ outFields: "LU_DESC,GPR,REGION_N,PLN_AREA_N,SUBZONE_N",
16
+ returnGeometry: "false",
17
+ f: "json",
18
+ resultRecordCount: ARCGIS_RESULT_LIMIT.toString(),
19
+ });
20
+ const response = await fetch(ARCGIS_LAND_USE_URL, {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
23
+ body,
24
+ });
25
+ if (!response.ok) {
26
+ console.error(`ArcGIS HTTP ${response.status}: ${response.statusText}`);
27
+ return [];
28
+ }
29
+ const data = (await response.json());
30
+ // ArcGIS can return errors inside a 200 OK
31
+ if (data.error) {
32
+ console.error(`ArcGIS error ${data.error.code}: ${data.error.message}`);
33
+ return [];
34
+ }
35
+ if (!data.features) {
36
+ return [];
37
+ }
38
+ // Filter out parcels without a meaningful land use description
39
+ const parcels = data.features
40
+ .filter((f) => f.attributes.LU_DESC && f.attributes.LU_DESC.trim() !== "")
41
+ .map((f) => ({
42
+ landUse: f.attributes.LU_DESC,
43
+ grossPlotRatio: f.attributes.GPR ?? null,
44
+ region: f.attributes.REGION_N ?? "",
45
+ planningArea: f.attributes.PLN_AREA_N ?? "",
46
+ subzone: f.attributes.SUBZONE_N ?? "",
47
+ }));
48
+ // Deduplicate — multiple polygons often share identical attributes
49
+ const seen = new Set();
50
+ return parcels.filter((p) => {
51
+ const key = `${p.landUse}|${p.grossPlotRatio}|${p.region}|${p.planningArea}|${p.subzone}`;
52
+ if (seen.has(key))
53
+ return false;
54
+ seen.add(key);
55
+ return true;
56
+ });
57
+ }
58
+ //# sourceMappingURL=arcgis.js.map
@@ -0,0 +1,6 @@
1
+ import { HdbResaleRecord } from "../types.js";
2
+ export declare function queryHdbResale(town: string, flatType?: string, limit?: number, onWait?: (delayMs: number) => void | Promise<void>): Promise<{
3
+ records: HdbResaleRecord[];
4
+ total: number;
5
+ error?: string;
6
+ }>;
@@ -0,0 +1,84 @@
1
+ // data.gov.sg client — HDB resale flat prices (2017 onwards).
2
+ // Free, no API key. CKAN datastore API.
3
+ import { DATAGOV_URL, HDB_RESALE_RESOURCE_ID } from "../config.js";
4
+ import { datagovLimiter } from "../rate-limiter.js";
5
+ function isErrorResponse(data) {
6
+ return "code" in data && "name" in data;
7
+ }
8
+ export async function queryHdbResale(town, flatType, limit = 10, onWait) {
9
+ const filters = { town: town.toUpperCase() };
10
+ if (flatType) {
11
+ filters.flat_type = flatType.toUpperCase();
12
+ }
13
+ const params = new URLSearchParams({
14
+ resource_id: HDB_RESALE_RESOURCE_ID,
15
+ filters: JSON.stringify(filters),
16
+ limit: limit.toString(),
17
+ sort: "month desc",
18
+ });
19
+ // Retry with backoff on 429 / rate-limit errors.
20
+ const MAX_RETRIES = 2;
21
+ let data;
22
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
23
+ await datagovLimiter.wait(onWait);
24
+ let response;
25
+ try {
26
+ response = await fetch(`${DATAGOV_URL}?${params}`);
27
+ }
28
+ catch (err) {
29
+ if (attempt < MAX_RETRIES) {
30
+ await new Promise((r) => setTimeout(r, (attempt + 1) * 15_000));
31
+ continue;
32
+ }
33
+ console.error(`[datagov] Network error: ${err.message}`);
34
+ return { records: [], total: 0, error: "Could not reach data service. Retry after 30 seconds delay." };
35
+ }
36
+ if (response.status === 429) {
37
+ const backoffSec = (attempt + 1) * 15; // 15s, 30s, 45s
38
+ console.error(`[datagov] HTTP 429 — attempt ${attempt + 1}/${MAX_RETRIES + 1}, backing off ${backoffSec}s`);
39
+ if (attempt < MAX_RETRIES) {
40
+ await new Promise((r) => setTimeout(r, backoffSec * 1000));
41
+ continue;
42
+ }
43
+ return { records: [], total: 0, error: "Data service is busy. Retry after 30 seconds delay." };
44
+ }
45
+ if (!response.ok) {
46
+ console.error(`[datagov] HTTP ${response.status}: ${response.statusText}`);
47
+ return { records: [], total: 0, error: "Data service returned an error. Retry after 30 seconds delay." };
48
+ }
49
+ data = (await response.json());
50
+ // Rate limit response: {"code": 24, "name": "TOO_MANY_REQUESTS"}
51
+ if (isErrorResponse(data)) {
52
+ console.error(`[datagov] Error response: ${data.name} (code ${data.code})`);
53
+ if (data.name === "TOO_MANY_REQUESTS" && attempt < MAX_RETRIES) {
54
+ await new Promise((r) => setTimeout(r, (attempt + 1) * 15_000));
55
+ continue;
56
+ }
57
+ return { records: [], total: 0, error: "Data service is busy. Retry after 30 seconds delay." };
58
+ }
59
+ break; // Success — exit retry loop
60
+ }
61
+ if (!data || isErrorResponse(data)) {
62
+ return { records: [], total: 0, error: "Could not reach data service. Retry after 30 seconds delay." };
63
+ }
64
+ if (!data.success || !data.result) {
65
+ return { records: [], total: 0 };
66
+ }
67
+ const records = data.result.records.map((r) => ({
68
+ month: r.month,
69
+ town: r.town,
70
+ flatType: r.flat_type,
71
+ block: r.block,
72
+ streetName: r.street_name,
73
+ storeyRange: r.storey_range,
74
+ floorAreaSqm: r.floor_area_sqm,
75
+ flatModel: r.flat_model,
76
+ leaseCommenceDate: r.lease_commence_date,
77
+ remainingLease: r.remaining_lease,
78
+ resalePrice: typeof r.resale_price === "number"
79
+ ? r.resale_price
80
+ : parseFloat(r.resale_price),
81
+ }));
82
+ return { records, total: data.result.total };
83
+ }
84
+ //# sourceMappingURL=datagov.js.map
@@ -0,0 +1,2 @@
1
+ import { GeocodingResult } from "../types.js";
2
+ export declare function geocodeAddress(address: string, onWait?: (delayMs: number) => void | Promise<void>): Promise<GeocodingResult | null>;
@@ -0,0 +1,32 @@
1
+ // Nominatim (OpenStreetMap) geocoding client.
2
+ // Converts a Singapore address string to lat/lon coordinates.
3
+ // Free, no API key. Rate limit: 1 request per second.
4
+ import { NOMINATIM_URL, USER_AGENT } from "../config.js";
5
+ import { nominatimLimiter } from "../rate-limiter.js";
6
+ export async function geocodeAddress(address, onWait) {
7
+ const params = new URLSearchParams({
8
+ q: address,
9
+ format: "json",
10
+ limit: "1",
11
+ countrycodes: "sg",
12
+ });
13
+ await nominatimLimiter.wait(onWait);
14
+ const response = await fetch(`${NOMINATIM_URL}?${params}`, {
15
+ headers: { "User-Agent": USER_AGENT },
16
+ });
17
+ if (!response.ok) {
18
+ console.error(`Nominatim HTTP ${response.status}: ${response.statusText}`);
19
+ return null;
20
+ }
21
+ const data = (await response.json());
22
+ if (data.length === 0) {
23
+ return null;
24
+ }
25
+ const result = data[0];
26
+ return {
27
+ lat: parseFloat(result.lat),
28
+ lon: parseFloat(result.lon),
29
+ displayName: result.display_name,
30
+ };
31
+ }
32
+ //# sourceMappingURL=nominatim.js.map
@@ -0,0 +1,11 @@
1
+ import type { NearbyAmenity } from "../types.js";
2
+ /** All supported amenity categories. */
3
+ export declare const SUPPORTED_CATEGORIES: string[];
4
+ /**
5
+ * Query nearby amenities around a coordinate.
6
+ * Returns normalized NearbyAmenity records sorted by distance.
7
+ */
8
+ export declare function queryNearbyAmenities(lat: number, lon: number, radiusMeters: number, categories: string[], onWait?: () => void | Promise<void>): Promise<{
9
+ amenities: NearbyAmenity[];
10
+ error?: string;
11
+ }>;
@@ -0,0 +1,153 @@
1
+ // Overpass API client — queries OpenStreetMap for nearby amenities.
2
+ // Free, no API key. Data licensed under ODbL.
3
+ // Rate limit: ~10,000 requests/day, max 2 concurrent queries per IP.
4
+ // We use a single combined query per tool call to minimise requests.
5
+ import { OVERPASS_URL, USER_AGENT } from "../config.js";
6
+ import { overpassLimiter } from "../rate-limiter.js";
7
+ // --- Amenity category definitions ---
8
+ const AMENITY_TAGS = [
9
+ { key: "amenity", value: "school", category: "school" },
10
+ { key: "amenity", value: "hospital", category: "hospital" },
11
+ { key: "amenity", value: "clinic", category: "clinic" },
12
+ { key: "amenity", value: "food_court", category: "food_court" },
13
+ { key: "amenity", value: "marketplace", category: "marketplace" },
14
+ { key: "amenity", value: "pharmacy", category: "pharmacy" },
15
+ { key: "leisure", value: "park", category: "park" },
16
+ { key: "railway", value: "station", category: "mrt" },
17
+ { key: "highway", value: "bus_stop", category: "bus_stop" },
18
+ { key: "shop", value: "supermarket", category: "supermarket" },
19
+ ];
20
+ const NODE_ONLY_CATEGORIES = new Set(["bus_stop", "mrt", "pharmacy"]);
21
+ // --- Distance calculation ---
22
+ /** Haversine distance in meters between two lat/lng points. */
23
+ function haversineMeters(lat1, lon1, lat2, lon2) {
24
+ const R = 6371000;
25
+ const toRad = (deg) => (deg * Math.PI) / 180;
26
+ const dLat = toRad(lat2 - lat1);
27
+ const dLon = toRad(lon2 - lon1);
28
+ const a = Math.sin(dLat / 2) ** 2 +
29
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
30
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
31
+ }
32
+ // --- Query builder ---
33
+ function buildQuery(lat, lon, radiusMeters, categories) {
34
+ const around = `around:${radiusMeters},${lat},${lon}`;
35
+ const categorySet = new Set(categories);
36
+ const parts = [];
37
+ for (const tag of AMENITY_TAGS) {
38
+ if (!categorySet.has(tag.category))
39
+ continue;
40
+ parts.push(` node["${tag.key}"="${tag.value}"](${around});`);
41
+ if (!NODE_ONLY_CATEGORIES.has(tag.category)) {
42
+ parts.push(` way["${tag.key}"="${tag.value}"](${around});`);
43
+ if (tag.category === "park") {
44
+ parts.push(` relation["${tag.key}"="${tag.value}"](${around});`);
45
+ }
46
+ }
47
+ }
48
+ return `[out:json][timeout:60];\n(\n${parts.join("\n")}\n);\nout center body;`;
49
+ }
50
+ // --- Category extraction ---
51
+ function extractCategory(tags) {
52
+ for (const tag of AMENITY_TAGS) {
53
+ if (tags[tag.key] === tag.value)
54
+ return tag.category;
55
+ }
56
+ return "other";
57
+ }
58
+ function extractAddress(tags) {
59
+ const parts = [];
60
+ if (tags["addr:housenumber"])
61
+ parts.push(tags["addr:housenumber"]);
62
+ if (tags["addr:street"])
63
+ parts.push(tags["addr:street"]);
64
+ if (tags["addr:postcode"])
65
+ parts.push(`Singapore ${tags["addr:postcode"]}`);
66
+ return parts.length > 0 ? parts.join(" ") : null;
67
+ }
68
+ // --- Public API ---
69
+ /** All supported amenity categories. */
70
+ export const SUPPORTED_CATEGORIES = AMENITY_TAGS.map((t) => t.category);
71
+ /**
72
+ * Query nearby amenities around a coordinate.
73
+ * Returns normalized NearbyAmenity records sorted by distance.
74
+ */
75
+ export async function queryNearbyAmenities(lat, lon, radiusMeters, categories, onWait) {
76
+ const query = buildQuery(lat, lon, radiusMeters, categories);
77
+ const MAX_RETRIES = 3;
78
+ let resp;
79
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
80
+ await overpassLimiter.wait(onWait ? async () => { await onWait(); } : undefined);
81
+ try {
82
+ resp = await fetch(OVERPASS_URL, {
83
+ method: "POST",
84
+ headers: {
85
+ "Content-Type": "application/x-www-form-urlencoded",
86
+ "User-Agent": USER_AGENT,
87
+ },
88
+ body: `data=${encodeURIComponent(query)}`,
89
+ });
90
+ }
91
+ catch (err) {
92
+ if (attempt < MAX_RETRIES)
93
+ continue;
94
+ console.error(`[overpass] Network error: ${err.message}`);
95
+ return { amenities: [], error: "Could not reach amenity service. Retry after 30 seconds delay." };
96
+ }
97
+ if ((resp.status === 429 || resp.status === 504) && attempt < MAX_RETRIES) {
98
+ continue;
99
+ }
100
+ break;
101
+ }
102
+ if (!resp) {
103
+ return { amenities: [], error: "Network error after retries." };
104
+ }
105
+ if (resp.status === 429) {
106
+ return { amenities: [], error: "Service is busy. Retry after 30 seconds delay." };
107
+ }
108
+ if (!resp.ok) {
109
+ return { amenities: [], error: `Service returned an error (status ${resp.status}).` };
110
+ }
111
+ let data;
112
+ try {
113
+ data = (await resp.json());
114
+ }
115
+ catch {
116
+ return { amenities: [], error: "Invalid response from amenity service." };
117
+ }
118
+ const amenities = [];
119
+ const seenIds = new Set();
120
+ for (const el of data.elements) {
121
+ if (seenIds.has(el.id))
122
+ continue;
123
+ seenIds.add(el.id);
124
+ const tags = el.tags ?? {};
125
+ const name = tags.name ?? tags["name:en"] ?? "(unnamed)";
126
+ let elLat;
127
+ let elLon;
128
+ if (el.lat !== undefined && el.lon !== undefined) {
129
+ elLat = el.lat;
130
+ elLon = el.lon;
131
+ }
132
+ else if (el.center) {
133
+ elLat = el.center.lat;
134
+ elLon = el.center.lon;
135
+ }
136
+ if (elLat === undefined || elLon === undefined)
137
+ continue;
138
+ const category = extractCategory(tags);
139
+ const distance = haversineMeters(lat, lon, elLat, elLon);
140
+ amenities.push({
141
+ category,
142
+ name,
143
+ lat: elLat,
144
+ lon: elLon,
145
+ distanceMeters: Math.round(distance),
146
+ address: extractAddress(tags),
147
+ tags,
148
+ });
149
+ }
150
+ amenities.sort((a, b) => a.distanceMeters - b.distanceMeters);
151
+ return { amenities };
152
+ }
153
+ //# sourceMappingURL=overpass.js.map
@@ -0,0 +1,14 @@
1
+ export declare const SERVER_NAME = "sg-property";
2
+ export declare const SERVER_VERSION = "0.1.0";
3
+ export declare const USER_AGENT: string;
4
+ export declare const NOMINATIM_URL: string;
5
+ export declare const ARCGIS_LAND_USE_URL: string;
6
+ export declare const DATAGOV_URL: string;
7
+ export declare const HDB_RESALE_RESOURCE_ID: string;
8
+ export declare const OVERPASS_URL: string;
9
+ export declare const RADIUS_MIN: number;
10
+ export declare const RADIUS_MAX: number;
11
+ export declare const RADIUS_DEFAULT: number;
12
+ export declare const HDB_LIMIT_DEFAULT: number;
13
+ export declare const ARCGIS_RESULT_LIMIT: number;
14
+ export declare const SAMPLING_MAX_TOKENS: number;
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ // Central configuration — API URLs, defaults, and constants.
2
+ // All endpoints are free, zero-key APIs verified working April 2026.
3
+ function envInt(key, fallback) {
4
+ const val = process.env[key];
5
+ if (!val)
6
+ return fallback;
7
+ const parsed = parseInt(val, 10);
8
+ return isNaN(parsed) ? fallback : parsed;
9
+ }
10
+ export const SERVER_NAME = "sg-property";
11
+ export const SERVER_VERSION = "0.1.0";
12
+ export const USER_AGENT = process.env.USER_AGENT || "SG-Property-MCP/0.1.0";
13
+ // Nominatim (OpenStreetMap) — geocoding
14
+ export const NOMINATIM_URL = process.env.NOMINATIM_URL || "https://nominatim.openstreetmap.org/search";
15
+ // URA ArcGIS — Master Plan 2019 land use spatial query
16
+ export const ARCGIS_LAND_USE_URL = process.env.ARCGIS_LAND_USE_URL ||
17
+ "https://maps.ura.gov.sg/arcgis/rest/services/MP19/Updated_Landuse_gaz/MapServer/24/query";
18
+ // data.gov.sg — HDB resale flat prices (2017 onwards)
19
+ export const DATAGOV_URL = process.env.DATAGOV_URL || "https://data.gov.sg/api/action/datastore_search";
20
+ export const HDB_RESALE_RESOURCE_ID = process.env.HDB_RESALE_RESOURCE_ID || "f1765b54-a209-4718-8d38-a39237f502b3";
21
+ // Overpass API (OpenStreetMap) — nearby amenities
22
+ export const OVERPASS_URL = process.env.OVERPASS_URL || "https://overpass-api.de/api/interpreter";
23
+ // Radius bounds for spatial queries (meters)
24
+ export const RADIUS_MIN = envInt("RADIUS_MIN", 10);
25
+ export const RADIUS_MAX = envInt("RADIUS_MAX", 5000);
26
+ export const RADIUS_DEFAULT = envInt("RADIUS_DEFAULT", 100);
27
+ // Default result limits
28
+ export const HDB_LIMIT_DEFAULT = envInt("HDB_LIMIT_DEFAULT", 10);
29
+ export const ARCGIS_RESULT_LIMIT = envInt("ARCGIS_RESULT_LIMIT", 50);
30
+ // Sampling defaults (analyze_results tool)
31
+ export const SAMPLING_MAX_TOKENS = envInt("SAMPLING_MAX_TOKENS", 2048);
32
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,7 @@
1
+ import { LandParcel, HdbResaleRecord, NearbyAmenity } from "./types.js";
2
+ export declare function formatLandParcelsTable(parcels: LandParcel[]): string;
3
+ export declare function formatHdbTable(records: HdbResaleRecord[]): string;
4
+ export declare function formatNearbyAmenityTable(records: NearbyAmenity[]): string;
5
+ export declare function formatLandParcelsCsv(parcels: LandParcel[]): string;
6
+ export declare function formatHdbCsv(records: HdbResaleRecord[]): string;
7
+ export declare function formatNearbyAmenityCsv(records: NearbyAmenity[]): string;
@@ -0,0 +1,93 @@
1
+ // Formatters — markdown tables and CSV output for land use, HDB, and amenities.
2
+ // --- Markdown tables ---
3
+ export function formatLandParcelsTable(parcels) {
4
+ if (parcels.length === 0) {
5
+ return "No land parcels found in this area.";
6
+ }
7
+ const header = "| Land Use | Plot Ratio | Region | Planning Area | Subzone |";
8
+ const divider = "|---|---|---|---|---|";
9
+ const rows = parcels.map((p) => `| ${p.landUse} | ${p.grossPlotRatio ?? "N/A"} | ${p.region} | ${p.planningArea} | ${p.subzone} |`);
10
+ return [header, divider, ...rows].join("\n");
11
+ }
12
+ export function formatHdbTable(records) {
13
+ if (records.length === 0) {
14
+ return "No HDB resale records found.";
15
+ }
16
+ const header = "| Month | Block | Street | Type | Storey | Area (sqm) | Price (SGD) | Lease Start | Remaining Lease |";
17
+ const divider = "|---|---|---|---|---|---|---|---|---|";
18
+ const rows = records.map((r) => `| ${r.month} | ${r.block} | ${r.streetName} | ${r.flatType} | ${r.storeyRange} | ${r.floorAreaSqm} | $${r.resalePrice.toLocaleString()} | ${r.leaseCommenceDate} | ${r.remainingLease} |`);
19
+ return [header, divider, ...rows].join("\n");
20
+ }
21
+ // --- Nearby amenities ---
22
+ const CATEGORY_LABELS = {
23
+ school: "School",
24
+ hospital: "Hospital",
25
+ clinic: "Clinic",
26
+ food_court: "Food Court / Hawker",
27
+ marketplace: "Market",
28
+ park: "Park",
29
+ mrt: "MRT/LRT Station",
30
+ bus_stop: "Bus Stop",
31
+ supermarket: "Supermarket",
32
+ pharmacy: "Pharmacy",
33
+ };
34
+ function formatDistance(meters) {
35
+ if (meters < 1000)
36
+ return `${meters}m`;
37
+ return `${(meters / 1000).toFixed(1)}km`;
38
+ }
39
+ export function formatNearbyAmenityTable(records) {
40
+ if (records.length === 0) {
41
+ return "No nearby amenities found matching your criteria.";
42
+ }
43
+ const header = "| Category | Name | Distance | Address |";
44
+ const divider = "|---|---|---|---|";
45
+ const rows = records.map((r) => `| ${CATEGORY_LABELS[r.category] ?? r.category} | ${r.name} | ${formatDistance(r.distanceMeters)} | ${r.address ?? "—"} |`);
46
+ return [header, divider, ...rows].join("\n");
47
+ }
48
+ // --- CSV ---
49
+ /** Escape a value for CSV: wrap in quotes if it contains commas, quotes, or newlines. */
50
+ function csvEscape(value) {
51
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
52
+ return `"${value.replace(/"/g, '""')}"`;
53
+ }
54
+ return value;
55
+ }
56
+ export function formatLandParcelsCsv(parcels) {
57
+ const header = "Land Use,Plot Ratio,Region,Planning Area,Subzone";
58
+ const rows = parcels.map((p) => [p.landUse, p.grossPlotRatio ?? "N/A", p.region, p.planningArea, p.subzone]
59
+ .map(csvEscape)
60
+ .join(","));
61
+ return [header, ...rows].join("\n");
62
+ }
63
+ export function formatHdbCsv(records) {
64
+ const header = "Month,Block,Street,Flat Type,Storey Range,Floor Area (sqm),Resale Price (SGD),Flat Model,Lease Start,Remaining Lease";
65
+ const rows = records.map((r) => [
66
+ r.month,
67
+ r.block,
68
+ r.streetName,
69
+ r.flatType,
70
+ r.storeyRange,
71
+ r.floorAreaSqm,
72
+ String(r.resalePrice),
73
+ r.flatModel,
74
+ r.leaseCommenceDate,
75
+ r.remainingLease,
76
+ ]
77
+ .map(csvEscape)
78
+ .join(","));
79
+ return [header, ...rows].join("\n");
80
+ }
81
+ export function formatNearbyAmenityCsv(records) {
82
+ const header = "Category,Name,Distance (m),Latitude,Longitude,Address";
83
+ const rows = records.map((r) => [
84
+ CATEGORY_LABELS[r.category] ?? r.category,
85
+ r.name,
86
+ String(r.distanceMeters),
87
+ String(r.lat),
88
+ String(r.lon),
89
+ r.address ?? "",
90
+ ].map(csvEscape).join(","));
91
+ return [header, ...rows].join("\n");
92
+ }
93
+ //# sourceMappingURL=formatters.js.map
@@ -0,0 +1,12 @@
1
+ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
2
+ import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js";
3
+ /** Shorthand for the extra parameter that every tool handler receives. */
4
+ export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
5
+ export declare function clampRadius(value: number): number;
6
+ /**
7
+ * Send a progress notification if the client requested one (via progressToken).
8
+ * No-op when the client didn't ask for progress — safe to call unconditionally.
9
+ */
10
+ export declare function sendProgress(extra: ToolExtra, progress: number, total: number, message?: string): Promise<void>;
11
+ /** Send an info-level log message to the client. */
12
+ export declare function logInfo(extra: ToolExtra, data: string): Promise<void>;
@@ -0,0 +1,26 @@
1
+ // Shared helpers used across tool handlers.
2
+ import { SERVER_NAME, RADIUS_MIN, RADIUS_MAX } from "./config.js";
3
+ export function clampRadius(value) {
4
+ return Math.max(RADIUS_MIN, Math.min(RADIUS_MAX, value));
5
+ }
6
+ /**
7
+ * Send a progress notification if the client requested one (via progressToken).
8
+ * No-op when the client didn't ask for progress — safe to call unconditionally.
9
+ */
10
+ export async function sendProgress(extra, progress, total, message) {
11
+ const token = extra._meta?.progressToken;
12
+ if (token === undefined)
13
+ return;
14
+ await extra.sendNotification({
15
+ method: "notifications/progress",
16
+ params: { progressToken: token, progress, total, message },
17
+ });
18
+ }
19
+ /** Send an info-level log message to the client. */
20
+ export async function logInfo(extra, data) {
21
+ await extra.sendNotification({
22
+ method: "notifications/message",
23
+ params: { level: "info", logger: SERVER_NAME, data },
24
+ });
25
+ }
26
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};