@cenogram/mcp-server 0.1.7 → 0.5.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 +28 -24
- package/dist/api-client.d.ts +687 -7
- package/dist/api-client.js +202 -25
- package/dist/auth-dispatch.d.ts +59 -0
- package/dist/auth-dispatch.js +141 -0
- package/dist/client-id.js +0 -5
- package/dist/error-messages.d.ts +12 -0
- package/dist/error-messages.js +80 -0
- package/dist/formatters.d.ts +21 -1
- package/dist/formatters.js +1133 -24
- package/dist/index.d.ts +1 -0
- package/dist/index.js +149 -44
- package/dist/mappings.d.ts +12 -5
- package/dist/mappings.js +135 -17
- package/dist/oauth-jwt.d.ts +19 -0
- package/dist/oauth-jwt.js +63 -0
- package/dist/request-context.d.ts +5 -0
- package/dist/request-context.js +2 -0
- package/dist/sentry-scrub.d.ts +2 -0
- package/dist/sentry-scrub.js +14 -0
- package/dist/sentry.d.ts +2 -0
- package/dist/sentry.js +29 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +544 -81
- package/dist/transport-mode.d.ts +2 -0
- package/dist/transport-mode.js +6 -0
- package/package.json +16 -7
package/dist/tools.js
CHANGED
|
@@ -1,48 +1,112 @@
|
|
|
1
|
+
import { Sentry } from "./sentry.js";
|
|
1
2
|
import { z } from "zod";
|
|
2
|
-
import { getStats, getTransactions, getPricePerM2, getDistricts, getPriceHistogram, getTransactionsSummary, searchParcels, searchByPolygon, compareLocations, } from "./api-client.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
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";
|
|
4
|
+
import { signupUrl } from "./error-messages.js";
|
|
5
|
+
import { channelSrc, isHttpMode } from "./transport-mode.js";
|
|
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";
|
|
9
|
+
function sanitizeInput(s, maxLen = 50) {
|
|
10
|
+
return s.replace(/[<>]/g, "").slice(0, maxLen);
|
|
11
|
+
}
|
|
12
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
13
|
function textResponse(text) {
|
|
7
14
|
return { content: [{ type: "text", text }] };
|
|
8
15
|
}
|
|
9
16
|
function formatCreditFooter(creditInfo) {
|
|
10
17
|
if (!creditInfo)
|
|
11
18
|
return "";
|
|
12
|
-
return `\n---\
|
|
19
|
+
return `\n---\nAPI tokens: ${creditInfo.balance} remaining (query cost: ${creditInfo.cost})`;
|
|
13
20
|
}
|
|
14
21
|
function requireApiKey(apiKey) {
|
|
15
22
|
if (!apiKey) {
|
|
16
|
-
|
|
23
|
+
if (isHttpMode()) {
|
|
24
|
+
throw new Error("Missing auth context on the hosted server - this is a bug on our side, not something " +
|
|
25
|
+
"you can fix. Please report it: https://github.com/cenogram/mcp-server/issues");
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`No Cenogram API key configured. Get a free key at ${signupUrl()}, then add it to your ` +
|
|
28
|
+
'MCP config: "env": { "CENOGRAM_API_KEY": "cngrm_..." }');
|
|
17
29
|
}
|
|
18
30
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
31
|
+
function decodeAuthIdentity(apiKey) {
|
|
32
|
+
if (!apiKey)
|
|
33
|
+
return { userId: null, keyPrefix: null };
|
|
34
|
+
if (apiKey.startsWith(OAUTH_CTX_PREFIX)) {
|
|
35
|
+
const oauth = decodeOAuthCtx(apiKey);
|
|
36
|
+
return { userId: oauth ? sanitizeForLog(oauth.userId) : null, keyPrefix: "oauth" };
|
|
26
37
|
}
|
|
38
|
+
if (apiKey.startsWith("cngrm_"))
|
|
39
|
+
return { userId: null, keyPrefix: apiKey.slice(0, 10) };
|
|
40
|
+
return { userId: null, keyPrefix: apiKey.slice(0, 4) };
|
|
41
|
+
}
|
|
42
|
+
async function withErrorHandling(toolName, apiKey, fn) {
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
let success = true;
|
|
45
|
+
const { userId, keyPrefix } = decodeAuthIdentity(apiKey);
|
|
46
|
+
return await Sentry.withScope(async (scope) => {
|
|
47
|
+
const identity = userId ?? keyPrefix;
|
|
48
|
+
if (identity)
|
|
49
|
+
scope.setUser({ id: identity });
|
|
50
|
+
try {
|
|
51
|
+
return await fn();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
success = false;
|
|
55
|
+
Sentry.captureException(error, { tags: { tool: toolName, error_layer: "tool_execution" } });
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
process.stderr.write(JSON.stringify({
|
|
61
|
+
level: "info",
|
|
62
|
+
evt: "tool.call",
|
|
63
|
+
tool: toolName,
|
|
64
|
+
key_prefix: keyPrefix,
|
|
65
|
+
user_id: userId ?? keyPrefix,
|
|
66
|
+
duration_ms: Date.now() - start,
|
|
67
|
+
success,
|
|
68
|
+
}) + "\n");
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function experimentalToolsEnabled() {
|
|
73
|
+
return process.env.CENOGRAM_EXPERIMENTAL_TOOLS === "1";
|
|
27
74
|
}
|
|
28
|
-
// ── Tool registration ──────────────────────────────────────────────
|
|
29
75
|
export function registerTools(server, apiKey) {
|
|
30
|
-
|
|
31
|
-
server.tool("search_transactions", `Search Polish real estate transactions from the national RCN registry (7M+ records).
|
|
76
|
+
server.tool("search_transactions", `Search Polish real estate transactions from the national RCN registry (8M+ records).
|
|
32
77
|
Returns transaction details: address, date, price, area, price/m², property type.
|
|
33
78
|
Use list_locations first to find valid location names.
|
|
34
|
-
Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN
|
|
79
|
+
Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN.
|
|
80
|
+
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
|
+
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
|
+
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
|
+
Location matches TERYT districts only - for neighborhoods (osiedla), use search_by_area instead.`, {
|
|
35
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. Use list_locations to find valid names."),
|
|
85
|
+
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."),
|
|
36
86
|
propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional()
|
|
37
87
|
.describe("Property type filter"),
|
|
38
88
|
marketType: z.enum(["primary", "secondary"]).optional()
|
|
39
|
-
.describe("Market type: primary (developer) or secondary (resale)"),
|
|
40
|
-
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional()
|
|
41
|
-
.describe("Unit/apartment function filter"),
|
|
42
|
-
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional()
|
|
43
|
-
.describe("Building type filter (PKOB classification)"),
|
|
89
|
+
.describe("Market type: primary (developer) or secondary (resale). ~55% of records have unknown market type and will be excluded when this filter is used."),
|
|
90
|
+
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other", "unknown"]).optional()
|
|
91
|
+
.describe("Unit/apartment function filter. 'unknown' = no function recorded (NULL); without it such rows are excluded. Garages appear only when 'garage' is selected, not via 'unknown'."),
|
|
92
|
+
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential", "unknown"]).optional()
|
|
93
|
+
.describe("Building type filter (PKOB classification). 'unknown' = no type recorded (NULL); without it such rows are excluded (~39% of buildings have no type)."),
|
|
94
|
+
ownershipType: z.array(z.enum(["land_ownership", "perpetual_usufruct", "cooperative_ownership", "unit_sale", "ownership", "unit_ownership_with_appurtenant_right", "building_ownership_with_appurtenant_right", "unknown"])).optional()
|
|
95
|
+
.describe("Ownership / legal-right type filter (rodzaj prawa do nieruchomości). land_ownership; perpetual_usufruct (użytkowanie wieczyste — covers both registry codes for this right); cooperative_ownership; unit_sale; ownership; unit_ownership_with_appurtenant_right; building_ownership_with_appurtenant_right. 'unknown' = no right recorded (NULL). Multi-select; e.g. ['land_ownership','perpetual_usufruct'] to compare ownership vs perpetual usufruct on undeveloped land."),
|
|
44
96
|
mpzpDesignation: z.string().optional()
|
|
45
|
-
.describe("MPZP zoning designation filter (exact match, e.g. 'budownictwoMieszkanioweWielorodzinne', 'terenObiektowProdukcyjnychSkladowIMagazynow')"),
|
|
97
|
+
.describe("MPZP zoning designation filter (exact match, e.g. 'budownictwoMieszkanioweWielorodzinne', 'terenObiektowProdukcyjnychSkladowIMagazynow'). Use 'unknown' for rows with no designation recorded (NULL); distinct from the registry code 'brakMPZPLubWZ' (= 'no plan/WZ' recorded as data)."),
|
|
98
|
+
transactionType: z.array(z.enum(["free_market", "auction", "non_auction", "subsidized", "public_purpose", "foreclosure", "unknown"])).optional()
|
|
99
|
+
.describe("Transaction type filter. For market analysis, ALWAYS specify transactionType to exclude non-market transactions (subsidized, foreclosure, public purpose). ~2% of transactions have unknown type (NULL) and are excluded when this filter is used unless 'unknown' is included."),
|
|
100
|
+
rooms: z.array(z.enum(["1", "2", "3", "4", "5", "6", "7", "8plus", "unknown"])).optional()
|
|
101
|
+
.describe("Number of rooms (izby) filter, residential units only. Multi-select; '8plus' means 8 or more, 'unknown' = no room count recorded (NULL). E.g. ['2','3'] for 2-3 izby flats. Without 'unknown', rows with no room count are excluded."),
|
|
102
|
+
floor: z.array(z.string().regex(/^(-?\d+|\d+plus|unknown)$/i, "Invalid floor token - use an integer (e.g. '2','0','-1'), 'Nplus' (e.g. '10plus'), or 'unknown'.")).optional()
|
|
103
|
+
.describe("Floor of the unit (piętro lokalu, residential). Multi-select buckets: exact integers incl. '0' (parter) and negatives e.g. '-1' (basement), 'Nplus' e.g. '10plus' = 10 or more, '0plus' = ground and above, 'unknown' = no floor recorded (NULL). E.g. ['0','1','2'] for ground-to-2nd floor. Building storeys are a different attribute. Without 'unknown', rows with no floor are excluded."),
|
|
104
|
+
floodRisk: z.array(z.enum(["low", "medium", "high"])).optional()
|
|
105
|
+
.describe("Flood-hazard filter. high = most frequent flooding (~1-in-10-year), medium (~1-in-100-year), low = rarest (~1-in-500-year). Selects ONLY transactions whose land sits in a mapped flood zone; absence of a zone is never asserted as 'safe'. Multi-select; e.g. ['medium','high'] = at least medium risk."),
|
|
106
|
+
heritageStatus: z.array(z.enum(["listed", "zone"])).optional()
|
|
107
|
+
.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."),
|
|
108
|
+
landslideRisk: z.array(z.enum(["landslide", "threatened"])).optional()
|
|
109
|
+
.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."),
|
|
46
110
|
minPrice: z.number().optional().describe("Minimum price in PLN"),
|
|
47
111
|
maxPrice: z.number().optional().describe("Maximum price in PLN"),
|
|
48
112
|
dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"),
|
|
@@ -60,15 +124,36 @@ Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN.`, {
|
|
|
60
124
|
.describe("Sort order (default: desc)"),
|
|
61
125
|
page: z.number().min(1).default(1).optional()
|
|
62
126
|
.describe("Page number for pagination (default: 1)"),
|
|
63
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
127
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Search Real Estate Transactions" }, async (params) => withErrorHandling("search_transactions", apiKey, async () => {
|
|
64
128
|
requireApiKey(apiKey);
|
|
129
|
+
if (params.teryt) {
|
|
130
|
+
const TERYT_RE = /^(\d{2}|\d{4}|\d{6}|\d{6}_\d|\d{6}_\d\.\d{4})$/;
|
|
131
|
+
const codes = params.teryt.split(",").map((c) => c.trim());
|
|
132
|
+
if (codes.length > 10) {
|
|
133
|
+
return textResponse("Too many TERYT codes (max 10). Narrow your selection.");
|
|
134
|
+
}
|
|
135
|
+
const invalid = codes.filter((c) => !TERYT_RE.test(c));
|
|
136
|
+
if (invalid.length > 0) {
|
|
137
|
+
return textResponse(`Invalid TERYT code(s): ${invalid.map((c) => `'${sanitizeInput(c)}'`).join(", ")}. ` +
|
|
138
|
+
"Valid formats: 2-digit (voivodeship), 4-digit (county), 6-digit (municipality), " +
|
|
139
|
+
"or precinct (e.g. '321705_2.0054'). Use list_locations to find codes.");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
65
142
|
const txParams = {
|
|
66
143
|
district: params.location,
|
|
144
|
+
teryt: params.teryt,
|
|
67
145
|
propertyType: mapPropertyType(params.propertyType),
|
|
68
146
|
marketType: mapMarketType(params.marketType),
|
|
69
147
|
unitFunction: mapUnitFunction(params.unitFunction),
|
|
148
|
+
ownershipType: mapOwnershipTypes(params.ownershipType),
|
|
70
149
|
buildingType: mapBuildingType(params.buildingType),
|
|
71
150
|
mpzpDesignation: params.mpzpDesignation,
|
|
151
|
+
transactionType: mapTransactionTypes(params.transactionType),
|
|
152
|
+
rooms: params.rooms?.join(","),
|
|
153
|
+
floor: params.floor?.join(","),
|
|
154
|
+
floodRisk: params.floodRisk?.join(","),
|
|
155
|
+
heritageStatus: params.heritageStatus?.join(","),
|
|
156
|
+
landslideRisk: params.landslideRisk?.join(","),
|
|
72
157
|
minPrice: params.minPrice,
|
|
73
158
|
maxPrice: params.maxPrice,
|
|
74
159
|
dateFrom: params.dateFrom,
|
|
@@ -89,57 +174,72 @@ Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN.`, {
|
|
|
89
174
|
]);
|
|
90
175
|
return textResponse(formatTransactionList(txResult.data, summaryResult?.data ?? null) + formatCreditFooter(txResult.creditInfo));
|
|
91
176
|
}));
|
|
92
|
-
// ── Tool 2: get_price_statistics ────────────────────────────────────
|
|
93
177
|
server.tool("get_price_statistics", `Get price per m² statistics by location for residential apartments in Poland.
|
|
94
178
|
Note: only covers residential units (lokale mieszkalne). For other property types, use search_transactions.
|
|
95
|
-
'Warszawa'/'Kraków'/'Łódź' auto-expand to all sub-districts (Warszawa=19, Kraków=5, Łódź=6). Other names use partial match
|
|
179
|
+
'Warszawa'/'Kraków'/'Łódź' auto-expand to all sub-districts (Warszawa=19, Kraków=5, Łódź=6). Other names use partial match.
|
|
180
|
+
Data quality: based on transaction prices from notarial deeds, not asking/listing prices. Coverage varies by county (some have data gaps of 5+ years).
|
|
181
|
+
${MARKET_CAVEAT}`, {
|
|
96
182
|
location: z.string().optional().describe("Filter by location name. 'Warszawa'/'Kraków'/'Łódź' auto-expand to all sub-districts. Other names use case-insensitive partial match (e.g. 'Wrocł' matches 'Wrocław'). Omit for all Poland."),
|
|
97
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
183
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Price per m² Statistics" }, async (params) => withErrorHandling("get_price_statistics", apiKey, async () => {
|
|
98
184
|
requireApiKey(apiKey);
|
|
99
185
|
const { data: allRows, creditInfo } = await getPricePerM2(apiKey);
|
|
100
186
|
let rows = allRows;
|
|
101
187
|
if (params.location) {
|
|
102
|
-
|
|
103
|
-
|
|
188
|
+
const city = tryResolveCityKey(params.location);
|
|
189
|
+
if (city) {
|
|
190
|
+
const allowed = new Set(city);
|
|
104
191
|
rows = rows.filter((r) => allowed.has(r.district));
|
|
105
192
|
}
|
|
106
193
|
else {
|
|
107
|
-
|
|
194
|
+
const { data: allDistricts } = await getDistricts(apiKey);
|
|
195
|
+
const resolved = resolveDistrict(params.location, allDistricts);
|
|
196
|
+
const isCityExpansion = resolved.length > 1;
|
|
197
|
+
if (isCityExpansion) {
|
|
198
|
+
const allowed = new Set(resolved);
|
|
199
|
+
rows = rows.filter((r) => allowed.has(r.district));
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
rows = rows.filter((r) => filterByLocation(params.location, [r.district]).length > 0);
|
|
203
|
+
}
|
|
108
204
|
}
|
|
109
205
|
}
|
|
110
206
|
return textResponse(formatPriceStats(rows, params.location) + formatCreditFooter(creditInfo));
|
|
111
207
|
}));
|
|
112
|
-
// ── Tool 3: get_price_distribution ──────────────────────────────────
|
|
113
208
|
server.tool("get_price_distribution", `Get price distribution histogram showing how many transactions fall into each price range.
|
|
114
|
-
Useful for understanding the overall market price structure in Poland
|
|
209
|
+
Useful for understanding the overall market price structure in Poland.
|
|
210
|
+
${MARKET_CAVEAT}`, {
|
|
115
211
|
bins: z.number().min(5).max(50).default(20)
|
|
116
212
|
.describe("Number of price bins (5-50, default 20)"),
|
|
117
213
|
maxPrice: z.number().default(3_000_000)
|
|
118
214
|
.describe("Maximum price to include (default 3,000,000 PLN)"),
|
|
119
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
215
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Price Distribution Histogram" }, async (params) => withErrorHandling("get_price_distribution", apiKey, async () => {
|
|
120
216
|
requireApiKey(apiKey);
|
|
121
217
|
const { data: bins, creditInfo } = await getPriceHistogram(params.bins, params.maxPrice, apiKey);
|
|
122
218
|
return textResponse(formatHistogram(bins) + formatCreditFooter(creditInfo));
|
|
123
219
|
}));
|
|
124
|
-
// ── Tool 4: search_by_area ──────────────────────────────────────────
|
|
125
220
|
server.tool("search_by_area", `Search real estate transactions within a geographic radius.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
221
|
+
Best tool for neighborhood/osiedle searches (neighborhoods are not TERYT districts).
|
|
222
|
+
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
|
+
Example: apartments in Wrocław's Nowy Dwór (lat 51.143, lng 16.993, radiusKm=0.7).
|
|
224
|
+
Area filters (minArea/maxArea) work for all propertyType values.
|
|
225
|
+
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.
|
|
226
|
+
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.`, {
|
|
129
227
|
latitude: z.number().min(49).max(55)
|
|
130
228
|
.describe("Latitude (Poland range: 49-55)"),
|
|
131
229
|
longitude: z.number().min(14).max(25)
|
|
132
230
|
.describe("Longitude (Poland range: 14-25)"),
|
|
133
231
|
radiusKm: z.number().min(0.1).max(50).default(2)
|
|
134
|
-
.describe("Search radius in
|
|
232
|
+
.describe("Search radius in km (0.1-50, default 2). Use 0.5-1 for neighborhoods, 0.3-0.5 for streets."),
|
|
135
233
|
propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional()
|
|
136
234
|
.describe("Property type filter"),
|
|
137
235
|
marketType: z.enum(["primary", "secondary"]).optional()
|
|
138
|
-
.describe("Market type filter"),
|
|
139
|
-
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional()
|
|
140
|
-
.describe("Unit/apartment function filter"),
|
|
141
|
-
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional()
|
|
142
|
-
.describe("Building type filter (PKOB classification)"),
|
|
236
|
+
.describe("Market type: primary (developer) or secondary (resale). ~55% of records have unknown market type and will be excluded when this filter is used."),
|
|
237
|
+
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other", "unknown"]).optional()
|
|
238
|
+
.describe("Unit/apartment function filter. 'unknown' = no function recorded (NULL); without it such rows are excluded. Garages appear only when 'garage' is selected, not via 'unknown'."),
|
|
239
|
+
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential", "unknown"]).optional()
|
|
240
|
+
.describe("Building type filter (PKOB classification). 'unknown' = no type recorded (NULL); without it such rows are excluded (~39% of buildings have no type)."),
|
|
241
|
+
ownershipType: z.array(z.enum(["land_ownership", "perpetual_usufruct", "cooperative_ownership", "unit_sale", "ownership", "unit_ownership_with_appurtenant_right", "building_ownership_with_appurtenant_right", "unknown"])).optional()
|
|
242
|
+
.describe("Ownership / legal-right type filter (rodzaj prawa do nieruchomości). land_ownership; perpetual_usufruct (użytkowanie wieczyste — covers both registry codes for this right); cooperative_ownership; unit_sale; ownership; unit_ownership_with_appurtenant_right; building_ownership_with_appurtenant_right. 'unknown' = no right recorded (NULL). Multi-select; e.g. ['land_ownership','perpetual_usufruct'] to compare ownership vs perpetual usufruct on undeveloped land."),
|
|
143
243
|
minPrice: z.number().optional().describe("Minimum price in PLN"),
|
|
144
244
|
maxPrice: z.number().optional().describe("Maximum price in PLN"),
|
|
145
245
|
minArea: z.number().optional()
|
|
@@ -148,9 +248,21 @@ Area filters (minArea/maxArea) work for all propertyType values via COALESCE(usa
|
|
|
148
248
|
.describe("Maximum area in m²"),
|
|
149
249
|
dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"),
|
|
150
250
|
dateTo: z.string().optional().describe("End date (YYYY-MM-DD)"),
|
|
251
|
+
transactionType: z.array(z.enum(["free_market", "auction", "non_auction", "subsidized", "public_purpose", "foreclosure", "unknown"])).optional()
|
|
252
|
+
.describe("Transaction type filter. For market analysis, ALWAYS specify to exclude non-market transactions."),
|
|
253
|
+
rooms: z.array(z.enum(["1", "2", "3", "4", "5", "6", "7", "8plus", "unknown"])).optional()
|
|
254
|
+
.describe("Number of rooms (izby) filter, residential units only. Multi-select; '8plus' means 8 or more, 'unknown' = no room count recorded (NULL). E.g. ['2','3'] for 2-3 izby flats. Without 'unknown', rows with no room count are excluded."),
|
|
255
|
+
floor: z.array(z.string().regex(/^(-?\d+|\d+plus|unknown)$/i, "Invalid floor token - use an integer (e.g. '2','0','-1'), 'Nplus' (e.g. '10plus'), or 'unknown'.")).optional()
|
|
256
|
+
.describe("Floor of the unit (piętro lokalu, residential). Multi-select buckets: exact integers incl. '0' (parter) and negatives e.g. '-1' (basement), 'Nplus' e.g. '10plus' = 10 or more, '0plus' = ground and above, 'unknown' = no floor recorded (NULL). E.g. ['0','1','2'] for ground-to-2nd floor. Building storeys are a different attribute. Without 'unknown', rows with no floor are excluded."),
|
|
257
|
+
floodRisk: z.array(z.enum(["low", "medium", "high"])).optional()
|
|
258
|
+
.describe("Flood-hazard filter. high = most frequent flooding (~1-in-10-year), medium (~1-in-100-year), low = rarest (~1-in-500-year). Selects ONLY transactions whose land sits in a mapped flood zone; absence of a zone is never asserted as 'safe'. Multi-select; e.g. ['medium','high'] = at least medium risk."),
|
|
259
|
+
heritageStatus: z.array(z.enum(["listed", "zone"])).optional()
|
|
260
|
+
.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
|
+
landslideRisk: z.array(z.enum(["landslide", "threatened"])).optional()
|
|
262
|
+
.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."),
|
|
151
263
|
limit: z.number().min(1).max(50).default(20)
|
|
152
264
|
.describe("Number of results (1-50, default 20)"),
|
|
153
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
265
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Search Transactions by Radius" }, async (params) => withErrorHandling("search_by_area", apiKey, async () => {
|
|
154
266
|
requireApiKey(apiKey);
|
|
155
267
|
const bbox = radiusKmToBbox(params.latitude, params.longitude, params.radiusKm);
|
|
156
268
|
const txParams = {
|
|
@@ -158,7 +270,14 @@ Area filters (minArea/maxArea) work for all propertyType values via COALESCE(usa
|
|
|
158
270
|
propertyType: mapPropertyType(params.propertyType),
|
|
159
271
|
marketType: mapMarketType(params.marketType),
|
|
160
272
|
unitFunction: mapUnitFunction(params.unitFunction),
|
|
273
|
+
ownershipType: mapOwnershipTypes(params.ownershipType),
|
|
161
274
|
buildingType: mapBuildingType(params.buildingType),
|
|
275
|
+
transactionType: mapTransactionTypes(params.transactionType),
|
|
276
|
+
rooms: params.rooms?.join(","),
|
|
277
|
+
floor: params.floor?.join(","),
|
|
278
|
+
floodRisk: params.floodRisk?.join(","),
|
|
279
|
+
heritageStatus: params.heritageStatus?.join(","),
|
|
280
|
+
landslideRisk: params.landslideRisk?.join(","),
|
|
162
281
|
minPrice: params.minPrice,
|
|
163
282
|
maxPrice: params.maxPrice,
|
|
164
283
|
minArea: params.minArea,
|
|
@@ -175,26 +294,49 @@ Area filters (minArea/maxArea) work for all propertyType values via COALESCE(usa
|
|
|
175
294
|
]);
|
|
176
295
|
return textResponse(formatTransactionList(txResult.data, summaryResult?.data ?? null) + formatCreditFooter(txResult.creditInfo));
|
|
177
296
|
}));
|
|
178
|
-
// ── Tool 5: get_market_overview ─────────────────────────────────────
|
|
179
297
|
server.tool("get_market_overview", `Get a comprehensive overview of the Polish real estate transaction database.
|
|
180
|
-
Returns: total transaction count, date range, breakdown by property type and market type, top locations, price statistics
|
|
298
|
+
Returns: total transaction count, date range, breakdown by property type and market type, top locations, price statistics.
|
|
299
|
+
Note: data quality varies by field - marketType is unknown for ~55% of records, transaction_date missing for ~1.7%.
|
|
300
|
+
${MARKET_CAVEAT}`, {}, { readOnlyHint: true, destructiveHint: false, title: "Market Overview" }, async () => withErrorHandling("get_market_overview", apiKey, async () => {
|
|
181
301
|
requireApiKey(apiKey);
|
|
182
302
|
const { data: stats, creditInfo } = await getStats(apiKey);
|
|
183
303
|
return textResponse(formatMarketOverview(stats) + formatCreditFooter(creditInfo));
|
|
184
304
|
}));
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
305
|
+
server.tool("list_locations", `Browse locations in two modes:
|
|
306
|
+
1. TERYT hierarchy (parent param): Navigate voivodeship → county → municipality → precinct. Returns TERYT codes for use in search_transactions(teryt=...).
|
|
307
|
+
- No parent: 16 voivodeships (2-digit codes)
|
|
308
|
+
- 2-digit: counties (4-digit), 4-digit: municipalities (6-digit), 6-digit: precincts
|
|
309
|
+
2. Name search (search param): Find districts by name (flat list, legacy).
|
|
310
|
+
If both provided, parent takes precedence.
|
|
311
|
+
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
|
+
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("Filter locations by name (case-insensitive partial match, e.g. 'Krak' for Kraków districts). Ignored when parent is set."),
|
|
314
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "List Locations & TERYT Codes" }, async (params) => withErrorHandling("list_locations", apiKey, async () => {
|
|
193
315
|
requireApiKey(apiKey);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
316
|
+
if (params.parent !== undefined) {
|
|
317
|
+
const parent = params.parent.trim();
|
|
318
|
+
if (!/^(\d{2}|\d{4}|\d{6})$/.test(parent)) {
|
|
319
|
+
return textResponse(`Invalid parent code '${sanitizeInput(parent)}'. Parent must be 2, 4, or 6 digits (e.g. '14' for Mazowieckie voivodeship). ` +
|
|
320
|
+
"For precinct-level codes (e.g. '321705_2.0054'), use search_transactions(teryt=...) directly.");
|
|
321
|
+
}
|
|
322
|
+
const { data: locations, creditInfo } = await getLocations(parent, apiKey);
|
|
323
|
+
return textResponse(formatLocationHierarchy(locations, parent) + formatCreditFooter(creditInfo));
|
|
324
|
+
}
|
|
325
|
+
if (params.search === undefined) {
|
|
326
|
+
const { data: locations, creditInfo } = await getLocations(undefined, apiKey);
|
|
327
|
+
return textResponse(formatLocationHierarchy(locations) + formatCreditFooter(creditInfo));
|
|
328
|
+
}
|
|
329
|
+
let districts;
|
|
330
|
+
let creditInfo;
|
|
331
|
+
const city = tryResolveCityKey(params.search);
|
|
332
|
+
if (city) {
|
|
333
|
+
districts = city;
|
|
334
|
+
creditInfo = null;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
const res = await getDistricts(apiKey);
|
|
338
|
+
creditInfo = res.creditInfo;
|
|
339
|
+
districts = filterByLocation(params.search, res.data);
|
|
198
340
|
}
|
|
199
341
|
if (districts.length === 0) {
|
|
200
342
|
const msg = params.search
|
|
@@ -203,7 +345,6 @@ Use the search parameter to filter by name.`, {
|
|
|
203
345
|
return textResponse(msg + formatCreditFooter(creditInfo));
|
|
204
346
|
}
|
|
205
347
|
const lines = [`Found ${districts.length} locations:\n`];
|
|
206
|
-
// Show all if filtered, otherwise top 50
|
|
207
348
|
const shown = params.search ? districts : districts.slice(0, 50);
|
|
208
349
|
for (const d of shown) {
|
|
209
350
|
lines.push(` - ${d}`);
|
|
@@ -213,7 +354,6 @@ Use the search parameter to filter by name.`, {
|
|
|
213
354
|
}
|
|
214
355
|
return textResponse(lines.join("\n") + formatCreditFooter(creditInfo));
|
|
215
356
|
}));
|
|
216
|
-
// ── Tool 7: search_parcels ──────────────────────────────────────────
|
|
217
357
|
server.tool("search_parcels", `Search for land parcels by parcel ID prefix (autocomplete).
|
|
218
358
|
Returns matching parcels with their district, area, and GPS coordinates.
|
|
219
359
|
Useful for finding exact parcel IDs, then searching transactions nearby.
|
|
@@ -221,32 +361,79 @@ Example: search for parcels starting with '146518_8.01'.`, {
|
|
|
221
361
|
q: z.string().min(3).describe("Parcel ID prefix to search for (min 3 chars). E.g. '146518_8.01'"),
|
|
222
362
|
limit: z.number().min(1).max(10).default(10).optional()
|
|
223
363
|
.describe("Max results (1-10, default 10)"),
|
|
224
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
364
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Search Land Parcels" }, async (params) => withErrorHandling("search_parcels", apiKey, async () => {
|
|
225
365
|
requireApiKey(apiKey);
|
|
226
366
|
const { data, creditInfo } = await searchParcels(params.q, params.limit, apiKey);
|
|
227
367
|
return textResponse(formatParcelResults(data, params.q) + formatCreditFooter(creditInfo));
|
|
228
368
|
}));
|
|
229
|
-
|
|
369
|
+
server.tool("resolve_parcel", `Resolve a land parcel to its cadastral identity using exactly ONE of:
|
|
370
|
+
- 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'). Name matching is exact on the locality (case-insensitive) — an unusual spelling may miss.
|
|
372
|
+
- 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 credit is refunded.
|
|
374
|
+
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
|
+
Costs 1 API token (refunded when nothing matches).`, {
|
|
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."),
|
|
377
|
+
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
|
+
lat: z.number().min(-90).max(90).optional().describe("Latitude WGS84. Must be paired with lng. Mutually exclusive with q and parcelId."),
|
|
379
|
+
lng: z.number().min(-180).max(180).optional().describe("Longitude WGS84. Must be paired with lat. Mutually exclusive with q and parcelId."),
|
|
380
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Resolve Land Parcel" }, async (params) => withErrorHandling("resolve_parcel", apiKey, async () => {
|
|
381
|
+
requireApiKey(apiKey);
|
|
382
|
+
const hasQ = params.q != null && params.q !== "";
|
|
383
|
+
const hasParcelId = params.parcelId != null && params.parcelId !== "";
|
|
384
|
+
const hasLat = params.lat != null;
|
|
385
|
+
const hasLng = params.lng != null;
|
|
386
|
+
const modeCount = (hasQ ? 1 : 0) + (hasParcelId ? 1 : 0) + (hasLat || hasLng ? 1 : 0);
|
|
387
|
+
if (modeCount !== 1) {
|
|
388
|
+
return textResponse("Provide exactly one lookup mode: q=, parcelId=, or lat= and lng=.");
|
|
389
|
+
}
|
|
390
|
+
if ((hasLat || hasLng) && !(hasLat && hasLng)) {
|
|
391
|
+
return textResponse("lat and lng must be provided together.");
|
|
392
|
+
}
|
|
393
|
+
const { data, creditInfo } = await resolveParcel({ q: params.q, parcelId: params.parcelId, lat: params.lat, lng: params.lng }, apiKey);
|
|
394
|
+
return textResponse(formatParcelResolve(data) + formatCreditFooter(creditInfo));
|
|
395
|
+
}));
|
|
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 nine enrichment layers (flood risk, heritage listing, landslide risk, nuisance surroundings, public-transport access, general-plan zoning, buildings on the parcel, recent building activity, agricultural-land eligibility), 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
|
+
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
|
+
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.
|
|
399
|
+
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 35 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
|
+
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
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Get Parcel Report" }, async (params) => withErrorHandling("get_parcel_report", apiKey, async () => {
|
|
403
|
+
requireApiKey(apiKey);
|
|
404
|
+
const { data, creditInfo } = await getParcelReport(params.parcelId, apiKey);
|
|
405
|
+
return textResponse(formatParcelReport(data) + formatCreditFooter(creditInfo));
|
|
406
|
+
}));
|
|
230
407
|
server.tool("search_by_polygon", `Search real estate transactions within a geographic polygon.
|
|
231
408
|
Provide a GeoJSON Polygon geometry to search within a custom area.
|
|
232
409
|
Returns transactions found inside the polygon with coordinates.
|
|
233
|
-
Use for precise
|
|
410
|
+
Use for precise neighborhood/osiedle boundaries. Can estimate coordinates from search_by_area results. For quick searches, start with search_by_area instead.
|
|
234
411
|
Coordinates are [longitude, latitude]. First and last point must be identical.
|
|
412
|
+
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.
|
|
413
|
+
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.
|
|
235
414
|
Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21],[21.0,52.21],[21.0,52.2]]]}`, {
|
|
236
415
|
polygon: z.object({
|
|
237
416
|
type: z.literal("Polygon"),
|
|
238
|
-
coordinates: z.array(z.array(z.array(z.number()))),
|
|
239
|
-
}).describe("GeoJSON Polygon geometry. Coordinates: [longitude, latitude] pairs. Max 500 vertices."),
|
|
417
|
+
coordinates: z.array(z.array(z.array(z.number()))).min(1),
|
|
418
|
+
}).refine((poly) => poly.coordinates.reduce((sum, ring) => sum + ring.length, 0) <= 500, { message: "polygon exceeds 500 total vertices (sum across all rings)" }).describe("GeoJSON Polygon geometry. Coordinates: [longitude, latitude] pairs. First and last point must be identical. Max 500 vertices total."),
|
|
240
419
|
propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional()
|
|
241
420
|
.describe("Property type filter"),
|
|
242
421
|
marketType: z.enum(["primary", "secondary"]).optional()
|
|
243
422
|
.describe("Market type filter"),
|
|
244
|
-
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional()
|
|
245
|
-
.describe("Unit/apartment function filter"),
|
|
246
|
-
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional()
|
|
247
|
-
.describe("Building type filter (PKOB classification)"),
|
|
423
|
+
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other", "unknown"]).optional()
|
|
424
|
+
.describe("Unit/apartment function filter. 'unknown' = no function recorded (NULL); without it such rows are excluded. Garages appear only when 'garage' is selected, not via 'unknown'."),
|
|
425
|
+
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential", "unknown"]).optional()
|
|
426
|
+
.describe("Building type filter (PKOB classification). 'unknown' = no type recorded (NULL); without it such rows are excluded (~39% of buildings have no type)."),
|
|
427
|
+
ownershipType: z.array(z.enum(["land_ownership", "perpetual_usufruct", "cooperative_ownership", "unit_sale", "ownership", "unit_ownership_with_appurtenant_right", "building_ownership_with_appurtenant_right", "unknown"])).optional()
|
|
428
|
+
.describe("Ownership / legal-right type filter (rodzaj prawa do nieruchomości). land_ownership; perpetual_usufruct (użytkowanie wieczyste — covers both registry codes for this right); cooperative_ownership; unit_sale; ownership; unit_ownership_with_appurtenant_right; building_ownership_with_appurtenant_right. 'unknown' = no right recorded (NULL). Multi-select; e.g. ['land_ownership','perpetual_usufruct'] to compare ownership vs perpetual usufruct on undeveloped land."),
|
|
248
429
|
mpzpDesignation: z.string().optional()
|
|
249
|
-
.describe("MPZP zoning designation filter (exact match)"),
|
|
430
|
+
.describe("MPZP zoning designation filter (exact match). Use 'unknown' for rows with no designation recorded (NULL); distinct from the registry code 'brakMPZPLubWZ'."),
|
|
431
|
+
transactionType: z.array(z.enum(["free_market", "auction", "non_auction", "subsidized", "public_purpose", "foreclosure", "unknown"])).optional()
|
|
432
|
+
.describe("Transaction type filter. For market analysis, ALWAYS specify to exclude non-market transactions."),
|
|
433
|
+
rooms: z.array(z.enum(["1", "2", "3", "4", "5", "6", "7", "8plus", "unknown"])).optional()
|
|
434
|
+
.describe("Number of rooms (izby) filter, residential units only. Multi-select; '8plus' means 8 or more, 'unknown' = no room count recorded (NULL). E.g. ['2','3'] for 2-3 izby flats. Without 'unknown', rows with no room count are excluded."),
|
|
435
|
+
floor: z.array(z.string().regex(/^(-?\d+|\d+plus|unknown)$/i, "Invalid floor token - use an integer (e.g. '2','0','-1'), 'Nplus' (e.g. '10plus'), or 'unknown'.")).optional()
|
|
436
|
+
.describe("Floor of the unit (piętro lokalu, residential). Multi-select buckets: exact integers incl. '0' (parter) and negatives e.g. '-1' (basement), 'Nplus' e.g. '10plus' = 10 or more, '0plus' = ground and above, 'unknown' = no floor recorded (NULL). E.g. ['0','1','2'] for ground-to-2nd floor. Building storeys are a different attribute. Without 'unknown', rows with no floor are excluded."),
|
|
250
437
|
minPrice: z.number().optional().describe("Minimum price in PLN"),
|
|
251
438
|
maxPrice: z.number().optional().describe("Maximum price in PLN"),
|
|
252
439
|
dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"),
|
|
@@ -257,15 +444,19 @@ Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21
|
|
|
257
444
|
street: z.string().optional().describe("Street name filter (partial match)"),
|
|
258
445
|
limit: z.number().min(1).max(5000).default(100).optional()
|
|
259
446
|
.describe("Max results (1-5000, default 100). MCP displays up to 50 transactions."),
|
|
260
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
447
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Search Transactions by Polygon" }, async (params) => withErrorHandling("search_by_polygon", apiKey, async () => {
|
|
261
448
|
requireApiKey(apiKey);
|
|
262
449
|
const { data, creditInfo } = await searchByPolygon({
|
|
263
450
|
polygon: params.polygon,
|
|
264
451
|
propertyType: mapPropertyType(params.propertyType),
|
|
265
452
|
marketType: mapMarketType(params.marketType),
|
|
266
453
|
unitFunction: mapUnitFunction(params.unitFunction),
|
|
454
|
+
ownershipType: mapOwnershipTypes(params.ownershipType),
|
|
267
455
|
buildingType: mapBuildingType(params.buildingType),
|
|
268
456
|
mpzpDesignation: params.mpzpDesignation,
|
|
457
|
+
transactionType: mapTransactionTypes(params.transactionType),
|
|
458
|
+
rooms: params.rooms?.join(","),
|
|
459
|
+
floor: params.floor?.join(","),
|
|
269
460
|
minPrice: params.minPrice,
|
|
270
461
|
maxPrice: params.maxPrice,
|
|
271
462
|
dateFrom: params.dateFrom,
|
|
@@ -278,23 +469,32 @@ Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21
|
|
|
278
469
|
}, apiKey);
|
|
279
470
|
return textResponse(formatSpatialResults(data) + formatCreditFooter(creditInfo));
|
|
280
471
|
}));
|
|
281
|
-
// ── Tool 9: compare_locations ──────────────────────────────────────
|
|
282
472
|
server.tool("compare_locations", `Compare real estate statistics across multiple locations side-by-side.
|
|
283
473
|
Provide 2-5 district names to compare median price/m², average area, and transaction counts.
|
|
284
474
|
Use list_locations first to find valid location names.
|
|
285
475
|
Requires at least one filter besides districts (e.g., propertyType).
|
|
286
|
-
Example: compare Mokotów, Wola, Ursynów for apartments
|
|
287
|
-
|
|
476
|
+
Example: compare Mokotów, Wola, Ursynów for apartments.
|
|
477
|
+
${MARKET_CAVEAT}`, {
|
|
478
|
+
districts: z.string()
|
|
479
|
+
.refine((s) => {
|
|
480
|
+
const list = [...new Set(s.split(",").map((d) => d.trim()).filter(Boolean))];
|
|
481
|
+
return list.length >= 2 && list.length <= 5;
|
|
482
|
+
}, { message: "districts must be 2-5 unique comma-separated names (e.g. 'Mokotów,Wola,Ursynów')" })
|
|
483
|
+
.describe("Comma-separated district names to compare (2-5, must be unique). E.g. 'Mokotów,Wola,Ursynów'"),
|
|
288
484
|
propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional()
|
|
289
485
|
.describe("Property type filter (recommended - API requires at least one filter)"),
|
|
290
486
|
marketType: z.enum(["primary", "secondary"]).optional()
|
|
291
487
|
.describe("Market type filter"),
|
|
292
|
-
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional()
|
|
293
|
-
.describe("Unit/apartment function filter"),
|
|
294
|
-
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional()
|
|
295
|
-
.describe("Building type filter (PKOB classification)"),
|
|
488
|
+
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other", "unknown"]).optional()
|
|
489
|
+
.describe("Unit/apartment function filter. 'unknown' = no function recorded (NULL); without it such rows are excluded. Garages appear only when 'garage' is selected, not via 'unknown'."),
|
|
490
|
+
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential", "unknown"]).optional()
|
|
491
|
+
.describe("Building type filter (PKOB classification). 'unknown' = no type recorded (NULL); without it such rows are excluded (~39% of buildings have no type)."),
|
|
492
|
+
ownershipType: z.array(z.enum(["land_ownership", "perpetual_usufruct", "cooperative_ownership", "unit_sale", "ownership", "unit_ownership_with_appurtenant_right", "building_ownership_with_appurtenant_right", "unknown"])).optional()
|
|
493
|
+
.describe("Ownership / legal-right type filter (rodzaj prawa do nieruchomości). land_ownership; perpetual_usufruct (użytkowanie wieczyste — covers both registry codes for this right); cooperative_ownership; unit_sale; ownership; unit_ownership_with_appurtenant_right; building_ownership_with_appurtenant_right. 'unknown' = no right recorded (NULL). Multi-select; e.g. ['land_ownership','perpetual_usufruct'] to compare ownership vs perpetual usufruct on undeveloped land."),
|
|
296
494
|
mpzpDesignation: z.string().optional()
|
|
297
|
-
.describe("MPZP zoning designation prefix filter (e.g. 'terenRolniczy', 'budownictwoMieszkanioweJednorodzinne', 'budownictwoMieszkanioweWielorodzinne')"),
|
|
495
|
+
.describe("MPZP zoning designation prefix filter (e.g. 'terenRolniczy', 'budownictwoMieszkanioweJednorodzinne', 'budownictwoMieszkanioweWielorodzinne'). Use 'unknown' for rows with no designation recorded (NULL); distinct from the registry code 'brakMPZPLubWZ'."),
|
|
496
|
+
transactionType: z.array(z.enum(["free_market", "auction", "non_auction", "subsidized", "public_purpose", "foreclosure", "unknown"])).optional()
|
|
497
|
+
.describe("Transaction type filter. For market analysis, ALWAYS specify to exclude non-market transactions."),
|
|
298
498
|
minPrice: z.number().optional().describe("Minimum price in PLN"),
|
|
299
499
|
maxPrice: z.number().optional().describe("Maximum price in PLN"),
|
|
300
500
|
dateFrom: z.string().optional().describe("Start date (YYYY-MM-DD)"),
|
|
@@ -302,15 +502,44 @@ Example: compare Mokotów, Wola, Ursynów for apartments.`, {
|
|
|
302
502
|
minArea: z.number().optional().describe("Minimum area in m²"),
|
|
303
503
|
maxArea: z.number().optional().describe("Maximum area in m²"),
|
|
304
504
|
street: z.string().optional().describe("Street name filter"),
|
|
305
|
-
|
|
505
|
+
rooms: z.array(z.enum(["1", "2", "3", "4", "5", "6", "7", "8plus", "unknown"])).optional()
|
|
506
|
+
.describe("Number of rooms (izby) filter, residential units only. Multi-select; '8plus' means 8 or more, 'unknown' = no room count recorded (NULL). E.g. ['2','3'] for 2-3 izby flats. Without 'unknown', rows with no room count are excluded."),
|
|
507
|
+
floor: z.array(z.string().regex(/^(-?\d+|\d+plus|unknown)$/i, "Invalid floor token - use an integer (e.g. '2','0','-1'), 'Nplus' (e.g. '10plus'), or 'unknown'.")).optional()
|
|
508
|
+
.describe("Floor of the unit (piętro lokalu, residential). Multi-select buckets: exact integers incl. '0' (parter) and negatives e.g. '-1' (basement), 'Nplus' e.g. '10plus' = 10 or more, '0plus' = ground and above, 'unknown' = no floor recorded (NULL). E.g. ['0','1','2'] for ground-to-2nd floor. Building storeys are a different attribute. Without 'unknown', rows with no floor are excluded."),
|
|
509
|
+
includeDemographics: z.boolean().optional()
|
|
510
|
+
.describe("Add a GUS BDL demographics block per district (county-level: population density, wages, unemployment, median age, plus a few cross-source ratios like price-to-income). Districts that don't resolve to a county are omitted from the demographics section."),
|
|
511
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Compare Locations" }, async (params) => withErrorHandling("compare_locations", apiKey, async () => {
|
|
306
512
|
requireApiKey(apiKey);
|
|
513
|
+
const hasFilter = !!params.propertyType ||
|
|
514
|
+
!!params.marketType ||
|
|
515
|
+
!!params.unitFunction ||
|
|
516
|
+
!!params.buildingType ||
|
|
517
|
+
!!params.mpzpDesignation?.trim() ||
|
|
518
|
+
(params.transactionType != null && params.transactionType.length > 0) ||
|
|
519
|
+
(params.rooms != null && params.rooms.length > 0) ||
|
|
520
|
+
(params.floor != null && params.floor.length > 0) ||
|
|
521
|
+
(params.ownershipType != null && params.ownershipType.length > 0) ||
|
|
522
|
+
params.minPrice != null ||
|
|
523
|
+
params.maxPrice != null ||
|
|
524
|
+
!!params.dateFrom?.trim() ||
|
|
525
|
+
!!params.dateTo?.trim() ||
|
|
526
|
+
params.minArea != null ||
|
|
527
|
+
params.maxArea != null ||
|
|
528
|
+
!!params.street?.trim();
|
|
529
|
+
if (!hasFilter) {
|
|
530
|
+
return textResponse("compare_locations requires at least one filter besides districts (e.g. propertyType=unit, marketType=secondary, or a date range).");
|
|
531
|
+
}
|
|
307
532
|
const { data, creditInfo } = await compareLocations({
|
|
308
533
|
districts: params.districts,
|
|
309
534
|
propertyType: mapPropertyType(params.propertyType),
|
|
310
535
|
marketType: mapMarketType(params.marketType),
|
|
311
536
|
unitFunction: mapUnitFunction(params.unitFunction),
|
|
537
|
+
ownershipType: mapOwnershipTypes(params.ownershipType),
|
|
312
538
|
buildingType: mapBuildingType(params.buildingType),
|
|
313
539
|
mpzpDesignation: params.mpzpDesignation,
|
|
540
|
+
transactionType: mapTransactionTypes(params.transactionType),
|
|
541
|
+
rooms: params.rooms?.join(","),
|
|
542
|
+
floor: params.floor?.join(","),
|
|
314
543
|
minPrice: params.minPrice,
|
|
315
544
|
maxPrice: params.maxPrice,
|
|
316
545
|
dateFrom: params.dateFrom,
|
|
@@ -318,7 +547,241 @@ Example: compare Mokotów, Wola, Ursynów for apartments.`, {
|
|
|
318
547
|
minArea: params.minArea,
|
|
319
548
|
maxArea: params.maxArea,
|
|
320
549
|
street: params.street,
|
|
550
|
+
include: params.includeDemographics ? "demographics" : undefined,
|
|
321
551
|
}, apiKey);
|
|
322
552
|
return textResponse(formatCompareResults(data) + formatCreditFooter(creditInfo));
|
|
323
553
|
}));
|
|
324
|
-
|
|
554
|
+
const DEMOGRAPHICS_TERYT_RE = /^(\d{2}|\d{4}|\d{6,7})$/;
|
|
555
|
+
const DEMOGRAPHICS_CATEGORIES = [
|
|
556
|
+
"demographics", "economy", "economy_macro", "housing", "planning",
|
|
557
|
+
"infrastructure", "environment", "safety", "re_market", "education", "prices",
|
|
558
|
+
];
|
|
559
|
+
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
|
+
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
|
+
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/county name, resolves to county/powiat level (e.g. 'Warszawa', 'Kraków'). Use this OR teryt. For gmina-level data pass a 6/7-digit teryt instead."),
|
|
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."),
|
|
564
|
+
year: z.number().int().optional().describe("Single year (2003-present). Mutually exclusive with yearFrom/yearTo. Omit for the latest available year per indicator."),
|
|
565
|
+
yearFrom: z.number().int().optional().describe("Start year for a time series (min 2003)."),
|
|
566
|
+
yearTo: z.number().int().optional().describe("End year for a time series (max current year + 1)."),
|
|
567
|
+
category: z.array(z.enum(DEMOGRAPHICS_CATEGORIES)).optional().describe("Filter to these categories. Omit to return all available."),
|
|
568
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Demographics & Local Statistics" }, async (params) => withErrorHandling("get_demographics", apiKey, async () => {
|
|
569
|
+
requireApiKey(apiKey);
|
|
570
|
+
const location = params.location?.trim();
|
|
571
|
+
const teryt = params.teryt?.trim();
|
|
572
|
+
if (!location && !teryt) {
|
|
573
|
+
return textResponse('Provide a location (city/county name) or teryt (administrative code). Example: get_demographics(location="Warszawa").');
|
|
574
|
+
}
|
|
575
|
+
if (teryt && !DEMOGRAPHICS_TERYT_RE.test(teryt)) {
|
|
576
|
+
return textResponse(`Invalid teryt '${sanitizeInput(teryt)}'. Use 2 digits (voivodeship), 4 (county), or 6-7 (gmina). Use list_locations to find codes.`);
|
|
577
|
+
}
|
|
578
|
+
const { data, creditInfo } = await getDemographics({
|
|
579
|
+
location,
|
|
580
|
+
teryt,
|
|
581
|
+
year: params.year,
|
|
582
|
+
yearFrom: params.yearFrom,
|
|
583
|
+
yearTo: params.yearTo,
|
|
584
|
+
category: params.category?.join(","),
|
|
585
|
+
}, apiKey);
|
|
586
|
+
return textResponse(formatDemographics(data) + formatCreditFooter(creditInfo));
|
|
587
|
+
}));
|
|
588
|
+
const INFRA_TERYT_RE = /^(\d{4}|\d{6,7})$/;
|
|
589
|
+
server.tool("get_infrastructure_signals", `Signals that a Polish municipality is about to build infrastructure — sewerage, water supply, roads, street lighting, gas network or cycling infrastructure. Three independent public sources: tenders published in the national public procurement bulletin (rolling 12-month window), membership in an agglomeration of the national urban waste-water treatment programme (where collective sewerage exists or is planned), and the municipality's own planned capital expenditure from its multi-year financial forecast.
|
|
590
|
+
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
|
+
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
|
+
Cost: 1 token.`, {
|
|
593
|
+
location: z.string().optional().describe("City or county name (e.g. 'Warszawa', 'Krotoszyn'). Aggregates every municipality in the county. Use this OR teryt."),
|
|
594
|
+
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
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Infrastructure Signals" }, async (params) => withErrorHandling("get_infrastructure_signals", apiKey, async () => {
|
|
596
|
+
requireApiKey(apiKey);
|
|
597
|
+
const location = params.location?.trim();
|
|
598
|
+
const teryt = params.teryt?.trim();
|
|
599
|
+
if (!location && !teryt) {
|
|
600
|
+
return textResponse('Provide a location (city/county name) or teryt (administrative code). Example: get_infrastructure_signals(location="Krotoszyn").');
|
|
601
|
+
}
|
|
602
|
+
if (teryt && !INFRA_TERYT_RE.test(teryt)) {
|
|
603
|
+
return textResponse(`Invalid teryt '${sanitizeInput(teryt)}'. Use 4 digits (county) or 6-7 digits (municipality). Use list_locations to find codes.`);
|
|
604
|
+
}
|
|
605
|
+
const { data, creditInfo } = await getInfrastructureSignals({ location, teryt }, apiKey);
|
|
606
|
+
return textResponse(formatInfrastructureSignals(data) + formatCreditFooter(creditInfo));
|
|
607
|
+
}));
|
|
608
|
+
server.tool("estimate_value", `[Beta] Estimate the market value of an apartment from comparable registered transaction prices near a point. An orientation estimate, NOT a certified appraisal (operat szacunkowy) — it does not account for the unit's condition, finish standard or floor, and does not replace a surveyor's valuation.
|
|
609
|
+
Address by lat + lng (a point on the map) OR parcelId (a full cadastral id or internal UUID; the parcel centroid is used) — exactly one. area (usable area in m², 10–250) is REQUIRED: there is no per-address floor-area source in Poland, so the caller supplies it.
|
|
610
|
+
Optional: rooms (1–10) and market (primary/secondary) narrow the comparables; includeComps (default true) echoes the nearest comparables it weighed.
|
|
611
|
+
Returns the point estimate, a likely and a wide value range, a confidence band, the comparable count, and an as_of date. as_of reflects transaction-data freshness, which lags by county — estimates are NOT directly comparable across cities with different as_of.
|
|
612
|
+
Apartments only (v1), 10–250 m². Too few comparables near the point → no estimate (the credit is refunded). Costs 5 API tokens, refunded when no estimate is produced. ${MARKET_CAVEAT}`, {
|
|
613
|
+
lat: z.number().min(49).max(55).optional().describe("Latitude of the apartment (WGS84, Poland). Must be paired with lng. Use this OR parcelId."),
|
|
614
|
+
lng: z.number().min(14).max(25).optional().describe("Longitude of the apartment (WGS84, Poland). Must be paired with lat. Use this OR parcelId."),
|
|
615
|
+
parcelId: z.string().max(200).optional().describe("Full cadastral id (slash or dash form) or internal UUID; the parcel centroid is used. Use instead of lat/lng."),
|
|
616
|
+
area: z.number().min(10).max(250).describe("Apartment usable area in m² (REQUIRED, 10–250). Estimates for 300+ m² are unreliable and rejected."),
|
|
617
|
+
rooms: z.number().int().min(1).max(10).optional().describe("Room count (1–10, optional) — narrows the comparables to ±1 room."),
|
|
618
|
+
market: z.enum(["primary", "secondary"]).optional().describe("Restrict comparables to the primary (new-build) or secondary market (optional)."),
|
|
619
|
+
includeComps: z.boolean().optional().describe("Echo the nearest comparables the estimate weighed (default true). Set false for the estimate only."),
|
|
620
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "[Beta] Apartment Value Estimate" }, async (params) => withErrorHandling("estimate_value", apiKey, async () => {
|
|
621
|
+
requireApiKey(apiKey);
|
|
622
|
+
const hasLat = params.lat != null;
|
|
623
|
+
const hasLng = params.lng != null;
|
|
624
|
+
const hasParcel = params.parcelId != null && params.parcelId !== "";
|
|
625
|
+
const latLngMode = hasLat || hasLng;
|
|
626
|
+
const modeCount = (latLngMode ? 1 : 0) + (hasParcel ? 1 : 0);
|
|
627
|
+
if (modeCount !== 1) {
|
|
628
|
+
return textResponse("Provide exactly one location: lat= and lng=, OR parcelId=.");
|
|
629
|
+
}
|
|
630
|
+
if (latLngMode && !(hasLat && hasLng)) {
|
|
631
|
+
return textResponse("lat and lng must be provided together.");
|
|
632
|
+
}
|
|
633
|
+
const { data, creditInfo } = await getValuation({
|
|
634
|
+
lat: params.lat,
|
|
635
|
+
lng: params.lng,
|
|
636
|
+
parcelId: params.parcelId,
|
|
637
|
+
area: params.area,
|
|
638
|
+
rooms: params.rooms,
|
|
639
|
+
market: params.market,
|
|
640
|
+
includeComps: params.includeComps ?? true,
|
|
641
|
+
}, apiKey);
|
|
642
|
+
return textResponse(formatValuation(data) + formatCreditFooter(creditInfo));
|
|
643
|
+
}));
|
|
644
|
+
if (experimentalToolsEnabled()) {
|
|
645
|
+
server.tool("get_rental_yield", `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it.
|
|
646
|
+
Estimate the gross rental yield for a Polish city or county: annualized median asking rent (PLN/m²/month × 12) divided by the median apartment transaction price per m² (secondary market) from the RCN registry.
|
|
647
|
+
Gross and top-line only — excludes vacancy, management, tax and maintenance. Indicative, not investment advice.
|
|
648
|
+
Address by location (city name → resolves to a county) OR teryt (4-digit county code; 6-digit = dzielnica where available, today Warszawa's 18 districts, otherwise truncated to the county; teryt wins when both are given). Both sides need at least 5 samples or the result is suppressed.
|
|
649
|
+
Not comparable across cities with different as_of dates (RCN publication lag varies by county). Rent and transaction prices come from different sources, so the yield is an approximation.
|
|
650
|
+
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
|
+
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
|
+
The transaction-price denominator uses the market median. ${MARKET_CAVEAT}`, {
|
|
653
|
+
location: z.string().optional().describe("County-level city name — must be a miasto na prawach powiatu or a catalog entry from list_rental_yield_locations (e.g. 'Warszawa', 'Kraków', 'Gdańsk'). A town within a larger powiat, a non-Warszawa district, or an osiedle will 404 — check the catalog or use teryt first. Use this OR teryt."),
|
|
654
|
+
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
|
+
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
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "[Beta] Rental Yield Estimate" }, async (params) => withErrorHandling("get_rental_yield", apiKey, async () => {
|
|
657
|
+
requireApiKey(apiKey);
|
|
658
|
+
if (!params.location?.trim() && !params.teryt?.trim()) {
|
|
659
|
+
return textResponse('Provide a location (city name) or teryt (county code). Example: get_rental_yield(location="Warszawa").');
|
|
660
|
+
}
|
|
661
|
+
const { data, creditInfo } = await getRentalYield({ location: params.location, teryt: params.teryt, areaBucket: params.areaBucket }, apiKey);
|
|
662
|
+
return textResponse(formatRentalYield(data) + formatCreditFooter(creditInfo));
|
|
663
|
+
}));
|
|
664
|
+
server.tool("list_rental_yield_locations", `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it.
|
|
665
|
+
List the cities/counties for which get_rental_yield can return data (asking-rent coverage). Use this to discover valid location/teryt values for get_rental_yield instead of guessing names.
|
|
666
|
+
Each entry is coverage signal only (offer sample size + confidence) — it does not compute the yield; call get_rental_yield(location|teryt) for the actual yield.
|
|
667
|
+
Optional search filters by city name (diacritic-insensitive substring, min 2 chars). Results are sorted by rent_sample_n descending. Free (0 credits).`, {
|
|
668
|
+
search: z.string().min(2, "search must be at least 2 characters").optional().describe("Filter by city name (diacritic-insensitive substring, min 2 chars). E.g. 'gda' → Gdańsk. Omit to list the full catalog."),
|
|
669
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "[Beta] List Rental Yield Locations" }, async (params) => withErrorHandling("list_rental_yield_locations", apiKey, async () => {
|
|
670
|
+
requireApiKey(apiKey);
|
|
671
|
+
const { data, creditInfo } = await getRentalYieldLocations({ search: params.search }, apiKey);
|
|
672
|
+
return textResponse(formatRentalYieldLocations(data) + formatCreditFooter(creditInfo));
|
|
673
|
+
}));
|
|
674
|
+
server.tool("get_price_spread", `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it.
|
|
675
|
+
Measure the asking-vs-transaction price spread for a Polish city or county: how far the median asking price per m² of apartments for sale sits above (or below) the median apartment transaction price per m² from the RCN registry. spread_pct = (asking − transaction) / transaction × 100.
|
|
676
|
+
The spread can be NEGATIVE (asking below transaction) in premium-secondary cities — that is a valid answer, not an error.
|
|
677
|
+
Address by location (city name → resolves to a county) OR teryt (4-digit county code; 6-digit = dzielnica where available, today Warszawa's 18 districts, otherwise truncated to the county; teryt wins when both are given). Both sides need at least 5 samples or the result is suppressed.
|
|
678
|
+
For marketType='all' (the default), sale offers are a mix of primary and secondary market, so the transaction denominator covers the whole market. With marketType='secondary' or 'primary', both the asking and transaction sides are narrowed to that single market segment.
|
|
679
|
+
Not comparable across cities with different as_of dates (RCN publication lag varies by county). Asking and transaction prices come from different sources, so the spread is an approximation.
|
|
680
|
+
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
|
+
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
|
+
The transaction-price denominator uses the market median. ${MARKET_CAVEAT}`, {
|
|
683
|
+
location: z.string().optional().describe("County-level city name — must be a miasto na prawach powiatu or a catalog entry from list_price_spread_locations (e.g. 'Warszawa', 'Kraków', 'Gdańsk'). A town within a larger powiat, a non-Warszawa district, or an osiedle will 404 — check the catalog or use teryt first. Use this OR teryt."),
|
|
684
|
+
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
|
+
marketType: z.enum(["primary", "secondary", "all"]).optional().describe("Transaction denominator segment: 'all' (default, composition-matched to mixed sale offers), 'secondary', or 'primary'."),
|
|
686
|
+
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."),
|
|
687
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "[Beta] Asking vs Transaction Price Spread" }, async (params) => withErrorHandling("get_price_spread", apiKey, async () => {
|
|
688
|
+
requireApiKey(apiKey);
|
|
689
|
+
if (!params.location?.trim() && !params.teryt?.trim()) {
|
|
690
|
+
return textResponse('Provide a location (city name) or teryt (county code). Example: get_price_spread(location="Warszawa").');
|
|
691
|
+
}
|
|
692
|
+
const { data, creditInfo } = await getPriceSpread({ location: params.location, teryt: params.teryt, marketType: params.marketType, areaBucket: params.areaBucket }, apiKey);
|
|
693
|
+
return textResponse(formatPriceSpread(data) + formatCreditFooter(creditInfo));
|
|
694
|
+
}));
|
|
695
|
+
server.tool("list_price_spread_locations", `EXPERIMENTAL (beta): this tool may change or be withdrawn without notice; do not build critical workflows on it.
|
|
696
|
+
List the cities/counties for which get_price_spread can return data (asking-sale coverage). Use this to discover valid location/teryt values for get_price_spread instead of guessing names.
|
|
697
|
+
Each entry is coverage signal only (sale offer sample size + confidence) — it does not compute the spread; call get_price_spread(location|teryt) for the actual spread.
|
|
698
|
+
Optional search filters by city name (diacritic-insensitive substring, min 2 chars). Results are sorted by asking_sample_n descending. Free (0 credits).`, {
|
|
699
|
+
search: z.string().min(2, "search must be at least 2 characters").optional().describe("Filter by city name (diacritic-insensitive substring, min 2 chars). E.g. 'gda' → Gdańsk. Omit to list the full catalog."),
|
|
700
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "[Beta] List Price Spread Locations" }, async (params) => withErrorHandling("list_price_spread_locations", apiKey, async () => {
|
|
701
|
+
requireApiKey(apiKey);
|
|
702
|
+
const { data, creditInfo } = await getPriceSpreadLocations({ search: params.search }, apiKey);
|
|
703
|
+
return textResponse(formatPriceSpreadLocations(data) + formatCreditFooter(creditInfo));
|
|
704
|
+
}));
|
|
705
|
+
}
|
|
706
|
+
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
|
+
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).
|
|
708
|
+
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
|
+
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
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Building-by-Building Breakdown" }, async (params) => withErrorHandling("get_building_breakdown", apiKey, async () => {
|
|
711
|
+
requireApiKey(apiKey);
|
|
712
|
+
const { data: res, creditInfo } = await getBuildingBreakdown(params.transaction_id, apiKey);
|
|
713
|
+
return textResponse(formatBuildingBreakdown(res) + formatCreditFooter(creditInfo));
|
|
714
|
+
}));
|
|
715
|
+
server.tool("get_transaction_flood", `Get the parcel-by-parcel flood-hazard breakdown for one transaction: for each linked plot that sits in a mapped flood zone — the worst hazard category (high/medium/low, i.e. ~1-in-10-year to ~1-in-500-year), the hazard type (river/coastal/infrastructure), the share of the plot inside the zone, and the full per-scenario list (each with its return period).
|
|
716
|
+
search_transactions (and search_by_area) surface a per-transaction worst-case flood_risk inline; this tool splits that into the individual parcels and scenarios behind it. Use it after a search when a result shows flood_risk. (search_by_polygon does not include flood inline.)
|
|
717
|
+
TWO-STATE: a transaction whose land is in no mapped zone returns nothing — absence of a zone is never asserted as "safe". Cost: 4 tokens (refunded when there is no flood data).`, {
|
|
718
|
+
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."),
|
|
719
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Flood-Hazard Breakdown" }, async (params) => withErrorHandling("get_transaction_flood", apiKey, async () => {
|
|
720
|
+
requireApiKey(apiKey);
|
|
721
|
+
const { data: res, creditInfo } = await getTransactionFlood(params.transaction_id, apiKey);
|
|
722
|
+
return textResponse(formatFloodBreakdown(res) + formatCreditFooter(creditInfo));
|
|
723
|
+
}));
|
|
724
|
+
server.tool("get_transaction_heritage", `Get the parcel-by-parcel heritage-listing breakdown for one transaction: for each linked plot with a detected heritage listing — the status (listed = a protected monument on/at the plot; zone = the plot lies within a protected urban layout or the designated surroundings of a monument), the share of the plot inside the protected area (when measurable), and the individual entries (category, name, function, period, entry date).
|
|
725
|
+
search_transactions (and search_by_area) surface a per-transaction heritage_status inline; this tool splits that into the individual parcels and entries behind it. Use it after a search when a result shows a heritage listing. (search_by_polygon does not include heritage inline.)
|
|
726
|
+
TWO-STATE: a transaction with no detected listing returns nothing — absence of a detection is never asserted as "not listed". Indicative data — the regional heritage conservator makes the final, binding determination. Cost: 4 tokens (refunded when there is no heritage data).`, {
|
|
727
|
+
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."),
|
|
728
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Heritage-Listing Breakdown" }, async (params) => withErrorHandling("get_transaction_heritage", apiKey, async () => {
|
|
729
|
+
requireApiKey(apiKey);
|
|
730
|
+
const { data: res, creditInfo } = await getTransactionHeritage(params.transaction_id, apiKey);
|
|
731
|
+
return textResponse(formatHeritageBreakdown(res) + formatCreditFooter(creditInfo));
|
|
732
|
+
}));
|
|
733
|
+
server.tool("get_transaction_landslide", `Get the parcel-by-parcel landslide-hazard breakdown for one transaction, based on official landslide-hazard maps (1:10,000 scale): for each linked plot that intersects a mapped hazard area — the worst category ('landslide' = a mapped landslide area, 'threatened' = an area threatened by mass movements), the share of the plot inside the mapped zones, and the per-zone list (each with its source_version_date — the source-record version date, not a survey/observation date).
|
|
734
|
+
An intersection at this scale means the parcel overlaps a mapped hazard area, not that the parcel itself is a landslide.
|
|
735
|
+
search_transactions (and search_by_area) surface a per-transaction worst-case landslide_risk inline; this tool splits that into the individual parcels and zones behind it. Use it after a search when a result shows a landslide risk. (search_by_polygon does not include landslide inline.)
|
|
736
|
+
TWO-STATE: a transaction whose land is in no mapped zone returns nothing — absence of data is never an assertion of safety. Cost: 4 tokens (refunded when there is no landslide data).`, {
|
|
737
|
+
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."),
|
|
738
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Landslide-Hazard Breakdown" }, async (params) => withErrorHandling("get_transaction_landslide", apiKey, async () => {
|
|
739
|
+
requireApiKey(apiKey);
|
|
740
|
+
const { data: res, creditInfo } = await getTransactionLandslide(params.transaction_id, apiKey);
|
|
741
|
+
return textResponse(formatLandslideBreakdown(res) + formatCreditFooter(creditInfo));
|
|
742
|
+
}));
|
|
743
|
+
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, and intensive livestock farm, from reference land-use and environmental-registry data. Useful for due-diligence on nearby nuisances.
|
|
744
|
+
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.
|
|
745
|
+
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
|
+
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
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Surroundings Breakdown" }, async (params) => withErrorHandling("get_transaction_surroundings", apiKey, async () => {
|
|
748
|
+
requireApiKey(apiKey);
|
|
749
|
+
const { data: res, creditInfo } = await getTransactionSurroundings(params.transaction_id, apiKey);
|
|
750
|
+
return textResponse(formatSurroundings(res) + formatCreditFooter(creditInfo));
|
|
751
|
+
}));
|
|
752
|
+
server.tool("get_transaction_transit", `Get the parcel-by-parcel public transport access breakdown for one transaction: for each linked plot, the nearest public transport stop distances per transaction parcel, by mode (rail/metro/tram/bus), from open GTFS data — plus the nearest stop's name for each mode present.
|
|
753
|
+
A mode is present only when a stop of that mode is within its cap (rail/metro 3000 m, tram 1500 m, bus 1000 m).
|
|
754
|
+
TWO-STATE: a transaction whose land has no stop within cap in any mode returns nothing — absence of a row is never asserted as "no transit access" (open feeds cover cities and national rail, not every rural area). Cost: 4 tokens (refunded when there is no transit data).`, {
|
|
755
|
+
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."),
|
|
756
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Public Transport Access Breakdown" }, async (params) => withErrorHandling("get_transaction_transit", apiKey, async () => {
|
|
757
|
+
requireApiKey(apiKey);
|
|
758
|
+
const { data: res, creditInfo } = await getTransactionTransit(params.transaction_id, apiKey);
|
|
759
|
+
return textResponse(formatTransitBreakdown(res) + formatCreditFooter(creditInfo));
|
|
760
|
+
}));
|
|
761
|
+
server.tool("get_transaction_permits", `Get the building-permit history for one transaction's parcels, from the official national registry of positively resolved 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.
|
|
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, and only positively resolved cases are held (no pending or refused applications).
|
|
763
|
+
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
|
+
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
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Building-Permit History" }, async (params) => withErrorHandling("get_transaction_permits", apiKey, async () => {
|
|
766
|
+
requireApiKey(apiKey);
|
|
767
|
+
const { data: res, creditInfo } = await getTransactionPermits(params.transaction_id, apiKey);
|
|
768
|
+
return textResponse(formatPermitsBreakdown(res) + formatCreditFooter(creditInfo));
|
|
769
|
+
}));
|
|
770
|
+
server.tool("get_transaction_planning", `Get the general-plan (plan ogólny, POG) zoning for one transaction's land: for each linked plot, the planning zones that cover it — zone symbol and name, the share of the plot each zone covers, and the building parameters the plan sets (max building height, max development intensity, max built-up coverage, min biologically active area) — plus any overlay areas (infill development area / obszar uzupełnienia zabudowy, central development area) that sit on top.
|
|
771
|
+
Coverage is honest and THREE-STATE: 'covered' returns zone data; 'covered_no_data' means the municipality has an adopted general plan but no zone data covers these plots in the data yet; 'not_covered' means no published general-plan data for this municipality yet — this is NEVER a claim that the municipality has no plan. General plans are still being adopted across Poland, so coverage grows over time.
|
|
772
|
+
Use it for feasibility and permitted-use questions on a plot. Cost: 4 tokens (refunded when there is no zone data for the transaction — 'covered_no_data' or 'not_covered').`, {
|
|
773
|
+
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."),
|
|
774
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction General-Plan Zoning" }, async (params) => withErrorHandling("get_transaction_planning", apiKey, async () => {
|
|
775
|
+
requireApiKey(apiKey);
|
|
776
|
+
const { data: res, creditInfo } = await getTransactionPlanning(params.transaction_id, apiKey);
|
|
777
|
+
return textResponse(formatPlanningBreakdown(res) + formatCreditFooter(creditInfo));
|
|
778
|
+
}));
|
|
779
|
+
server.tool("get_transaction_farmland", `Get the parcel-by-parcel agricultural land-eligibility breakdown for one transaction, from official nationwide agricultural land-eligibility data (updated weekly): for each linked parcel with a matched eligible agricultural area — the eligible area in square metres, its share of the parcel (when the parcel's measured area is known), and how many source features compose it. The response also reports how many of the transaction's linked parcels carry a match and the source snapshot date. Useful for due-diligence on land that is actually eligible/maintained as agricultural (beyond what a registry classification says on paper).
|
|
780
|
+
TWO-STATE: a parcel with no matched eligible area returns nothing — absence of a match is NEVER a statement that the property is non-agricultural (small plots that are not actively farmed are simply absent, the reference layer has its own update cadence, and older transactions can reference renumbered parcels). Cost: 4 tokens (refunded when there is no eligible agricultural area for the linked parcels).`, {
|
|
781
|
+
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."),
|
|
782
|
+
}, { readOnlyHint: true, destructiveHint: false, title: "Transaction Agricultural Land-Eligibility Breakdown" }, async (params) => withErrorHandling("get_transaction_farmland", apiKey, async () => {
|
|
783
|
+
requireApiKey(apiKey);
|
|
784
|
+
const { data: res, creditInfo } = await getTransactionFarmland(params.transaction_id, apiKey);
|
|
785
|
+
return textResponse(formatFarmland(res) + formatCreditFooter(creditInfo));
|
|
786
|
+
}));
|
|
787
|
+
}
|