@cenogram/mcp-server 0.5.0 → 0.11.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/README.md +40 -8
- package/dist/api-client.d.ts +279 -1
- package/dist/api-client.js +117 -4
- package/dist/error-messages.js +1 -1
- package/dist/formatters.d.ts +14 -1
- package/dist/formatters.js +647 -9
- package/dist/index.d.ts +1 -0
- package/dist/index.js +129 -108
- package/dist/tools.js +336 -47
- package/package.json +8 -2
package/dist/tools.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Sentry } from "./sentry.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { getStats, getTransactions, getPricePerM2, getDistricts, getLocations, getPriceHistogram, getTransactionsSummary, searchParcels, resolveParcel, getParcelReport, searchByPolygon, compareLocations, getRentalYield, getRentalYieldLocations, getPriceSpread, getPriceSpreadLocations, getValuation, getBuildingBreakdown, getTransactionFlood, getTransactionHeritage, getTransactionLandslide, getTransactionSurroundings, getTransactionTransit, getTransactionPermits, getTransactionPlanning, getTransactionFarmland, getDemographics, getInfrastructureSignals, decodeOAuthCtx, OAUTH_CTX_PREFIX, } from "./api-client.js";
|
|
3
|
+
import { getStats, getTransactions, getPricePerM2, getDistricts, getLocations, searchLocations, getPriceHistogram, getTransactionsSummary, searchParcels, resolveParcel, listParcels, getParcelsMap, searchParcelsByPolygon, getParcelReport, getParcelLandClass, searchByPolygon, compareLocations, getRentalYield, getRentalYieldLocations, getPriceSpread, getPriceSpreadLocations, getFloodRisk, getFloodRiskLocations, getValuation, getBuildingBreakdown, getTransactionFlood, getTransactionHeritage, getTransactionLandslide, getTransactionNature, getTransactionSubsurface, getTransactionSurroundings, getTransactionRoads, getTransactionTransit, getTransactionPermits, getTransactionPlanning, getTransactionFarmland, getDemographics, getInfrastructureSignals, decodeOAuthCtx, isExpectedApiError, OAUTH_CTX_PREFIX, } from "./api-client.js";
|
|
4
4
|
import { signupUrl } from "./error-messages.js";
|
|
5
5
|
import { channelSrc, isHttpMode } from "./transport-mode.js";
|
|
6
6
|
import { sanitizeForLog } from "./auth-dispatch.js";
|
|
7
|
-
import { formatTransactionList, formatMarketOverview, formatPriceStats, formatHistogram, formatParcelResults, formatParcelResolve, formatParcelReport, formatSpatialResults, formatCompareResults, formatLocationHierarchy, formatRentalYield, formatRentalYieldLocations, formatPriceSpread, formatPriceSpreadLocations, formatValuation, formatBuildingBreakdown, formatFloodBreakdown, formatHeritageBreakdown, formatLandslideBreakdown, formatSurroundings, formatTransitBreakdown, formatPermitsBreakdown, formatPlanningBreakdown, formatFarmland, formatDemographics, formatInfrastructureSignals, MARKET_CAVEAT, } from "./formatters.js";
|
|
8
|
-
import { mapPropertyType, mapMarketType, mapUnitFunction, mapBuildingType, mapOwnershipTypes, mapTransactionTypes, radiusKmToBbox, filterByLocation, resolveDistrict, tryResolveCityKey, } from "./mappings.js";
|
|
7
|
+
import { formatTransactionList, formatMarketOverview, formatPriceStats, formatHistogram, formatParcelResults, formatParcelResolve, formatParcelList, formatParcelFeatures, formatParcelReport, formatParcelLandClass, formatSpatialResults, formatCompareResults, formatLocationHierarchy, formatLocationSearch, formatRentalYield, formatRentalYieldLocations, formatPriceSpread, formatPriceSpreadLocations, formatFloodRisk, formatFloodRiskLocations, formatValuation, formatBuildingBreakdown, formatFloodBreakdown, formatHeritageBreakdown, formatLandslideBreakdown, formatNatureBreakdown, formatSubsurfaceBreakdown, formatSurroundings, formatRoads, formatTransitBreakdown, formatPermitsBreakdown, formatPlanningBreakdown, formatFarmland, formatDemographics, formatInfrastructureSignals, MARKET_CAVEAT, } from "./formatters.js";
|
|
8
|
+
import { mapPropertyType, mapMarketType, mapUnitFunction, mapBuildingType, mapOwnershipTypes, mapTransactionTypes, radiusKmToBbox, filterByLocation, resolveDistrict, tryResolveCityKey, stripDiacritics, } from "./mappings.js";
|
|
9
9
|
function sanitizeInput(s, maxLen = 50) {
|
|
10
10
|
return s.replace(/[<>]/g, "").slice(0, maxLen);
|
|
11
11
|
}
|
|
@@ -52,7 +52,9 @@ async function withErrorHandling(toolName, apiKey, fn) {
|
|
|
52
52
|
}
|
|
53
53
|
catch (error) {
|
|
54
54
|
success = false;
|
|
55
|
-
|
|
55
|
+
if (!isExpectedApiError(error)) {
|
|
56
|
+
Sentry.captureException(error, { tags: { tool: toolName, error_layer: "tool_execution" } });
|
|
57
|
+
}
|
|
56
58
|
const message = error instanceof Error ? error.message : String(error);
|
|
57
59
|
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
58
60
|
}
|
|
@@ -72,16 +74,27 @@ async function withErrorHandling(toolName, apiKey, fn) {
|
|
|
72
74
|
export function experimentalToolsEnabled() {
|
|
73
75
|
return process.env.CENOGRAM_EXPERIMENTAL_TOOLS === "1";
|
|
74
76
|
}
|
|
77
|
+
const landUseParam = z.array(z.enum([
|
|
78
|
+
"gruntyZabudowaneIZurbanizowane",
|
|
79
|
+
"gruntyRolne",
|
|
80
|
+
"gruntyLesne",
|
|
81
|
+
"terenyKomunikacyjne",
|
|
82
|
+
"inne",
|
|
83
|
+
"unknown",
|
|
84
|
+
])).optional().describe("Recorded land-use category of the transaction's land. Multi-select from: gruntyZabudowaneIZurbanizowane (built-up and urbanised), gruntyRolne (agricultural), gruntyLesne (forest), terenyKomunikacyjne (transport), inne (other). 'unknown' = no category recorded for the land (NULL) — a legitimate bucket, never a claim that the land has no use. Values are case-sensitive. E.g. ['gruntyRolne'] for farmland, or ['gruntyZabudowaneIZurbanizowane','gruntyRolne'] to compare developed vs farmland.");
|
|
85
|
+
const buildingStoreysParam = z.array(z.string().regex(/^(\d+|\d+plus|unknown)$/i, "Invalid buildingStoreys token - use a non-negative integer (e.g. '1','2'), 'Nplus' (e.g. '3plus'), or 'unknown'.")).optional().describe("Number of above-ground storeys of the building. Multi-select buckets: exact non-negative integers (e.g. '1','2'), 'Nplus' e.g. '3plus' = 3 or more, 'unknown' = no storey count recorded (NULL). Recorded ONLY for single-building transactions, so 'unknown' covers BOTH a deed with several buildings (no single storey count exists) and a single building with missing data — never read it as 'a building with no storeys'. This is NOT the floor of a unit (see floor). Without 'unknown', rows with no storey count are excluded.");
|
|
86
|
+
const minFootprintAreaParam = z.number().optional().describe("Minimum building footprint (ground-plan) area in m², summed over all buildings of the transaction. Distinct from minArea, which measures usable floor area (units) or land/parcel area. Set only where every linked building has footprint data, so this bound selects only measured rows — absence means 'not measured', not 'no building'.");
|
|
87
|
+
const maxFootprintAreaParam = z.number().optional().describe("Maximum building footprint (ground-plan) area in m², summed over all buildings of the transaction. Distinct from maxArea, which measures usable floor area (units) or land/parcel area. Set only where every linked building has footprint data, so this bound selects only measured rows — absence means 'not measured', not 'no building'.");
|
|
75
88
|
export function registerTools(server, apiKey) {
|
|
76
89
|
server.tool("search_transactions", `Search Polish real estate transactions from the national RCN registry (8M+ records).
|
|
77
90
|
Returns transaction details: address, date, price, area, price/m², property type.
|
|
78
|
-
|
|
91
|
+
Call list_locations(search=...) first to resolve a place: prefer the returned TERYT code as teryt= (exact administrative match). Pass a name to location= only when the result flags it as an RCN district (rcn_district) — most TERYT names are not valid location= values and silently return zero rows.
|
|
79
92
|
Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN.
|
|
80
93
|
Data notes: marketType is NULL for ~55% of records (notary didn't classify) - filtering by marketType excludes them. ~1.7% of records have no transaction_date.
|
|
81
94
|
Permalink: every result is shareable on the map. From a result's "id:" line and its "Location: <A>°N, <B>°E" line, build https://cenogram.pl/ceny-transakcyjne?src=${channelSrc()}#v=1&lat=<A>&lng=<B>&z=16&tx=<id> (drop the °N/°E; lat = the °N number, lng = the °E number) — opens that exact transaction on the map. Omit &tx=<id> for the area only.
|
|
82
95
|
Field provenance: values are from the notarial deed (RCN) by default; computed values (parcel area summed across plots or converted from hectares, an inferred/reclassified property type) and approximated streets are flagged inline with a neutral [...] note.
|
|
83
96
|
Location matches TERYT districts only - for neighborhoods (osiedla), use search_by_area instead.`, {
|
|
84
|
-
location: z.string().optional().describe("Location name - city (e.g. 'Warszawa', 'Kraków', 'Gdańsk') or district (e.g. 'Mokotów', 'Kraków-Podgórze'). 'Warszawa', 'Kraków', 'Łódź' auto-expand to all sub-districts.
|
|
97
|
+
location: z.string().optional().describe("Location name - city (e.g. 'Warszawa', 'Kraków', 'Gdańsk') or district (e.g. 'Mokotów', 'Kraków-Podgórze'). 'Warszawa', 'Kraków', 'Łódź' auto-expand to all sub-districts. Prefer teryt= for exact matches; call list_locations(search=...) to confirm a name is valid here — it flags valid ones as rcn_district."),
|
|
85
98
|
teryt: z.string().min(1).optional().describe("TERYT administrative code(s) for precise area filtering. Comma-separated, max 10. 2-digit (voivodeship), 4-digit (county), 6-digit (municipality), or full precinct code (e.g. '321705_2.0054'). Use list_locations to find codes. More precise than 'location' - avoids name ambiguity."),
|
|
86
99
|
propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional()
|
|
87
100
|
.describe("Property type filter"),
|
|
@@ -111,11 +124,15 @@ Location matches TERYT districts only - for neighborhoods (osiedla), use search_
|
|
|
111
124
|
maxPrice: z.number().optional().describe("Maximum price in PLN"),
|
|
112
125
|
dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"),
|
|
113
126
|
dateTo: z.string().optional().describe("End date (YYYY-MM-DD)"),
|
|
114
|
-
street: z.string().optional().describe("Street name filter
|
|
127
|
+
street: z.string().optional().describe("Street name filter, matched anywhere inside the name (e.g. 'Puławska', 'Trakt Lubelski'). Give it in the NOMINATIVE and with its Polish diacritics — matching is literal, so 'Karmelickiej' does not find 'Karmelicka' and 'Marszalkowska' does not find 'Marszałkowska'. Either mistake answers with nothing, which reads exactly like 'no such transactions'."),
|
|
115
128
|
buildingNumber: z.string().optional().describe("Building/house number (e.g. '251C', '12A'). Requires location or street to be set."),
|
|
116
129
|
parcelId: z.string().optional().describe("Exact parcel ID as returned in search results (e.g. '146518_8.0108.27'). Must match exactly - copy from a previous search result's parcel_id field."),
|
|
117
130
|
minArea: z.number().optional().describe("Minimum area in m²"),
|
|
118
131
|
maxArea: z.number().optional().describe("Maximum area in m²"),
|
|
132
|
+
landUse: landUseParam,
|
|
133
|
+
buildingStoreys: buildingStoreysParam,
|
|
134
|
+
minFootprintArea: minFootprintAreaParam,
|
|
135
|
+
maxFootprintArea: maxFootprintAreaParam,
|
|
119
136
|
limit: z.number().min(1).max(50).default(10)
|
|
120
137
|
.describe("Number of results (1-50, default 10)"),
|
|
121
138
|
sort: z.enum(["price", "date", "area", "pricePerM2", "district", "rooms", "floor"]).default("date")
|
|
@@ -163,6 +180,10 @@ Location matches TERYT districts only - for neighborhoods (osiedla), use search_
|
|
|
163
180
|
parcelId: params.parcelId,
|
|
164
181
|
minArea: params.minArea,
|
|
165
182
|
maxArea: params.maxArea,
|
|
183
|
+
landUse: params.landUse?.join(","),
|
|
184
|
+
buildingStoreys: params.buildingStoreys?.join(","),
|
|
185
|
+
minFootprintArea: params.minFootprintArea,
|
|
186
|
+
maxFootprintArea: params.maxFootprintArea,
|
|
166
187
|
limit: params.limit,
|
|
167
188
|
sort: params.sort,
|
|
168
189
|
order: params.order ?? "desc",
|
|
@@ -218,6 +239,7 @@ ${MARKET_CAVEAT}`, {
|
|
|
218
239
|
return textResponse(formatHistogram(bins) + formatCreditFooter(creditInfo));
|
|
219
240
|
}));
|
|
220
241
|
server.tool("search_by_area", `Search real estate transactions within a geographic radius.
|
|
242
|
+
Returns TRANSACTIONS — deeds and prices — despite the name. For the land plots themselves in an area, use list_parcels_in_area.
|
|
221
243
|
Best tool for neighborhood/osiedle searches (neighborhoods are not TERYT districts).
|
|
222
244
|
Radius guide: 0.3-0.5 km for a street, 0.5-1 km for a neighborhood, 2-5 km for a city area.
|
|
223
245
|
Example: apartments in Wrocław's Nowy Dwór (lat 51.143, lng 16.993, radiusKm=0.7).
|
|
@@ -260,6 +282,10 @@ Field provenance: values are from the notarial deed (RCN) by default; computed v
|
|
|
260
282
|
.describe("Heritage-listing filter. listed = a protected monument on/at the property's land; zone = the land lies within a protected urban layout or the designated surroundings of a monument. Selects ONLY transactions where a listing was detected; absence of a detection is never asserted as 'not listed'. Multi-select; e.g. ['listed'] = individually listed properties only."),
|
|
261
283
|
landslideRisk: z.array(z.enum(["landslide", "threatened"])).optional()
|
|
262
284
|
.describe("Landslide-hazard filter, from official landslide-hazard maps (1:10,000 scale). 'landslide' = the land intersects a mapped landslide area; 'threatened' = an area threatened by mass movements. Selects ONLY transactions whose land intersects a mapped hazard area — an intersection means overlap with a mapped area, not that the parcel itself is a landslide; absence of a zone is never asserted as 'safe'. Multi-select; e.g. ['landslide','threatened'] = any mapped hazard."),
|
|
285
|
+
landUse: landUseParam,
|
|
286
|
+
buildingStoreys: buildingStoreysParam,
|
|
287
|
+
minFootprintArea: minFootprintAreaParam,
|
|
288
|
+
maxFootprintArea: maxFootprintAreaParam,
|
|
263
289
|
limit: z.number().min(1).max(50).default(20)
|
|
264
290
|
.describe("Number of results (1-50, default 20)"),
|
|
265
291
|
}, { readOnlyHint: true, destructiveHint: false, title: "Search Transactions by Radius" }, async (params) => withErrorHandling("search_by_area", apiKey, async () => {
|
|
@@ -282,6 +308,10 @@ Field provenance: values are from the notarial deed (RCN) by default; computed v
|
|
|
282
308
|
maxPrice: params.maxPrice,
|
|
283
309
|
minArea: params.minArea,
|
|
284
310
|
maxArea: params.maxArea,
|
|
311
|
+
landUse: params.landUse?.join(","),
|
|
312
|
+
buildingStoreys: params.buildingStoreys?.join(","),
|
|
313
|
+
minFootprintArea: params.minFootprintArea,
|
|
314
|
+
maxFootprintArea: params.maxFootprintArea,
|
|
285
315
|
dateFrom: params.dateFrom,
|
|
286
316
|
dateTo: params.dateTo,
|
|
287
317
|
limit: params.limit,
|
|
@@ -306,11 +336,12 @@ ${MARKET_CAVEAT}`, {}, { readOnlyHint: true, destructiveHint: false, title: "Mar
|
|
|
306
336
|
1. TERYT hierarchy (parent param): Navigate voivodeship → county → municipality → precinct. Returns TERYT codes for use in search_transactions(teryt=...).
|
|
307
337
|
- No parent: 16 voivodeships (2-digit codes)
|
|
308
338
|
- 2-digit: counties (4-digit), 4-digit: municipalities (6-digit), 6-digit: precincts
|
|
309
|
-
2. Name search (search param):
|
|
339
|
+
2. Name search (search param): Look up a place by name across all levels (voivodeship, county, municipality, precinct). Each match comes with its TERYT code, its parent unit, and the exact follow-up calls to make — use teryt= for precise administrative filtering. Rows also flagged as RCN districts additionally accept the name in search_transactions(location=)/compare_locations. RCN district names that have no TERYT code are listed separately.
|
|
310
340
|
If both provided, parent takes precedence.
|
|
341
|
+
Returns administrative units — never streets. A street is not a level of this hierarchy and has no code of its own; to search for parcels on one, pass the name straight to list_parcels_in_area as street=.
|
|
311
342
|
Use 'location' for quick city searches, 'teryt' for precise administrative filtering (avoids name ambiguity, e.g. 'Wałcz' is both a county and a municipality).`, {
|
|
312
343
|
parent: z.string().min(1).optional().describe("TERYT parent code to browse children. 2-digit (voivodeship → counties), 4-digit (county → municipalities), 6-digit (municipality → precincts). Omit for all voivodeships."),
|
|
313
|
-
search: z.string().min(1).optional().describe("
|
|
344
|
+
search: z.string().min(1).optional().describe("Look up a place by name (case-insensitive, diacritics-insensitive partial match, e.g. 'wejher' for Wejherowo). Returns TERYT codes plus, where applicable, RCN district names. Ignored when parent is set."),
|
|
314
345
|
}, { readOnlyHint: true, destructiveHint: false, title: "List Locations & TERYT Codes" }, async (params) => withErrorHandling("list_locations", apiKey, async () => {
|
|
315
346
|
requireApiKey(apiKey);
|
|
316
347
|
if (params.parent !== undefined) {
|
|
@@ -326,38 +357,67 @@ Use 'location' for quick city searches, 'teryt' for precise administrative filte
|
|
|
326
357
|
const { data: locations, creditInfo } = await getLocations(undefined, apiKey);
|
|
327
358
|
return textResponse(formatLocationHierarchy(locations) + formatCreditFooter(creditInfo));
|
|
328
359
|
}
|
|
329
|
-
|
|
330
|
-
let
|
|
331
|
-
|
|
360
|
+
const query = params.search;
|
|
361
|
+
let rcnDistricts;
|
|
362
|
+
let rcnCreditInfo;
|
|
363
|
+
const city = tryResolveCityKey(query);
|
|
332
364
|
if (city) {
|
|
333
|
-
|
|
334
|
-
|
|
365
|
+
rcnDistricts = city;
|
|
366
|
+
rcnCreditInfo = null;
|
|
335
367
|
}
|
|
336
368
|
else {
|
|
337
369
|
const res = await getDistricts(apiKey);
|
|
338
|
-
|
|
339
|
-
|
|
370
|
+
rcnCreditInfo = res.creditInfo;
|
|
371
|
+
rcnDistricts = filterByLocation(query, res.data);
|
|
340
372
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
373
|
+
let terytItems = [];
|
|
374
|
+
let terytCreditInfo = null;
|
|
375
|
+
if (wordCharCount(query) >= TERYT_SEARCH_MIN_WORD_CHARS) {
|
|
376
|
+
try {
|
|
377
|
+
const res = await searchLocations(query, apiKey);
|
|
378
|
+
terytItems = res.data;
|
|
379
|
+
terytCreditInfo = res.creditInfo;
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
terytItems = [];
|
|
383
|
+
terytCreditInfo = null;
|
|
384
|
+
}
|
|
346
385
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
386
|
+
if (city && terytItems.length > 0) {
|
|
387
|
+
const cityNorm = stripDiacritics(query.trim().toLowerCase());
|
|
388
|
+
const countyRow = terytItems.find((it) => it.level === "county" && stripDiacritics(it.name.toLowerCase()) === cityNorm);
|
|
389
|
+
if (countyRow) {
|
|
390
|
+
try {
|
|
391
|
+
const { data: children } = await getLocations(countyRow.code, apiKey);
|
|
392
|
+
const rcnNorms = new Set(rcnDistricts.map((d) => stripDiacritics(d.toLowerCase())));
|
|
393
|
+
const present = new Set(terytItems.map((it) => stripDiacritics(it.name.toLowerCase())));
|
|
394
|
+
for (const child of children) {
|
|
395
|
+
const childNorm = stripDiacritics(child.name.toLowerCase());
|
|
396
|
+
if (rcnNorms.has(childNorm) && !present.has(childNorm)) {
|
|
397
|
+
terytItems.push({ ...child, rcn_district: true });
|
|
398
|
+
present.add(childNorm);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
}
|
|
404
|
+
}
|
|
351
405
|
}
|
|
352
|
-
|
|
353
|
-
|
|
406
|
+
const terytNames = new Set(terytItems.map((it) => stripDiacritics(it.name.toLowerCase())));
|
|
407
|
+
const rcnOnly = rcnDistricts.filter((d) => !terytNames.has(stripDiacritics(d.toLowerCase())));
|
|
408
|
+
const creditInfo = terytCreditInfo ?? rcnCreditInfo;
|
|
409
|
+
if (terytItems.length === 0 && rcnOnly.length === 0) {
|
|
410
|
+
return textResponse(`No locations found matching "${query}".` + formatCreditFooter(creditInfo));
|
|
354
411
|
}
|
|
355
|
-
return textResponse(
|
|
412
|
+
return textResponse(formatLocationSearch(terytItems, rcnOnly, query) + formatCreditFooter(creditInfo));
|
|
356
413
|
}));
|
|
357
414
|
server.tool("search_parcels", `Search for land parcels by parcel ID prefix (autocomplete).
|
|
415
|
+
Matches on the ID PREFIX only, never on an area: to list the parcels in a place or a shape use list_parcels_in_area, and to turn an address, a coordinate or 'locality + number' into one parcel use resolve_parcel.
|
|
358
416
|
Returns matching parcels with their district, area, and GPS coordinates.
|
|
359
417
|
Useful for finding exact parcel IDs, then searching transactions nearby.
|
|
360
|
-
Example: search for parcels starting with '146518_8.01'
|
|
418
|
+
Example: search for parcels starting with '146518_8.01'.
|
|
419
|
+
Coverage: this searches the cadastral register we hold — near-complete national coverage, though not the whole of it and not live — so an empty result more likely means a recent change or a mistyped prefix than that no such parcel exists. Do not report a missing match as "this parcel does not exist". The answer carries a corpus_coverage block with the measured figures for the county the prefix names, and resolve_parcel with the FULL id can still confirm and add a parcel we do not yet hold.
|
|
420
|
+
Free: searching for parcels costs no API tokens.`, {
|
|
361
421
|
q: z.string().min(3).describe("Parcel ID prefix to search for (min 3 chars). E.g. '146518_8.01'"),
|
|
362
422
|
limit: z.number().min(1).max(10).default(10).optional()
|
|
363
423
|
.describe("Max results (1-10, default 10)"),
|
|
@@ -368,12 +428,12 @@ Example: search for parcels starting with '146518_8.01'.`, {
|
|
|
368
428
|
}));
|
|
369
429
|
server.tool("resolve_parcel", `Resolve a land parcel to its cadastral identity using exactly ONE of:
|
|
370
430
|
- parcelId: a full cadastral id, either raw '/' form ('142907_2.0014.342/5') or URL-safe '-' form ('142907_2.0014.342-5'), or the internal UUID from search results.
|
|
371
|
-
- q: a full cadastral id, a UUID, OR free-text 'locality name + parcel number' (e.g. 'Sabnie 342/5').
|
|
431
|
+
- q: a full cadastral id, a UUID, OR free-text 'locality name + parcel number' (e.g. 'Sabnie 342/5'). The name may be a gmina name or a cadastral precinct (obręb) name; matching is exact and case-insensitive, so an unusual spelling may miss. A precinct name is not unique nationwide, so an ambiguous name comes back as several candidates rather than a guess. This is NOT a street address: it is a locality name plus a PARCEL number, never a street name plus a building number. For a city address (street + building number) use list_parcels_in_area(street=, buildingNumber=) instead — resolve_parcel will not turn 'Marszałkowska 12' into a parcel.
|
|
372
432
|
- lat & lng: a WGS84 point inside the parcel (returns the parcel(s) containing that point).
|
|
373
|
-
Returns a list of matching parcels with district, area, and coordinates; 'truncated' when the name+number match was capped. When nothing matches, coverage is not_covered and the
|
|
433
|
+
Returns a list of matching parcels with district, area, and coordinates; 'truncated' when the name+number match was capped. When nothing matches, coverage is not_covered — and what that means depends on the mode. With a FULL cadastral id we confirm the parcel live and add it if it exists, so not_covered there really does mean we could not confirm one. With the DISCOVERY modes (locality name + number, or a coordinate) we only look at the register we hold — near-complete national coverage, though not the whole of it and not live — so not_covered means "not among the parcels we hold", which is a weaker statement, and a fresh change or an unusual spelling is a likelier cause than the parcel not existing. Never report it as "no such parcel". The corpus_coverage block in each answer says how much of the register was actually searched. When the lookup could not be completed at all — a live confirmation that failed, or a name carried by more precincts than one search covers and nothing found among them — coverage is not_computed instead: that is not a statement that the parcel does not exist. Matches found before such a search ran out are returned normally, with 'truncated'.
|
|
374
434
|
Use this to turn an address point, a coordinate, or a locality+number into a concrete parcel id — then feed that id to search_transactions (parcelId) to see its sale history.
|
|
375
|
-
|
|
376
|
-
q: z.string().max(200).optional().describe("Full cadastral id, a UUID, or 'locality name + parcel number' (e.g. 'Sabnie 342/5'). Mutually exclusive with parcelId and lat/lng."),
|
|
435
|
+
Free: resolving a parcel costs no API tokens.`, {
|
|
436
|
+
q: z.string().max(200).optional().describe("Full cadastral id, a UUID, or 'locality name + parcel number' (e.g. 'Sabnie 342/5') — the name may be a gmina or a cadastral precinct (obręb). NOT a street address: for a street + building number use list_parcels_in_area instead. Mutually exclusive with parcelId and lat/lng."),
|
|
377
437
|
parcelId: z.string().max(200).optional().describe("Full cadastral id (slash or dash form) or internal UUID. Mutually exclusive with q and lat/lng."),
|
|
378
438
|
lat: z.number().min(-90).max(90).optional().describe("Latitude WGS84. Must be paired with lng. Mutually exclusive with q and parcelId."),
|
|
379
439
|
lng: z.number().min(-180).max(180).optional().describe("Longitude WGS84. Must be paired with lat. Mutually exclusive with q and parcelId."),
|
|
@@ -393,18 +453,182 @@ Costs 1 API token (refunded when nothing matches).`, {
|
|
|
393
453
|
const { data, creditInfo } = await resolveParcel({ q: params.q, parcelId: params.parcelId, lat: params.lat, lng: params.lng }, apiKey);
|
|
394
454
|
return textResponse(formatParcelResolve(data) + formatCreditFooter(creditInfo));
|
|
395
455
|
}));
|
|
396
|
-
server.tool("get_parcel_report", `The whole dossier for one land parcel in a single call: the parcel core (location, area, land use, plan designation), all
|
|
456
|
+
server.tool("get_parcel_report", `The whole dossier for one land parcel in a single call: the parcel core (location, area, land use, plan designation), all thirteen enrichment layers (flood risk, heritage listing, landslide risk, subsurface: mining terrains and major groundwater reservoirs, nuisance surroundings, public-transport access, general-plan zoning, buildings on the parcel, recent building activity, agricultural-land eligibility, official land-use & soil-quality classification with its re-designation consequences where the county publishes it, nature: nearby forest and protected areas, roads: geometric road-access evidence measured from carriageway centrelines, which is not a determination of legal access), the parcel's transaction history (newest first, up to 20), a local price context (median zł/m² for the county and the locality over the last 12 months) and a municipal context (a headline demographic/economic subset plus upcoming-infrastructure signals for the gmina).
|
|
397
457
|
Address it by a full cadastral id in the natural '/' form ('142907_2.0014.342/5'), the URL-safe '-' form, or the internal UUID from a search or resolve result.
|
|
398
458
|
Each section carries its own state, shown explicitly: covered = a definitive result; covered_no_data = the parcel was checked and nothing was found (still billed); not_covered = outside our data (refunded); not_computed = a live computation could not finish in time (refunded — the rest of the report still returns, so a report can be partial). The two context sections instead use full / low_sample / suppressed / no_data.
|
|
459
|
+
The buildings section additionally reports how many of the buildings could be given a construction-age estimate from building-permit records. That is an ESTIMATE with an interval, never a registry construction date, and the records only start in 2016, so for most buildings the answer is that the year could not be established — which is stated rather than omitted.
|
|
399
460
|
Prefer this over calling the per-layer parcel tools one by one — it is one call at a flat price and never costs more than the sum of its parts. Use resolve_parcel first when you only have an address, a coordinate, or a 'locality + number'.
|
|
400
|
-
Costs
|
|
461
|
+
Costs 45 API tokens. Billing is by outcome (see the billing line on the response): a parcel that cannot be resolved is fully refunded; a resolved parcel where no layer had data is billed only the core floor (1 token) with the rest refunded; a resolved parcel with at least one covered layer is billed in full.`, {
|
|
401
462
|
parcelId: z.string().min(3).max(200).describe("Full cadastral id ('142907_2.0014.342/5' or the '-' form) or the internal UUID from a search/resolve result."),
|
|
402
463
|
}, { readOnlyHint: true, destructiveHint: false, title: "Get Parcel Report" }, async (params) => withErrorHandling("get_parcel_report", apiKey, async () => {
|
|
403
464
|
requireApiKey(apiKey);
|
|
404
465
|
const { data, creditInfo } = await getParcelReport(params.parcelId, apiKey);
|
|
405
466
|
return textResponse(formatParcelReport(data) + formatCreditFooter(creditInfo));
|
|
406
467
|
}));
|
|
468
|
+
server.tool("get_parcel_land_class", `The official land-use and soil-quality classification recorded for one land parcel, and what it implies for taking the land out of agricultural use. Returns the land-use categories and the soil-quality grades entered for the parcel, whether any of those grades is in the protected I-III range, whether the parcel lies inside a city's administrative boundary (which changes the rule that applies), and a note on the re-designation consequences with the date the legal state behind it was verified.
|
|
469
|
+
Use it when the question is specifically about the classification or about re-designating farmland. For anything else about the parcel — price history, flood risk, zoning, buildings, permits, surroundings, transport — call get_parcel_report instead: it is one call at a flat price and includes this same classification as one of its sections.
|
|
470
|
+
Address it by a full cadastral id in the natural '/' form ('142907_2.0014.342/5'), the URL-safe '-' form, or the internal UUID from a search or resolve result.
|
|
471
|
+
The categories and grades come back as SETS. The source records no area for any of them, so the answer can never say which category prevails on the parcel or give a share — a parcel listing two categories has both, in unknown proportion.
|
|
472
|
+
This layer answers only where the county publishes the classification; many counties, including several large cities, do not. Four states, told apart explicitly: covered = the county publishes it and the parcel has an entry; covered_no_data = the county publishes it and this parcel has none (a checked negative — still billed); not_covered = the county does not publish it, or we do not hold the parcel (refunded); not_computed = the lookup could not finish in time (refunded — retry).
|
|
473
|
+
Costs 4 API tokens, refunded on not_covered and not_computed. Not legal advice, and never a statement that a parcel can or cannot be built on.`, {
|
|
474
|
+
parcelId: z.string().min(3).max(200).describe("Full cadastral id ('142907_2.0014.342/5' or the '-' form) or the internal UUID from a search/resolve result."),
|
|
475
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Get Parcel Land Classification" }, async (params) => withErrorHandling("get_parcel_land_class", apiKey, async () => {
|
|
476
|
+
requireApiKey(apiKey);
|
|
477
|
+
const { data, creditInfo } = await getParcelLandClass(params.parcelId, apiKey);
|
|
478
|
+
return textResponse(formatParcelLandClass(data) + formatCreditFooter(creditInfo));
|
|
479
|
+
}));
|
|
480
|
+
const PARCEL_AREA_MAX_KM2 = 500;
|
|
481
|
+
const PARCEL_RADIUS_MAX_KM = 12.6;
|
|
482
|
+
const ADDRESS_FILTER_MIN_TERYT_DIGITS = 4;
|
|
483
|
+
const STREET_MIN_WORD_CHARS = 3;
|
|
484
|
+
const TERYT_SEARCH_MIN_WORD_CHARS = 2;
|
|
485
|
+
const ADDRESS_FILTER_MAX_CHARS = 200;
|
|
486
|
+
function wordCharCount(s) {
|
|
487
|
+
return (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
|
|
488
|
+
}
|
|
489
|
+
function terytReachesCounty(teryt) {
|
|
490
|
+
const parts = teryt.split(",").map((p) => p.trim()).filter((p) => p !== "");
|
|
491
|
+
if (parts.length === 0)
|
|
492
|
+
return false;
|
|
493
|
+
return parts.every((p) => p.replace(/\D/g, "").length >= ADDRESS_FILTER_MIN_TERYT_DIGITS);
|
|
494
|
+
}
|
|
495
|
+
server.tool("list_parcels_in_area", `List cadastral parcels in an area — the land plots themselves, NOT transactions.
|
|
496
|
+
For deeds and prices in an area use search_by_area or search_by_polygon instead. To look up a parcel by an id prefix use search_parcels; to turn one address, coordinate or 'locality + number' into a parcel use resolve_parcel; for everything known about a single parcel use get_parcel_report.
|
|
497
|
+
|
|
498
|
+
Name the area in one of five ways — at least one is required:
|
|
499
|
+
- teryt: administrative code prefix at any level (2 digits = voivodeship, 4 = county, 6 = municipality, finer allowed); comma-separate several. Browse codes with list_locations.
|
|
500
|
+
- location: a county or city NAME, resolved to its code. A name shared by two counties comes back as an error listing both, never as a guess.
|
|
501
|
+
- bbox: "minLng,minLat,maxLng,maxLat" in WGS84.
|
|
502
|
+
- lat + lng + radiusKm: a circle (all three together).
|
|
503
|
+
- polygon: a GeoJSON Polygon, for a drawn shape. Pass it on its own.
|
|
504
|
+
A bbox and a circle cannot be combined — together they would mean the overlap of a rectangle and a circle, which is rarely the question.
|
|
505
|
+
|
|
506
|
+
Two shapes of answer, priced differently:
|
|
507
|
+
- Default: the LIGHT list — parcel id, district and centre point per row, with no outline and no surface. 2 API tokens. Pages by cursor: when more parcels match, the answer hands you an opaque cursor to pass back as cursor=.
|
|
508
|
+
- includeGeometry=true (needs a bbox), or a polygon: OUTLINES — the full GeoJSON polygon of each parcel. 5 API tokens, at most 100 parcels per call (50 by default), no paging.
|
|
509
|
+
|
|
510
|
+
Truncation is the normal case on outlines, not an edge case. An area the size of a city holds far more parcels than one call returns, so an outline answer is usually marked TRUNCATED and the parcels in it are an arbitrary subset — not the first, nearest or largest. Never report a truncated answer as the parcel list of an area; ask for a smaller area instead.
|
|
511
|
+
|
|
512
|
+
minArea / maxArea (m²) filter on the registered parcel surface without returning it. They need a narrow scope — a bbox, a circle, a location name, or a teryt of at least 4 digits (county level) — and are refused elsewhere with a message saying what to add. They are not available on the outline calls.
|
|
513
|
+
|
|
514
|
+
Every row of the light list carries the street address held for that parcel, whether or not you asked about one, and says where it came from: a street on record, or one we worked out for a parcel the record left without a street (shown with a note saying so). A building number is only ever on record, so a worked-out street never carries one. Most rural parcels have no street at all — for those the way in is resolve_parcel with the precinct name and the parcel number, not a street.
|
|
515
|
+
- street: matches the name case-insensitively anywhere inside it, ${STREET_MIN_WORD_CHARS} letters or digits minimum, ${ADDRESS_FILTER_MAX_CHARS} characters maximum. It is a NAME, not a pattern: % and _ match themselves. Both sources are searched at once and a parcel found in both appears once, attributed to the record. Matching is case- and accent-insensitive, so 'karmelicka' and 'Karmelicką' both find 'Karmelicka' — but it does NOT inflect: pass the name in the NOMINATIVE, because an inflected form like 'Karmelickiej' answers with an empty list. That empty page costs nothing (the tokens are refunded) and carries a suggestions block with close names to retry, so read the suggestions before you conclude anything about the data.
|
|
516
|
+
- buildingNumber: matches EXACTLY ('12A' does not find '12a') and needs street alongside it. RCN often records a compound or split number ('84/92'), so an exact '84' will not find '84/92' — when a plain number comes back empty on a street that exists, that page costs nothing (the tokens are refunded) and lists the numbers held on the street as suggestions to retry. Because a number is only ever on record, a call carrying one answers only with parcels whose street is on record.
|
|
517
|
+
Both need a narrow scope for the same reason minArea does — a bbox, a circle, a teryt of at least 4 digits, or a location name — and neither counts as naming the area: a common street name across the country is a national search, not a question. Neither is available on the outline calls.
|
|
518
|
+
|
|
519
|
+
Parcel identity is gated: parcel_id comes back for API-token / OAuth callers and for paid or active-trial accounts, and is withheld for everyone else while the location and the outline still come back.
|
|
520
|
+
|
|
521
|
+
Coverage, so you can allow for it — TWO SEPARATE GAPS:
|
|
522
|
+
1. We hold near-complete coverage of the cadastral register, though not the whole of it and not live: what we hold was measured county by county on a fixed date, so a freshly split, merged or renumbered parcel may not be in yet. This applies to EVERY entrance here, teryt and location included. A short list, and the absence of a truncation marker, mean only that nothing further matched what we hold as of that measurement — never that you have every parcel in the area. Each answer carries a corpus_coverage block: for teryt and location it gives the measured parcels-held and parcels-in-register figures for the counties you asked about; for bbox, circle and polygon those figures are null, because sizing an arbitrary shape needs county boundaries we do not have — the measurement still applies, we just cannot put a number on it for that shape.
|
|
523
|
+
2. Separately, the spatial entrances (bbox, circle, polygon) work off the stored outline, and a small share of the parcels we DO hold keep theirs in a coordinate system those queries cannot read — those are missing from spatial answers specifically. The teryt and location entrances never touch geometry and are unaffected BY THAT SECOND GAP; a parcel whose outline we do not hold shows up there with no location.
|
|
524
|
+
Neither gap is a reason to distrust what comes back: a returned parcel is a real parcel. They are a reason never to read an empty or short answer as evidence that the land is not there.
|
|
525
|
+
|
|
526
|
+
Limits: an area over ${PARCEL_AREA_MAX_KM2} km², a radius over ${PARCEL_RADIUS_MAX_KM} km (the same ground) or a polygon over 500 vertices is refused rather than scanned, and a query that outruns its time limit answers with an error asking you to narrow it.`, {
|
|
527
|
+
teryt: z.string().max(200).optional().describe("TERYT administrative code prefix (2/4/6 digits or finer), comma-separated for several. Wins over location when both are given."),
|
|
528
|
+
location: z.string().max(200).optional().describe("County, city, or district name, resolved to its TERYT code. A Warszawa district name (e.g. 'Mokotów') narrows to that district; another city's delegatura (e.g. 'Kraków-Podgórze') resolves to its parent county. An ambiguous name is an error listing the candidates."),
|
|
529
|
+
bbox: z.string().max(200).optional().describe(`Bounding box in WGS84 as "minLng,minLat,maxLng,maxLat", covering at most ${PARCEL_AREA_MAX_KM2} km². Required for includeGeometry=true.`),
|
|
530
|
+
lat: z.number().min(-90).max(90).optional().describe("Latitude of the circle centre (WGS84). Requires lng and radiusKm."),
|
|
531
|
+
lng: z.number().min(-180).max(180).optional().describe("Longitude of the circle centre (WGS84). Requires lat and radiusKm."),
|
|
532
|
+
radiusKm: z.number().positive().max(PARCEL_RADIUS_MAX_KM).optional().describe(`Circle radius in km (max ${PARCEL_RADIUS_MAX_KM} — the radius covering the ${PARCEL_AREA_MAX_KM2} km² ceiling). Requires lat and lng.`),
|
|
533
|
+
polygon: z.object({
|
|
534
|
+
type: z.literal("Polygon"),
|
|
535
|
+
coordinates: z.array(z.array(z.array(z.number()))).min(1),
|
|
536
|
+
}).refine((poly) => poly.coordinates.reduce((sum, ring) => sum + ring.length, 0) <= 500, { message: "polygon exceeds 500 total vertices (sum across all rings)" }).optional().describe("GeoJSON Polygon geometry. Coordinates: [longitude, latitude] pairs, first and last point identical, at most 500 vertices. Always returns outlines. Pass it instead of the other area parameters, not alongside them."),
|
|
537
|
+
minArea: z.number().positive().optional().describe("Minimum parcel surface in m². Needs a bbox, a circle, a location name, or a teryt of at least 4 digits. Not available with includeGeometry or a polygon."),
|
|
538
|
+
maxArea: z.number().positive().optional().describe("Maximum parcel surface in m². Same scope requirement as minArea."),
|
|
539
|
+
street: z.string().max(ADDRESS_FILTER_MAX_CHARS).optional().describe(`Street name, matched case-insensitively anywhere inside the name (min ${STREET_MIN_WORD_CHARS} letters or digits). A name, not a pattern — % and _ match themselves. NOMINATIVE and with Polish diacritics: 'Karmelickiej' and 'Marszalkowska' both answer empty. Needs a bbox, a circle, a location name, or a teryt of at least ${ADDRESS_FILTER_MIN_TERYT_DIGITS} digits. Not available with includeGeometry or a polygon.`),
|
|
540
|
+
buildingNumber: z.string().max(ADDRESS_FILTER_MAX_CHARS).optional().describe("Building number, matched exactly ('12A' does not find '12a'). RCN numbering is often compound ('84/92'), so an exact '84' misses '84/92' — if a plain number answers empty, drop it and read the numbers off the street's rows. Requires street, and answers only with parcels whose street is on record. Same scope requirement as street."),
|
|
541
|
+
includeGeometry: z.boolean().optional().describe("Return each parcel's full outline instead of the light row (5 tokens instead of 2, at most 100 parcels, no paging). Requires a bbox; with a polygon the outlines come back anyway."),
|
|
542
|
+
limit: z.number().min(1).max(1000).optional().describe("Max parcels returned. Light list: up to 1000, default 250. Outlines: up to 100, default 50 — a larger value is clamped there."),
|
|
543
|
+
cursor: z.string().max(500).optional().describe("Opaque cursor from the previous light-list answer, to get the next page. Pass it back unchanged; it is not available on outline calls."),
|
|
544
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "List Parcels in an Area" }, async (params) => withErrorHandling("list_parcels_in_area", apiKey, async () => {
|
|
545
|
+
requireApiKey(apiKey);
|
|
546
|
+
const hasTeryt = params.teryt != null && params.teryt !== "";
|
|
547
|
+
const hasLocation = params.location != null && params.location !== "";
|
|
548
|
+
const hasBbox = params.bbox != null && params.bbox !== "";
|
|
549
|
+
const circleParts = [params.lat, params.lng, params.radiusKm].filter((v) => v != null).length;
|
|
550
|
+
const hasPolygon = params.polygon != null;
|
|
551
|
+
const hasAreaFilter = params.minArea != null || params.maxArea != null;
|
|
552
|
+
const hasCursor = params.cursor != null && params.cursor !== "";
|
|
553
|
+
const street = params.street != null && params.street !== "" ? params.street : undefined;
|
|
554
|
+
const buildingNumber = params.buildingNumber != null && params.buildingNumber !== "" ? params.buildingNumber : undefined;
|
|
555
|
+
const hasAddressFilter = street !== undefined || buildingNumber !== undefined;
|
|
556
|
+
if (!hasTeryt && !hasLocation && !hasBbox && circleParts === 0 && !hasPolygon) {
|
|
557
|
+
return textResponse("Name the area first: pass teryt, location, bbox, lat+lng+radiusKm, or polygon. Listing every parcel in the country is not something this tool does.");
|
|
558
|
+
}
|
|
559
|
+
if (hasPolygon && (hasTeryt || hasLocation || hasBbox || circleParts > 0)) {
|
|
560
|
+
return textResponse("A polygon already describes the area. Pass it on its own, or drop it and use teryt / location / bbox / lat+lng+radiusKm instead.");
|
|
561
|
+
}
|
|
562
|
+
if (hasBbox && circleParts > 0) {
|
|
563
|
+
return textResponse("Pick one shape for the area: a bbox or a lat+lng+radiusKm circle. Passing both asks for the overlap of a rectangle and a circle.");
|
|
564
|
+
}
|
|
565
|
+
if (circleParts > 0 && circleParts < 3) {
|
|
566
|
+
return textResponse("lat, lng and radiusKm must be given together — a circle needs a centre and a radius.");
|
|
567
|
+
}
|
|
568
|
+
if (params.includeGeometry === false && hasPolygon) {
|
|
569
|
+
return textResponse("A polygon search always returns outlines. Drop includeGeometry=false, or use a bbox if you want the light list instead.");
|
|
570
|
+
}
|
|
571
|
+
const wantsOutlines = hasPolygon || params.includeGeometry === true;
|
|
572
|
+
if (params.includeGeometry === true && !hasBbox && !hasPolygon) {
|
|
573
|
+
return textResponse("Outlines are returned for a bbox or a polygon. With teryt, location or a circle, drop includeGeometry to get the light list, then ask for outlines with a bbox around the part you care about.");
|
|
574
|
+
}
|
|
575
|
+
if (wantsOutlines && hasAreaFilter) {
|
|
576
|
+
return textResponse("minArea/maxArea are not available on an outline call. Use them on the light list (no includeGeometry, no polygon) to find the parcels, then ask for outlines by bbox.");
|
|
577
|
+
}
|
|
578
|
+
if (wantsOutlines && hasCursor) {
|
|
579
|
+
return textResponse("An outline call has no paging, so there is no cursor to pass. Narrow the area instead — the cursor belongs to the light list.");
|
|
580
|
+
}
|
|
581
|
+
if (wantsOutlines && hasAddressFilter) {
|
|
582
|
+
return textResponse("street/buildingNumber are not available on an outline call. Use them on the light list (no includeGeometry, no polygon) to find the parcels, then ask for outlines by bbox.");
|
|
583
|
+
}
|
|
584
|
+
if (buildingNumber !== undefined && street === undefined) {
|
|
585
|
+
return textResponse("buildingNumber needs street alongside it, e.g. street=\"Karmelicka\", buildingNumber=\"10\". A number on its own would have to be looked up parcel by parcel across the whole area.");
|
|
586
|
+
}
|
|
587
|
+
if (hasAddressFilter) {
|
|
588
|
+
const scopeIsNarrow = hasBbox
|
|
589
|
+
|| circleParts === 3
|
|
590
|
+
|| (hasTeryt ? terytReachesCounty(params.teryt) : hasLocation);
|
|
591
|
+
if (!scopeIsNarrow) {
|
|
592
|
+
return textResponse(`A street name needs a narrower area alongside it: a bbox, a lat+lng+radiusKm circle, a location name, or a teryt of at least ${ADDRESS_FILTER_MIN_TERYT_DIGITS} digits (county level). The same street name occurs in hundreds of towns, so on its own it asks for a search of the whole country.`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (street !== undefined && wordCharCount(street) < STREET_MIN_WORD_CHARS) {
|
|
596
|
+
return textResponse(`street needs at least ${STREET_MIN_WORD_CHARS} letters or digits — punctuation alone cannot be looked up, and a shorter fragment would have to read every street name we hold. Pass more of the name.`);
|
|
597
|
+
}
|
|
598
|
+
if (hasPolygon) {
|
|
599
|
+
const { data, creditInfo } = await searchParcelsByPolygon(params.polygon, params.limit, apiKey);
|
|
600
|
+
return textResponse(formatParcelFeatures(data, "the polygon") + formatCreditFooter(creditInfo));
|
|
601
|
+
}
|
|
602
|
+
if (wantsOutlines) {
|
|
603
|
+
const { data, creditInfo } = await getParcelsMap(params.bbox, params.limit, apiKey);
|
|
604
|
+
return textResponse(formatParcelFeatures(data, "the bounding box") + formatCreditFooter(creditInfo));
|
|
605
|
+
}
|
|
606
|
+
const { data, creditInfo } = await listParcels({
|
|
607
|
+
teryt: params.teryt,
|
|
608
|
+
location: params.location,
|
|
609
|
+
bbox: params.bbox,
|
|
610
|
+
lat: params.lat,
|
|
611
|
+
lng: params.lng,
|
|
612
|
+
radiusKm: params.radiusKm,
|
|
613
|
+
minArea: params.minArea,
|
|
614
|
+
maxArea: params.maxArea,
|
|
615
|
+
street,
|
|
616
|
+
buildingNumber,
|
|
617
|
+
limit: params.limit,
|
|
618
|
+
cursor: params.cursor,
|
|
619
|
+
}, apiKey);
|
|
620
|
+
const area = hasTeryt ? `teryt ${sanitizeInput(params.teryt)}`
|
|
621
|
+
: hasLocation ? `"${sanitizeInput(params.location)}"`
|
|
622
|
+
: hasBbox ? "the bounding box"
|
|
623
|
+
: `a ${params.radiusKm} km circle`;
|
|
624
|
+
const address = street === undefined ? ""
|
|
625
|
+
: buildingNumber === undefined
|
|
626
|
+
? `, street "${sanitizeInput(street, ADDRESS_FILTER_MAX_CHARS)}"`
|
|
627
|
+
: `, street "${sanitizeInput(street, ADDRESS_FILTER_MAX_CHARS)}" no. ${sanitizeInput(buildingNumber, ADDRESS_FILTER_MAX_CHARS)}`;
|
|
628
|
+
return textResponse(formatParcelList(data, area + address, (creditInfo?.refunded ?? 0) > 0) + formatCreditFooter(creditInfo));
|
|
629
|
+
}));
|
|
407
630
|
server.tool("search_by_polygon", `Search real estate transactions within a geographic polygon.
|
|
631
|
+
Returns TRANSACTIONS — deeds and prices — despite the name. For the land plots themselves inside a drawn shape, pass the same polygon to list_parcels_in_area.
|
|
408
632
|
Provide a GeoJSON Polygon geometry to search within a custom area.
|
|
409
633
|
Returns transactions found inside the polygon with coordinates.
|
|
410
634
|
Use for precise neighborhood/osiedle boundaries. Can estimate coordinates from search_by_area results. For quick searches, start with search_by_area instead.
|
|
@@ -442,8 +666,12 @@ Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21
|
|
|
442
666
|
maxArea: z.number().optional().describe("Maximum area in m²"),
|
|
443
667
|
district: z.string().optional().describe("District name filter"),
|
|
444
668
|
street: z.string().optional().describe("Street name filter (partial match)"),
|
|
445
|
-
|
|
446
|
-
|
|
669
|
+
landUse: landUseParam,
|
|
670
|
+
buildingStoreys: buildingStoreysParam,
|
|
671
|
+
minFootprintArea: minFootprintAreaParam,
|
|
672
|
+
maxFootprintArea: maxFootprintAreaParam,
|
|
673
|
+
limit: z.number().min(1).max(3000).default(100).optional()
|
|
674
|
+
.describe("Max results (1-3000, default 100). MCP displays up to 50 transactions."),
|
|
447
675
|
}, { readOnlyHint: true, destructiveHint: false, title: "Search Transactions by Polygon" }, async (params) => withErrorHandling("search_by_polygon", apiKey, async () => {
|
|
448
676
|
requireApiKey(apiKey);
|
|
449
677
|
const { data, creditInfo } = await searchByPolygon({
|
|
@@ -463,6 +691,10 @@ Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21
|
|
|
463
691
|
dateTo: params.dateTo,
|
|
464
692
|
minArea: params.minArea,
|
|
465
693
|
maxArea: params.maxArea,
|
|
694
|
+
landUse: params.landUse?.join(","),
|
|
695
|
+
buildingStoreys: params.buildingStoreys?.join(","),
|
|
696
|
+
minFootprintArea: params.minFootprintArea,
|
|
697
|
+
maxFootprintArea: params.maxFootprintArea,
|
|
466
698
|
district: params.district,
|
|
467
699
|
street: params.street,
|
|
468
700
|
limit: params.limit,
|
|
@@ -471,7 +703,7 @@ Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21
|
|
|
471
703
|
}));
|
|
472
704
|
server.tool("compare_locations", `Compare real estate statistics across multiple locations side-by-side.
|
|
473
705
|
Provide 2-5 district names to compare median price/m², average area, and transaction counts.
|
|
474
|
-
|
|
706
|
+
This tool matches on name only. Call list_locations(search=...) first: use names it flags as RCN districts (rcn_district) — other names (most TERYT unit names) silently return no data here.
|
|
475
707
|
Requires at least one filter besides districts (e.g., propertyType).
|
|
476
708
|
Example: compare Mokotów, Wola, Ursynów for apartments.
|
|
477
709
|
${MARKET_CAVEAT}`, {
|
|
@@ -559,8 +791,8 @@ ${MARKET_CAVEAT}`, {
|
|
|
559
791
|
server.tool("get_demographics", `Demographic, economic, housing and other local statistics for a Polish location, from GUS BDL (Bank Danych Lokalnych) — Poland's public Central Statistical Office open-data bank. ~50 indicators across 11 categories (population, economy, housing, spatial planning, infrastructure, environment, safety, education, prices) plus a few derived metrics.
|
|
560
792
|
Address by location (city/county name) OR teryt. A name resolves to county/powiat (4-digit) level; for richer gmina/district-level data (L6) pass a 6 or 7-digit teryt. teryt wins when both are given. Use list_locations to find TERYT codes — neighborhoods/osiedla are NOT addressable here.
|
|
561
793
|
A query returns the requested level PLUS all parent levels (a gmina query also yields powiat, NUTS3 region and voivodeship indicators). Optional year, or yearFrom+yearTo for a time series, and category to filter. Cost: 1 token.`, {
|
|
562
|
-
location: z.string().optional().describe("City
|
|
563
|
-
teryt: z.string().optional().describe("TERYT code: 2-digit (voivodeship, e.g. 14), 4-digit (county, e.g. 1465), 6 or 7-digit (gmina, e.g. 1465011). Wins over location. Use list_locations to find codes."),
|
|
794
|
+
location: z.string().optional().describe("City, county, or district name (e.g. 'Warszawa', 'Kraków'). A city/county name resolves to county/powiat level; a Warszawa district name (e.g. 'Mokotów') resolves to that district, another city's delegatura (e.g. 'Kraków-Podgórze') to its parent county. Use this OR teryt. For gmina-level data on other units pass a 6/7-digit teryt instead."),
|
|
795
|
+
teryt: z.string().optional().describe("TERYT code: 2-digit (voivodeship, e.g. 14), 4-digit (county, e.g. 1465), 6 or 7-digit (gmina, e.g. 1465011). The 7th digit selects the unit type: 3 = urban-rural gmina overall, 4/5 = urban/rural part only, 8 = Warszawa district (1465011 = all of Warszawa, 1465108 = Śródmieście). Wins over location. Use list_locations to find codes."),
|
|
564
796
|
year: z.number().int().optional().describe("Single year (2003-present). Mutually exclusive with yearFrom/yearTo. Omit for the latest available year per indicator."),
|
|
565
797
|
yearFrom: z.number().int().optional().describe("Start year for a time series (min 2003)."),
|
|
566
798
|
yearTo: z.number().int().optional().describe("End year for a time series (max current year + 1)."),
|
|
@@ -590,7 +822,7 @@ A query returns the requested level PLUS all parent levels (a gmina query also y
|
|
|
590
822
|
Address by location (city/county name → aggregates every municipality in that county) OR teryt (6-7 digits = one municipality, 4 digits = a county aggregate). teryt wins when both are given. Use list_locations to find codes.
|
|
591
823
|
Known limits, state them when you report results: the bulletin carries only contracts BELOW the EU procurement thresholds (from 2021), so the largest investments are not visible here. A tender is attributed to the SEAT of the contracting authority, not to the works location — county and national authorities tender works in other municipalities. The category counters therefore include municipal authorities only, while the recent-notice list shows every authority with a flag. Absence of tenders is NOT evidence that a municipality is not investing.
|
|
592
824
|
Cost: 1 token.`, {
|
|
593
|
-
location: z.string().optional().describe("City or
|
|
825
|
+
location: z.string().optional().describe("City, county, or district name (e.g. 'Warszawa', 'Krotoszyn'). A city/county name aggregates every municipality in the county; a Warszawa district name (e.g. 'Mokotów') narrows to that gmina, another city's delegatura (e.g. 'Kraków-Podgórze') resolves to its parent county. Use this OR teryt."),
|
|
594
826
|
teryt: z.string().optional().describe("TERYT code: 6 or 7 digits = one municipality (e.g. 146501), 4 digits = a county aggregate (e.g. 1465). Wins over location."),
|
|
595
827
|
}, { readOnlyHint: true, destructiveHint: false, title: "Infrastructure Signals" }, async (params) => withErrorHandling("get_infrastructure_signals", apiKey, async () => {
|
|
596
828
|
requireApiKey(apiKey);
|
|
@@ -650,7 +882,7 @@ Not comparable across cities with different as_of dates (RCN publication lag var
|
|
|
650
882
|
Coverage is county-level only (miasta na prawach powiatu) plus Warszawa's 18 districts, and further limited to cities with asking-rent data. A town inside a larger powiat (e.g. Sandomierz, Pruszków), a non-Warszawa city district, or an osiedle does NOT resolve and returns a 404 — do not pass such names. Unless the location is a major city you already know is covered, call list_rental_yield_locations FIRST to get valid names, or pass a 4-digit county TERYT.
|
|
651
883
|
Optional areaBucket restricts both sides to an apartment area range in m2 (e.g. '40-50'). Area ranges are NOT additive — a bucket does not sum back to 'all'.
|
|
652
884
|
The transaction-price denominator uses the market median. ${MARKET_CAVEAT}`, {
|
|
653
|
-
location: z.string().optional().describe("County-level city name —
|
|
885
|
+
location: z.string().optional().describe("County-level city name — a miasto na prawach powiatu or a catalog entry from list_rental_yield_locations (e.g. 'Warszawa', 'Kraków', 'Gdańsk'). A Warszawa district name (e.g. 'Mokotów') narrows to that district; another city's delegatura (e.g. 'Kraków-Podgórze') resolves to its parent county. A town within a larger powiat or an osiedle will 404 — check the catalog or use teryt first. Use this OR teryt."),
|
|
654
886
|
teryt: z.string().optional().describe("TERYT code. 4 digits = county (e.g. 1465 = Warszawa). 6 digits = dzielnica where available (today: Warszawa's 18 districts, e.g. 146510 = Śródmieście) → yield for that district. Other longer codes (gmina/precinct) are truncated to the county. Wins over location when both are provided."),
|
|
655
887
|
areaBucket: z.enum(["all", "0-30", "30-40", "40-50", "50-60", "60-80", "80+"]).optional().describe("Apartment area range in m2: all (default, whole stock), 0-30, 30-40, 40-50, 50-60, 60-80, 80+. Bucket values are not additive."),
|
|
656
888
|
}, { readOnlyHint: true, destructiveHint: false, title: "[Beta] Rental Yield Estimate" }, async (params) => withErrorHandling("get_rental_yield", apiKey, async () => {
|
|
@@ -680,7 +912,7 @@ Not comparable across cities with different as_of dates (RCN publication lag var
|
|
|
680
912
|
Coverage is county-level only (miasta na prawach powiatu) plus Warszawa's 18 districts, and further limited to cities with asking-sale data. A town inside a larger powiat (e.g. Sandomierz, Pruszków), a non-Warszawa city district, or an osiedle does NOT resolve and returns a 404 — do not pass such names. Unless the location is a major city you already know is covered, call list_price_spread_locations FIRST to get valid names, or pass a 4-digit county TERYT.
|
|
681
913
|
Optional areaBucket restricts both sides to an apartment area range in m2 (e.g. '40-50'). Area ranges are NOT additive — a bucket does not sum back to 'all'.
|
|
682
914
|
The transaction-price denominator uses the market median. ${MARKET_CAVEAT}`, {
|
|
683
|
-
location: z.string().optional().describe("County-level city name —
|
|
915
|
+
location: z.string().optional().describe("County-level city name — a miasto na prawach powiatu or a catalog entry from list_price_spread_locations (e.g. 'Warszawa', 'Kraków', 'Gdańsk'). A Warszawa district name (e.g. 'Mokotów') narrows to that district; another city's delegatura (e.g. 'Kraków-Podgórze') resolves to its parent county. A town within a larger powiat or an osiedle will 404 — check the catalog or use teryt first. Use this OR teryt."),
|
|
684
916
|
teryt: z.string().optional().describe("TERYT code. 4 digits = county (e.g. 1465 = Warszawa). 6 digits = dzielnica where available (today: Warszawa's 18 districts, e.g. 146510 = Śródmieście) → spread for that district. Other longer codes (gmina/precinct) are truncated to the county. Wins over location when both are provided."),
|
|
685
917
|
marketType: z.enum(["primary", "secondary", "all"]).optional().describe("Transaction denominator segment: 'all' (default, composition-matched to mixed sale offers), 'secondary', or 'primary'."),
|
|
686
918
|
areaBucket: z.enum(["all", "0-30", "30-40", "40-50", "50-60", "60-80", "80+"]).optional().describe("Apartment area range in m2: all (default, whole stock), 0-30, 30-40, 40-50, 50-60, 60-80, 80+. Bucket values are not additive."),
|
|
@@ -702,9 +934,35 @@ Optional search filters by city name (diacritic-insensitive substring, min 2 cha
|
|
|
702
934
|
const { data, creditInfo } = await getPriceSpreadLocations({ search: params.search }, apiKey);
|
|
703
935
|
return textResponse(formatPriceSpreadLocations(data) + formatCreditFooter(creditInfo));
|
|
704
936
|
}));
|
|
937
|
+
server.tool("get_flood_risk", `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it.
|
|
938
|
+
Report what share of a Polish county's real-estate transactions sit on land in a mapped flood-hazard zone, broken down by severity, from the RCN registry.
|
|
939
|
+
Severity bands are return periods: high = most frequent flooding (~1-in-10-year), medium (~1-in-100-year), low = rarest (~1-in-500-year). Counts land in a mapped hazard zone only; absence of a zone is never asserted as 'safe' (an area may be unmapped).
|
|
940
|
+
Address by location (city/county name → resolves to a county) OR teryt (4-digit county code; longer codes truncate to the county; a district code resolves to its county — exposure is reported at county level; teryt wins when both are given).
|
|
941
|
+
Aggregated over the whole transaction history (all-time, no date window). Suppressed below 5 assessed transactions. Coverage is county-level. Unless the location is a major city you already know is covered, call list_flood_risk_locations FIRST to get valid names, or pass a 4-digit county TERYT.`, {
|
|
942
|
+
location: z.string().optional().describe("County-level city/county name (e.g. 'Warszawa', 'Kraków', 'Gdańsk'). District names are accepted (a Warszawa district or another city's delegatura, e.g. 'Kraków-Podgórze'), but coverage is county-level, so the answer is the parent county with a note. A town within a larger powiat or an osiedle may 404 — check the catalog or use teryt first. Use this OR teryt."),
|
|
943
|
+
teryt: z.string().optional().describe("TERYT code. 4 digits = county (e.g. 1465 = Warszawa). Longer codes (gmina/precinct/district) are truncated/resolved to the county. Wins over location when both are provided."),
|
|
944
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "[Beta] Flood-Hazard Exposure Share" }, async (params) => withErrorHandling("get_flood_risk", apiKey, async () => {
|
|
945
|
+
requireApiKey(apiKey);
|
|
946
|
+
if (!params.location?.trim() && !params.teryt?.trim()) {
|
|
947
|
+
return textResponse('Provide a location (city/county name) or teryt (county code). Example: get_flood_risk(location="Warszawa").');
|
|
948
|
+
}
|
|
949
|
+
const { data, creditInfo } = await getFloodRisk({ location: params.location, teryt: params.teryt }, apiKey);
|
|
950
|
+
return textResponse(formatFloodRisk(data) + formatCreditFooter(creditInfo));
|
|
951
|
+
}));
|
|
952
|
+
server.tool("list_flood_risk_locations", `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it.
|
|
953
|
+
List the counties for which get_flood_risk can return data (i.e. where transactions have a completed flood assessment). Use this to discover valid location/teryt values for get_flood_risk instead of guessing names.
|
|
954
|
+
Each entry is coverage signal only (assessed sample size + confidence) — it does not compute the share; call get_flood_risk(location|teryt) for the actual exposure.
|
|
955
|
+
Optional search filters by county name (diacritic-insensitive substring, min 2 chars). Results are sorted by assessed_sample_n descending. Free (0 credits).`, {
|
|
956
|
+
search: z.string().min(2, "search must be at least 2 characters").optional().describe("Filter by county name (diacritic-insensitive substring, min 2 chars). E.g. 'gda' → Gdańsk. Omit to list the full catalog."),
|
|
957
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "[Beta] List Flood-Risk Locations" }, async (params) => withErrorHandling("list_flood_risk_locations", apiKey, async () => {
|
|
958
|
+
requireApiKey(apiKey);
|
|
959
|
+
const { data, creditInfo } = await getFloodRiskLocations({ search: params.search }, apiKey);
|
|
960
|
+
return textResponse(formatFloodRiskLocations(data) + formatCreditFooter(creditInfo));
|
|
961
|
+
}));
|
|
705
962
|
}
|
|
706
963
|
server.tool("get_building_breakdown", `Get the building-by-building breakdown for one transaction: footprint area, number of storeys, and estimated total floor area (footprint × storeys) for each building on the property.
|
|
707
964
|
search_transactions / search_by_area / search_by_polygon return per-transaction building SUMS inline; this tool splits them into individual buildings. Use it after a search when a result has building data and you need the detail (e.g. a developed-land deed covering several buildings).
|
|
965
|
+
Each building also carries a construction-age estimate derived from building-permit records. It is an ESTIMATE with an interval, never a registry construction date, and the records only start in 2016 — so for most buildings the honest answer is "construction year not established", which is stated explicitly rather than left out.
|
|
708
966
|
The transaction_id is the id shown on a search result that has building data. Cost: 4 tokens. Returns nothing for a transaction with no buildings.`, {
|
|
709
967
|
transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result that has building data").describe("Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result that carries building data."),
|
|
710
968
|
}, { readOnlyHint: true, destructiveHint: false, title: "Building-by-Building Breakdown" }, async (params) => withErrorHandling("get_building_breakdown", apiKey, async () => {
|
|
@@ -740,8 +998,29 @@ TWO-STATE: a transaction whose land is in no mapped zone returns nothing — abs
|
|
|
740
998
|
const { data: res, creditInfo } = await getTransactionLandslide(params.transaction_id, apiKey);
|
|
741
999
|
return textResponse(formatLandslideBreakdown(res) + formatCreditFooter(creditInfo));
|
|
742
1000
|
}));
|
|
743
|
-
server.tool("
|
|
744
|
-
|
|
1001
|
+
server.tool("get_transaction_subsurface", `Get the parcel-by-parcel subsurface breakdown for one transaction across two dimensions: mining terrains and major groundwater reservoirs. For each linked plot that overlaps either — the mining-terrain status ('active' | 'former') with its mineral class ('subsidence' = extraction with surface deformation, the actual mining-damage risk; 'surface' = open-pit working, mostly local impact; 'fluid' = borehole extraction; 'other'), the groundwater-reservoir status ('documented' | 'undocumented'), the share of the plot inside each, and the per-object lists (mining terrain: name, oversight authority, validity dates; reservoir: number, name, documentation).
|
|
1002
|
+
A mining terrain is a legally defined zone of anticipated mining influence; its mapped location is approximate — an intersection is an advisory signal to verify with the competent mining-supervision authority, not a legal determination. A groundwater reservoir's extent alone imposes NO restriction; a restriction would come only from an established protection zone, which is not published here.
|
|
1003
|
+
Use it for a specific transaction to see whether its land overlaps a mining terrain or a major groundwater reservoir, and the per-object detail. get_parcel_report includes a one-line subsurface summary per parcel.
|
|
1004
|
+
TWO-STATE: a transaction whose land overlaps neither layer returns nothing — absence of data is never an assertion of safety. Cost: 4 tokens (refunded when there is no subsurface data).`, {
|
|
1005
|
+
transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe("Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result."),
|
|
1006
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Subsurface Breakdown" }, async (params) => withErrorHandling("get_transaction_subsurface", apiKey, async () => {
|
|
1007
|
+
requireApiKey(apiKey);
|
|
1008
|
+
const { data: res, creditInfo } = await getTransactionSubsurface(params.transaction_id, apiKey);
|
|
1009
|
+
return textResponse(formatSubsurfaceBreakdown(res) + formatCreditFooter(creditInfo));
|
|
1010
|
+
}));
|
|
1011
|
+
server.tool("get_transaction_roads", `Get the plot-by-plot road-access evidence for one transaction: for each linked plot, the distance in meters to the nearest public road, to the nearest road of any kind, and to the nearest motorway/expressway/dual-carriageway — plus, for the nearest public road, the estimated distance to the EDGE of its carriageway, its management category ('national' | 'voivodeship' | 'county' | 'municipal'), its functional class ('motorway' | 'expressway' | 'main_accelerated' | 'main' | 'collector' | 'local' | 'access' | 'other') and whether it runs at ground level (false = it crosses on a viaduct or in a tunnel, so it passes the plot over or under it). Each plot also carries access_indicator ('likely' | 'uncertain' | 'unlikely') and the version of the rule that produced it.
|
|
1012
|
+
access_indicator is GEOMETRIC EVIDENCE measured from carriageway centrelines in reference road-network data. It does NOT determine legal access and says nothing about easements or rights of way, which are recorded in the land register and are not published here — treat it as a lead to verify, never as a conclusion. That is why it has three states and is never a yes/no.
|
|
1013
|
+
Each measurement has a fixed radius: public road 500 m, road of any kind 500 m, motorway/expressway/dual-carriageway 3 km (that last one is a traffic-nuisance proxy, not an access signal). public_road_edge_distance_m is null when the source carries no carriageway width — no median is substituted.
|
|
1014
|
+
Use it for a specific transaction to judge how its land sits relative to the road network. get_parcel_report includes a one-line road-access summary per parcel.
|
|
1015
|
+
TWO-STATE: a null/absent distance means no such road within the search radius in the reference data — it is NEVER a guarantee that none exists. assessed=false means the plot has not been evaluated yet (no statement either way). Cost: 4 tokens (refunded when there is no informative data — no linked plots, or none evaluated yet).`, {
|
|
1016
|
+
transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe("Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result."),
|
|
1017
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Road Access" }, async (params) => withErrorHandling("get_transaction_roads", apiKey, async () => {
|
|
1018
|
+
requireApiKey(apiKey);
|
|
1019
|
+
const { data: res, creditInfo } = await getTransactionRoads(params.transaction_id, apiKey);
|
|
1020
|
+
return textResponse(formatRoads(res) + formatCreditFooter(creditInfo));
|
|
1021
|
+
}));
|
|
1022
|
+
server.tool("get_transaction_surroundings", `Get the plot-by-plot surroundings profile for one transaction: for each linked plot, the distance in meters to the nearest cemetery, landfill (waste disposal site), sewage treatment plant, industrial/storage area, large industrial plant, intensive livestock farm, high-voltage overhead power line and extra-high-voltage overhead power line, from reference land-use and environmental-registry data. Useful for due-diligence on nearby nuisances.
|
|
1023
|
+
Distances are approximate and measured from the plot boundary; 0 means the plot touches or overlaps such an area. Each category is searched within a fixed radius only: cemetery 1 km, landfill 3 km, sewage treatment 2 km, industrial/storage 1 km, large industrial plant 3 km, intensive livestock farm 3 km, high-voltage overhead power line 1 km, extra-high-voltage overhead power line 1 km. Only overhead high- and extra-high-voltage lines are covered — medium- and low-voltage lines are ubiquitous and carry no signal, and no easement corridor width or substation is published here.
|
|
745
1024
|
TWO-STATE: a null/absent distance means no such object within the search radius in the reference data — it is NEVER a guarantee that none exists. assessed=false means the plot has not been evaluated yet (no statement either way). Cost: 4 tokens (refunded when there is no informative data — no linked plots, or none evaluated yet).`, {
|
|
746
1025
|
transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe("Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result."),
|
|
747
1026
|
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Surroundings Breakdown" }, async (params) => withErrorHandling("get_transaction_surroundings", apiKey, async () => {
|
|
@@ -758,8 +1037,8 @@ TWO-STATE: a transaction whose land has no stop within cap in any mode returns n
|
|
|
758
1037
|
const { data: res, creditInfo } = await getTransactionTransit(params.transaction_id, apiKey);
|
|
759
1038
|
return textResponse(formatTransitBreakdown(res) + formatCreditFooter(creditInfo));
|
|
760
1039
|
}));
|
|
761
|
-
server.tool("get_transaction_permits", `Get the building-permit history for one transaction's parcels, from the official national registry of
|
|
762
|
-
Use it after a search to screen what has been built or approved on the transaction's land — a leading indicator of development activity. Match is by the parcel's current identifier, so splits/merges break the link,
|
|
1040
|
+
server.tool("get_transaction_permits", `Get the building-permit history for one transaction's parcels, from the official national registry of building permits and works notifications (records since 2016): for each case — its kind (permit / notification), the building intent and works type, the statutory object category, the deciding authority, the decision or intake date, the investment address, and the volume.
|
|
1041
|
+
Use it after a search to screen what has been built or approved on the transaction's land — a leading indicator of development activity. Match is by the parcel's current identifier, so splits/merges break the link. Permits are held once a decision has been issued; the outcome of that decision, granted or refused, is not part of the data held here. Notifications are held only where they were accepted without objection. Cases still pending are not held at all.
|
|
763
1042
|
TWO-STATE: a transaction whose parcels have no registered case returns nothing — an empty result is never a confirmation that nothing was ever planned. Cost: 4 tokens (refunded when there is no record).`, {
|
|
764
1043
|
transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe("Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result."),
|
|
765
1044
|
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Building-Permit History" }, async (params) => withErrorHandling("get_transaction_permits", apiKey, async () => {
|
|
@@ -784,4 +1063,14 @@ TWO-STATE: a parcel with no matched eligible area returns nothing — absence of
|
|
|
784
1063
|
const { data: res, creditInfo } = await getTransactionFarmland(params.transaction_id, apiKey);
|
|
785
1064
|
return textResponse(formatFarmland(res) + formatCreditFooter(creditInfo));
|
|
786
1065
|
}));
|
|
1066
|
+
server.tool("get_transaction_nature", `Get the parcel-by-parcel nature breakdown for one transaction: for each linked plot with a nature signal — the nearest forest within 2 km (forest_distance_m in metres, 0 = the plot overlaps forest, with its overlap share) and any overlapping protected natural areas: the sharpest form (protection_rank 1 = national park, 2 = nature reserve, 3 = Natura 2000, 4 = landscape park, 5 = protected landscape, 6 = other), building_restriction ('statutory_ban' = a build ban that follows directly from the Nature Protection Act for national parks and reserves, 'conditional' = restrictions depend on the act that established the area), the share of the plot under protection, and the named areas.
|
|
1067
|
+
This describes the SOURCE of a restriction (statute vs the establishing act), never the outcome of a specific permitting case, and is not legal advice. Forest is an amenity signal (proximity), protected areas a due-diligence one (build limits).
|
|
1068
|
+
Search results do not carry a nature signal, so call this tool directly on a transaction id whenever forest proximity or protected-area build limits matter. Use it after a search on land plots.
|
|
1069
|
+
An empty result is NEVER a statement that building is allowed — this layer does not cover local zoning plans, planning-permission decisions or areas under designation. A buffer zone around a park or reserve IS reported, as form 'buffer_zone' at rank 6, and never as a statutory ban. An empty result also says which kind of empty it is: either the plots were checked and carry no signal, or no nature reference data is held for them yet and nothing was checked — the second is never a finding that there is no forest or protected area. Cost: 4 tokens (refunded on any empty result).`, {
|
|
1070
|
+
transaction_id: z.string().regex(UUID_RE, "transaction_id must be a UUID — copy the id from a search_transactions result").describe("Transaction id (UUID) from a search_transactions / search_by_area / search_by_polygon result."),
|
|
1071
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Nature (Forest & Protected Areas) Breakdown" }, async (params) => withErrorHandling("get_transaction_nature", apiKey, async () => {
|
|
1072
|
+
requireApiKey(apiKey);
|
|
1073
|
+
const { data: res, creditInfo } = await getTransactionNature(params.transaction_id, apiKey);
|
|
1074
|
+
return textResponse(formatNatureBreakdown(res) + formatCreditFooter(creditInfo));
|
|
1075
|
+
}));
|
|
787
1076
|
}
|