@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/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ export declare function serverInstructions(): string;
3
4
  export declare function createMcpServer(apiKey?: string): McpServer;
package/dist/index.js CHANGED
@@ -1,80 +1,179 @@
1
1
  #!/usr/bin/env node
2
+ import { Sentry } from "./sentry.js";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { dirname, join } from "node:path";
6
7
  import { readFileSync, realpathSync } from "node:fs";
7
- import { registerTools } from "./tools.js";
8
- // ── Node version check ─────────────────────────────────────────────
8
+ import { fetch } from "undici";
9
+ import { registerTools, experimentalToolsEnabled } from "./tools.js";
10
+ import { dispatchAuth, sanitizeForLog } from "./auth-dispatch.js";
11
+ import { requestContext } from "./request-context.js";
12
+ import { signupUrl } from "./error-messages.js";
13
+ import { channelSrc, isHttpMode } from "./transport-mode.js";
14
+ function logAuth(payload, level) {
15
+ process.stderr.write(JSON.stringify({ level, ...payload }) + "\n");
16
+ }
9
17
  const [nodeMajor] = process.versions.node.split(".").map(Number);
10
18
  if (nodeMajor != null && nodeMajor < 18) {
11
19
  process.stderr.write(`Warning: @cenogram/mcp-server recommends Node.js >= 18 (current: ${process.version}). ` +
12
20
  "The server will try to run, but some features may not work.\n");
13
21
  }
14
- // ── Version ────────────────────────────────────────────────────────
15
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
23
  let PKG_VERSION = "0.1.0";
17
24
  try {
18
25
  PKG_VERSION = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
19
26
  }
20
- catch { /* fallback to hardcoded if dist/ used standalone */ }
21
- // ── Server factory ──────────────────────────────────────────────────
27
+ catch { }
28
+ export function serverInstructions() {
29
+ return [
30
+ "Cenogram MCP Server - 8M+ verified real estate transactions from Poland's official RCN registry (Rejestr Cen Nieruchomości). Transaction prices from notarial deeds - NOT asking/listing prices. Data from 2003 to present, 380 counties, refreshed every ~2 weeks.",
31
+ "",
32
+ "CRITICAL - District names (ALWAYS verify first):",
33
+ "- NEVER guess district names. Call list_locations(search=\"city\") first.",
34
+ "- Warsaw: 'Warszawa' auto-includes all 18 districts. Or use specific: Mokotów, Wola, Śródmieście",
35
+ "- Kraków/Łódź: 'Kraków'/'Łódź' auto-include all sub-districts. Or use specific: Kraków-Podgórze, etc.",
36
+ "- Most cities (Gdańsk, Gdynia, Sopot, Poznań, Wrocław): just the city name, no sub-districts",
37
+ "- Neighborhoods/osiedla (Nowy Dwór, Oliwa, Jeżyce) are NOT TERYT districts. Start with search_by_area (radiusKm 0.5-1.0), then refine with search_by_polygon if needed.",
38
+ "- TERYT hierarchy: For precise administrative filtering (avoids name ambiguity), use list_locations(parent) to browse TERYT codes, then search_transactions(teryt=code).",
39
+ "- Use 'location' for quick city searches, 'teryt' when you need exact administrative boundaries.",
40
+ "",
41
+ "Workflows:",
42
+ "- Market analysis: get_market_overview → get_price_statistics(location) → search_transactions",
43
+ ...(experimentalToolsEnabled()
44
+ ? [
45
+ "- Rental yield: (experimental - may change/withdraw) list_rental_yield_locations (catalog of covered cities) → get_rental_yield(location|teryt) - indicative gross yield from asking rent vs RCN transaction prices. County level only (miasta na prawach powiatu), or Warszawa district via 6-digit teryt. For any name that is not a major city you know is covered, call the catalog FIRST or pass a county teryt - towns inside a larger powiat and non-Warszawa districts 404.",
46
+ "- Price spread: (experimental - may change/withdraw) list_price_spread_locations (catalog of covered cities) → get_price_spread(location|teryt) - asking-vs-transaction price spread %, marketType all|secondary|primary. County level only (miasta na prawach powiatu), or Warszawa district via 6-digit teryt. For any name that is not a major city you know is covered, call the catalog FIRST or pass a county teryt - towns inside a larger powiat and non-Warszawa districts 404.",
47
+ ]
48
+ : []),
49
+ "- Compare locations: list_locations → compare_locations (2-5 districts, requires at least one filter e.g. propertyType). Add includeDemographics=true for a GUS BDL block per district.",
50
+ "- Demographics & local stats: get_demographics(location|teryt) — GUS BDL indicators (population, economy, housing, planning, safety, education, prices). A city name resolves to county level; pass a 6/7-digit teryt for gmina-level detail.",
51
+ "- Parcel lookup: search_parcels(q, min 3 chars) → search_by_area (use returned lat/lng)",
52
+ "- Parcel resolve: resolve_parcel(parcelId | q | lat+lng) — turn a full parcel id, a UUID, a 'locality name + parcel number', or a coordinate into a concrete parcel; feed the returned id to search_transactions(parcelId) for its sale history.",
53
+ "- Per-building detail: search_transactions → get_building_breakdown(transaction_id) — footprint, storeys, est. total floor area per building (searches return per-transaction sums inline)",
54
+ "- Surroundings (nearby nuisances): search_transactions → get_transaction_surroundings(transaction_id) — per-plot distance to the nearest cemetery, landfill, sewage treatment plant, industrial/storage area, large industrial plant, and intensive livestock farm. A null distance = nothing within the search radius in reference data, never a guarantee.",
55
+ "- Address search: search_transactions(location, street, buildingNumber)",
56
+ "- Radius search: search_by_area(lat, lng, radiusKm) - for geographic proximity",
57
+ "- Polygon search: search_by_polygon - coordinates are [longitude, latitude], first=last point, max 500 vertices",
58
+ "- TERYT drill-down: list_locations() → list_locations(parent=voivodeshipCode) → list_locations(parent=countyCode) → search_transactions(teryt=municipalityCode)",
59
+ "",
60
+ "Data notes:",
61
+ "- Median/average prices are market-based: fractional ownership shares (share_basis=\"fraction\") and non-market deeds (public tenders, foreclosures, privileged/subsidized sales) are excluded from price aggregates. Transaction counts and coverage stay full. search_transactions/search_by_area flag fractional-share rows so you can spot which comparables are partial.",
62
+ "- price_per_m2 only meaningful for apartments (propertyType=\"unit\")",
63
+ "- Field provenance (search_transactions/search_by_area/search_by_polygon): per-record values are from the notarial deed (RCN) by default and carry no marker. Values we computed (parcel area summed across plots or converted from hectares; an inferred or reclassified property type) and approximated streets are flagged inline with a neutral [...] note — mirroring the \"Z RCN / Obliczone / Uzupełnione\" tiers on cenogram.pl. A field being absent from a result does NOT mean the deed omitted it: the county may simply not report that field.",
64
+ "- Rooms (izby) filter: search_transactions/search_by_area/search_by_polygon/compare_locations accept `rooms` (array, e.g. [\"2\",\"3\"]; \"8plus\" = 8 or more). Units only (propertyType=\"unit\"); rows with no room count are excluded. NOTE: RCN counts izby (chambers - a kitchen counts as one izba), so values run higher than portal \"pokoje\" listings.",
65
+ "- Floor (piętro) filter: the same tools accept `floor` (array, e.g. [\"0\",\"1\",\"2\"]; \"0\" = ground/parter, negatives = basement, \"10plus\" = 10 or more, \"0plus\" = ground and above, \"unknown\" = no floor recorded). Units only; this is the unit's floor, NOT the number of building storeys. Rows with no floor are excluded unless \"unknown\" is included.",
66
+ "- Results paginated (default 10-20). Use page parameter for more.",
67
+ `- For §79-compliant export table or interactive map - direct user to https://cenogram.pl/ceny-transakcyjne?src=${channelSrc()}`,
68
+ `- Deep link / permalink to the map: from a transaction's \`id\` 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 a link centered on the area without a specific row open.`,
69
+ ].join("\n");
70
+ }
22
71
  export function createMcpServer(apiKey) {
23
- const server = new McpServer({ name: "cenogram-mcp-server", version: PKG_VERSION }, {
24
- instructions: [
25
- "Cenogram MCP Server - 7M+ verified real estate transactions from Poland's official RCN registry (Rejestr Cen Nieruchomości). Transaction prices from notarial deeds - NOT asking/listing prices. Data from 2003 to present, ~380 counties, refreshed every ~2 weeks.",
26
- "",
27
- "CRITICAL - District names (ALWAYS verify first):",
28
- "- NEVER guess district names. Call list_locations(search=\"city\") first.",
29
- "- Warsaw: 'Warszawa' auto-includes all 18 districts. Or use specific: Mokotów, Wola, Śródmieście",
30
- "- Kraków/Łódź: 'Kraków'/'Łódź' auto-include all sub-districts. Or use specific: Kraków-Podgórze, etc.",
31
- "- Most cities (Gdańsk, Gdynia, Sopot, Poznań): just the city name, no sub-districts",
32
- "",
33
- "Workflows:",
34
- "- Market analysis: get_market_overview → get_price_statistics(location) → search_transactions",
35
- "- Compare locations: list_locations → compare_locations (2-5 districts, requires at least one filter e.g. propertyType)",
36
- "- Parcel lookup: search_parcels(q, min 3 chars) → search_by_area (use returned lat/lng)",
37
- "- Address search: search_transactions(location, street, buildingNumber)",
38
- "- Radius search: search_by_area(lat, lng, radiusKm) - for geographic proximity",
39
- "- Polygon search: search_by_polygon - coordinates are [longitude, latitude], first=last point, max 500 vertices",
40
- "",
41
- "Data notes:",
42
- "- price_per_m2 only meaningful for apartments (propertyType=\"unit\")",
43
- "- API has no rooms filter - use area as proxy (1-room: 20-35m², 2: 35-55m², 3: 55-90m², 4+: 80-130m²), then post-filter by rooms field in results",
44
- "- Results paginated (default 10-20). Use page parameter for more.",
45
- "- For §79-compliant export table or interactive map - direct user to cenogram.pl",
46
- ].join("\n"),
47
- });
72
+ const server = new McpServer({ name: "cenogram-mcp-server", version: PKG_VERSION }, { instructions: serverInstructions() });
48
73
  registerTools(server, apiKey);
49
74
  return server;
50
75
  }
51
- // ── Start ───────────────────────────────────────────────────────────
52
76
  async function main() {
53
- const mode = process.argv.includes("--http") || process.env.MCP_TRANSPORT === "http" ? "http" : "stdio";
77
+ const mode = isHttpMode() ? "http" : "stdio";
54
78
  if (mode === "http") {
55
79
  const { createServer } = await import("node:http");
56
80
  const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
81
+ if (process.env.NODE_ENV === "production") {
82
+ if (!process.env.OAUTH_JWT_KID)
83
+ throw new Error("OAUTH_JWT_KID required in production");
84
+ if (!process.env.OAUTH_JWT_PUBLIC_KEY)
85
+ throw new Error("OAUTH_JWT_PUBLIC_KEY required in production");
86
+ if (!process.env.INTERNAL_AUTH_SECRET)
87
+ throw new Error("INTERNAL_AUTH_SECRET required in production");
88
+ }
57
89
  const port = parseInt(process.env.MCP_PORT || "3002", 10);
58
90
  const handleHttpRequest = async (req, res) => {
59
91
  try {
60
92
  const pathname = req.url?.split("?")[0];
61
93
  if (pathname === "/mcp") {
62
- const auth = req.headers.authorization;
63
- const apiKeyFromHeader = auth?.startsWith("Bearer ") ? auth.slice(7).trim() : undefined;
64
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
65
- const mcpServer = createMcpServer(apiKeyFromHeader);
66
- try {
67
- await mcpServer.connect(transport);
68
- await transport.handleRequest(req, res);
94
+ const dispatch = await dispatchAuth(req.headers.authorization);
95
+ if (dispatch.kind === "passthrough") {
96
+ logAuth(dispatch.log, "info");
69
97
  }
70
- finally {
71
- await transport.close();
72
- await mcpServer.close();
98
+ else if (dispatch.kind === "500") {
99
+ logAuth(dispatch.log, "error");
100
+ res.writeHead(500, dispatch.headers).end(JSON.stringify(dispatch.body));
101
+ return;
73
102
  }
103
+ else {
104
+ const level = dispatch.kind === "401" && dispatch.log.reason === "missing" ? "info" : "warn";
105
+ const clientIp = (req.headers["cf-connecting-ip"] ?? req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "");
106
+ logAuth({
107
+ ...dispatch.log,
108
+ method: req.method,
109
+ ua: sanitizeForLog((req.headers["user-agent"] ?? "").slice(0, 128)),
110
+ ip: sanitizeForLog(clientIp.split(",")[0].trim().slice(0, 45)),
111
+ }, level);
112
+ const status = dispatch.kind === "401" ? 401 : 403;
113
+ res.writeHead(status, dispatch.headers).end(JSON.stringify(dispatch.body));
114
+ return;
115
+ }
116
+ const clientUA = (req.headers["user-agent"] ?? "").slice(0, 512) || undefined;
117
+ await requestContext.run({ clientUserAgent: clientUA }, async () => {
118
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
119
+ const mcpServer = createMcpServer(dispatch.apiKey);
120
+ try {
121
+ await mcpServer.connect(transport);
122
+ await transport.handleRequest(req, res);
123
+ }
124
+ finally {
125
+ await transport.close();
126
+ await mcpServer.close();
127
+ }
128
+ });
129
+ }
130
+ else if (pathname === "/.well-known/oauth-protected-resource") {
131
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
132
+ resource: "https://mcp.cenogram.pl",
133
+ authorization_servers: ["https://api.cenogram.pl"],
134
+ scopes_supported: ["mcp"],
135
+ bearer_methods_supported: ["header"],
136
+ }));
137
+ }
138
+ else if (pathname === "/.well-known/mcp.json") {
139
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
140
+ name: "Cenogram",
141
+ description: "8M+ verified real estate transactions from Poland's official RCN registry. Transaction prices from notarial deeds, 380 counties, data from 2003.",
142
+ icon: "https://cenogram.pl/apple-touch-icon.png",
143
+ endpoint: "https://mcp.cenogram.pl/mcp",
144
+ }));
74
145
  }
75
146
  else if (pathname === "/health") {
76
147
  res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
77
148
  }
149
+ else if (pathname === "/health/deep") {
150
+ const apiUrl = process.env.CENOGRAM_API_URL || "https://cenogram.pl";
151
+ const controller = new AbortController();
152
+ const timeout = setTimeout(() => controller.abort(), 3_000);
153
+ let apiStatus = "fail";
154
+ let apiError;
155
+ try {
156
+ const apiRes = await fetch(`${apiUrl}/api/health`, { signal: controller.signal });
157
+ if (apiRes.ok) {
158
+ apiStatus = "ok";
159
+ }
160
+ else {
161
+ apiError = `HTTP ${apiRes.status}`;
162
+ }
163
+ }
164
+ catch (err) {
165
+ apiError = err instanceof Error ? err.message : String(err);
166
+ }
167
+ finally {
168
+ clearTimeout(timeout);
169
+ }
170
+ const code = apiStatus === "ok" ? 200 : 503;
171
+ res.writeHead(code, { "Content-Type": "application/json" }).end(JSON.stringify({
172
+ status: apiStatus === "ok" ? "ok" : "degraded",
173
+ dependencies: { api: apiStatus, ...(apiError ? { apiError } : {}) },
174
+ apiUrl,
175
+ }));
176
+ }
78
177
  else if (pathname === "/robots.txt") {
79
178
  res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "public, max-age=86400" }).end("User-agent: *\nDisallow: /\n");
80
179
  }
@@ -83,6 +182,7 @@ async function main() {
83
182
  }
84
183
  }
85
184
  catch (err) {
185
+ Sentry.captureException(err, { tags: { error_layer: "http_handler" } });
86
186
  process.stderr.write(`HTTP error: ${String(err)}\n`);
87
187
  if (!res.headersSent) {
88
188
  res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }));
@@ -100,7 +200,7 @@ async function main() {
100
200
  else {
101
201
  if (!process.env.CENOGRAM_API_KEY) {
102
202
  process.stderr.write("Error: CENOGRAM_API_KEY is required.\n" +
103
- "Get your free API key at https://cenogram.pl/api\n" +
203
+ `Get your free API key at ${signupUrl()}\n` +
104
204
  "Then add it to your MCP config:\n" +
105
205
  ' "env": { "CENOGRAM_API_KEY": "cngrm_..." }\n');
106
206
  process.exit(1);
@@ -110,7 +210,12 @@ async function main() {
110
210
  }
111
211
  }
112
212
  if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
113
- main().catch((err) => {
213
+ process.on("SIGTERM", () => {
214
+ void Sentry.flush(2000).then(() => process.exit(0));
215
+ });
216
+ main().catch(async (err) => {
217
+ Sentry.captureException(err, { tags: { error_layer: "fatal" } });
218
+ await Sentry.flush(2000);
114
219
  process.stderr.write(`Fatal: ${String(err)}\n`);
115
220
  process.exit(1);
116
221
  });
@@ -3,13 +3,20 @@ export declare const MARKET_TYPES: Record<number, string>;
3
3
  export declare function mapPropertyType(value: string | undefined): number | undefined;
4
4
  export declare function mapMarketType(value: string | undefined): number | undefined;
5
5
  export declare const UNIT_FUNCTIONS: Record<number, string>;
6
- export declare function mapUnitFunction(value: string | undefined): number | undefined;
6
+ export declare function mapUnitFunction(value: string | undefined): number | "unknown" | undefined;
7
7
  export declare const BUILDING_TYPES: Record<number, string>;
8
- export declare function mapBuildingType(value: string | undefined): number | undefined;
9
- /** Convert lat/lng/radius to bbox [minLng, minLat, maxLng, maxLat] (lng-first!) */
8
+ export declare function mapBuildingType(value: string | undefined): number | "unknown" | undefined;
9
+ export declare const OWNERSHIP_TYPES: Record<number, string>;
10
+ export declare function mapOwnershipTypes(values: string[] | undefined): string | undefined;
11
+ export declare const PARTY_TYPES: Record<number, string>;
12
+ export declare const LAND_USES: Record<string, string>;
13
+ export declare const TRANSACTION_TYPES: Record<number, string>;
14
+ export declare function mapTransactionTypes(values: string[] | undefined): string | undefined;
10
15
  export declare function radiusKmToBbox(lat: number, lng: number, radiusKm: number): [number, number, number, number];
11
- /** Filter districts by location name (case-insensitive includes match) */
16
+ export declare function stripDiacritics(s: string): string;
12
17
  export declare function filterByLocation(location: string, districts: string[]): string[];
13
18
  export declare const CITY_SUBDISTRICTS: ReadonlyMap<string, readonly string[]>;
14
- /** Returns sub-districts for known multi-district cities, or [district] for everything else. */
15
19
  export declare function expandDistrict(district: string): string[];
20
+ export declare function buildNormalizedMap(districts: string[]): Map<string, string[]>;
21
+ export declare function tryResolveCityKey(input: string): string[] | null;
22
+ export declare function resolveDistrict(input: string, allDistricts: string[]): string[];
package/dist/mappings.js CHANGED
@@ -1,4 +1,3 @@
1
- // ── Property & market type enum maps ────────────────────────────────
2
1
  export const PROPERTY_TYPES = {
3
2
  1: "Land (Grunt)",
4
3
  2: "Building (Budynek)",
@@ -29,7 +28,6 @@ export function mapMarketType(value) {
29
28
  return undefined;
30
29
  return MARKET_TYPE_MAP[value];
31
30
  }
32
- // ── Unit function enum maps ─────────────────────────────────────────
33
31
  export const UNIT_FUNCTIONS = {
34
32
  1: "Residential (Mieszkalna)",
35
33
  2: "Commercial (Handlowo-usługowa)",
@@ -45,13 +43,13 @@ const UNIT_FUNCTION_MAP = {
45
43
  production: 4,
46
44
  garage: 5,
47
45
  other: 6,
46
+ unknown: "unknown",
48
47
  };
49
48
  export function mapUnitFunction(value) {
50
49
  if (!value)
51
50
  return undefined;
52
51
  return UNIT_FUNCTION_MAP[value];
53
52
  }
54
- // ── Building type enum maps ─────────────────────────────────────────
55
53
  export const BUILDING_TYPES = {
56
54
  110: "Residential (Mieszkalny)",
57
55
  121: "Commercial (Handlowo-usługowy)",
@@ -75,33 +73,99 @@ const BUILDING_TYPE_MAP = {
75
73
  farm_utility: 127,
76
74
  hospital: 128,
77
75
  other_nonresidential: 129,
76
+ unknown: "unknown",
78
77
  };
79
78
  export function mapBuildingType(value) {
80
79
  if (!value)
81
80
  return undefined;
82
81
  return BUILDING_TYPE_MAP[value];
83
82
  }
84
- // ── Bbox conversion ─────────────────────────────────────────────────
85
- /** Convert lat/lng/radius to bbox [minLng, minLat, maxLng, maxLat] (lng-first!) */
83
+ export const OWNERSHIP_TYPES = {
84
+ 1: "Land ownership",
85
+ 2: "Perpetual usufruct",
86
+ 3: "Cooperative ownership right",
87
+ 4: "Unit sale",
88
+ 5: "Ownership",
89
+ 6: "Unit ownership with appurtenant right",
90
+ 7: "Building ownership with appurtenant right",
91
+ 8: "Perpetual usufruct",
92
+ };
93
+ const OWNERSHIP_TYPE_FILTER_MAP = {
94
+ land_ownership: "1",
95
+ perpetual_usufruct: "2,8",
96
+ cooperative_ownership: "3",
97
+ unit_sale: "4",
98
+ ownership: "5",
99
+ unit_ownership_with_appurtenant_right: "6",
100
+ building_ownership_with_appurtenant_right: "7",
101
+ unknown: "unknown",
102
+ };
103
+ export function mapOwnershipTypes(values) {
104
+ if (!values || values.length === 0)
105
+ return undefined;
106
+ const codes = values.map((v) => OWNERSHIP_TYPE_FILTER_MAP[v]).filter(Boolean);
107
+ return codes.length > 0 ? codes.join(",") : undefined;
108
+ }
109
+ export const PARTY_TYPES = {
110
+ 1: "State Treasury",
111
+ 2: "Local-government unit",
112
+ 3: "Natural person",
113
+ 4: "Legal person",
114
+ 5: "Cooperative",
115
+ 6: "State legal person",
116
+ 7: "Other legal person",
117
+ };
118
+ export const LAND_USES = {
119
+ gruntyZabudowaneIZurbanizowane: "Built-up and urbanized land",
120
+ gruntyRolne: "Agricultural land",
121
+ gruntyLesne: "Forest land",
122
+ terenyKomunikacyjne: "Transport land",
123
+ inne: "Other",
124
+ };
125
+ export const TRANSACTION_TYPES = {
126
+ 1: "Free market (Wolny rynek)",
127
+ 3: "Auction (Przetargowa)",
128
+ 4: "Non-auction (Bezprzetargowa)",
129
+ 5: "Subsidized (Z bonifikatą)",
130
+ 9: "Public purpose (Na cel publiczny)",
131
+ 10: "Foreclosure (Egzekucyjna)",
132
+ };
133
+ const TRANSACTION_TYPE_MAP = {
134
+ free_market: 1,
135
+ auction: 3,
136
+ non_auction: 4,
137
+ subsidized: 5,
138
+ public_purpose: 9,
139
+ foreclosure: 10,
140
+ unknown: "unknown",
141
+ };
142
+ export function mapTransactionTypes(values) {
143
+ if (!values || values.length === 0)
144
+ return undefined;
145
+ const mapped = values
146
+ .map(v => TRANSACTION_TYPE_MAP[v])
147
+ .filter((v) => v !== undefined);
148
+ if (mapped.length === 0)
149
+ return undefined;
150
+ return mapped.join(",");
151
+ }
86
152
  export function radiusKmToBbox(lat, lng, radiusKm) {
87
153
  const latDelta = radiusKm / 111.0;
88
154
  const lngDelta = radiusKm / (111.0 * Math.cos((lat * Math.PI) / 180));
89
155
  return [
90
- lng - lngDelta, // minLng
91
- lat - latDelta, // minLat
92
- lng + lngDelta, // maxLng
93
- lat + latDelta, // maxLat
156
+ lng - lngDelta,
157
+ lat - latDelta,
158
+ lng + lngDelta,
159
+ lat + latDelta,
94
160
  ];
95
161
  }
96
- // ── Location filtering ──────────────────────────────────────────────
97
- /** Filter districts by location name (case-insensitive includes match) */
162
+ export function stripDiacritics(s) {
163
+ return s.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[łŁ]/g, (c) => c === "ł" ? "l" : "L");
164
+ }
98
165
  export function filterByLocation(location, districts) {
99
- const lower = location.toLowerCase();
100
- return districts.filter((d) => d.toLowerCase().includes(lower));
166
+ const needle = stripDiacritics(location.toLowerCase());
167
+ return districts.filter((d) => stripDiacritics(d.toLowerCase()).includes(needle));
101
168
  }
102
- // ── City → sub-district expansion ───────────────────────────────────
103
- // Keep in sync with api/src/helpers.ts CITY_SUBDISTRICTS (ADR-003).
104
- // Cities: Warszawa (19), Kraków (5), Łódź (6).
105
169
  export const CITY_SUBDISTRICTS = new Map([
106
170
  ["Warszawa", [
107
171
  "Warszawa", "Bemowo", "Białołęka", "Bielany", "Mokotów", "Ochota",
@@ -115,7 +179,61 @@ export const CITY_SUBDISTRICTS = new Map([
115
179
  "Łódź", "Łódź-Bałuty", "Łódź-Górna", "Łódź-Polesie", "Łódź-Śródmieście", "Łódź-Widzew",
116
180
  ]],
117
181
  ]);
118
- /** Returns sub-districts for known multi-district cities, or [district] for everything else. */
119
182
  export function expandDistrict(district) {
120
183
  return CITY_SUBDISTRICTS.get(district)?.slice() ?? [district];
121
184
  }
185
+ export function buildNormalizedMap(districts) {
186
+ const map = new Map();
187
+ for (const d of districts) {
188
+ const key = stripDiacritics(d.toLowerCase());
189
+ const existing = map.get(key);
190
+ if (existing) {
191
+ existing.push(d);
192
+ }
193
+ else {
194
+ map.set(key, [d]);
195
+ }
196
+ }
197
+ for (const [cityName] of CITY_SUBDISTRICTS) {
198
+ const key = stripDiacritics(cityName.toLowerCase());
199
+ if (!map.has(key)) {
200
+ map.set(key, [cityName]);
201
+ }
202
+ }
203
+ return map;
204
+ }
205
+ let lastDistricts = null;
206
+ let normalizedMap = null;
207
+ function getNormalizedMap(districts) {
208
+ if (districts !== lastDistricts) {
209
+ normalizedMap = buildNormalizedMap(districts);
210
+ lastDistricts = districts;
211
+ }
212
+ return normalizedMap;
213
+ }
214
+ export function tryResolveCityKey(input) {
215
+ const normalized = stripDiacritics(input.trim().toLowerCase());
216
+ for (const [cityName, subs] of CITY_SUBDISTRICTS) {
217
+ if (stripDiacritics(cityName.toLowerCase()) === normalized) {
218
+ return subs.slice();
219
+ }
220
+ }
221
+ return null;
222
+ }
223
+ export function resolveDistrict(input, allDistricts) {
224
+ const city = tryResolveCityKey(input);
225
+ if (city)
226
+ return city;
227
+ const normalized = stripDiacritics(input.trim().toLowerCase());
228
+ const map = getNormalizedMap(allDistricts);
229
+ const canonicals = map.get(normalized);
230
+ if (canonicals) {
231
+ const result = [];
232
+ for (const c of canonicals) {
233
+ const expanded = expandDistrict(c);
234
+ result.push(...expanded);
235
+ }
236
+ return result;
237
+ }
238
+ return [input];
239
+ }
@@ -0,0 +1,19 @@
1
+ export declare const MCP_AUDIENCE = "https://mcp.cenogram.pl";
2
+ export interface OAuthJwtClaims {
3
+ sub: string;
4
+ scope: string;
5
+ grant_id: string;
6
+ client_id: string;
7
+ }
8
+ export type ValidationReason = "expired" | "unknown_key" | "invalid";
9
+ export type ValidationResult = {
10
+ ok: true;
11
+ claims: OAuthJwtClaims;
12
+ } | {
13
+ ok: false;
14
+ reason: ValidationReason;
15
+ };
16
+ export declare class OAuthConfigError extends Error {
17
+ constructor(message?: string);
18
+ }
19
+ export declare function validateOAuthJwt(token: string): Promise<ValidationResult>;
@@ -0,0 +1,63 @@
1
+ import { decodeProtectedHeader, importSPKI, jwtVerify, errors as joseErrors } from "jose";
2
+ export const MCP_AUDIENCE = "https://mcp.cenogram.pl";
3
+ const ISSUER = "https://api.cenogram.pl";
4
+ export class OAuthConfigError extends Error {
5
+ constructor(message = "OAuth not configured: OAUTH_JWT_KID and OAUTH_JWT_PUBLIC_KEY required") {
6
+ super(message);
7
+ this.name = "OAuthConfigError";
8
+ }
9
+ }
10
+ let cachedKey = null;
11
+ let cachedKid;
12
+ async function getKeyPair() {
13
+ const kid = process.env.OAUTH_JWT_KID;
14
+ const pem = (process.env.OAUTH_JWT_PUBLIC_KEY ?? "").replace(/\\+n/g, "\n");
15
+ if (!kid || !pem)
16
+ throw new OAuthConfigError();
17
+ if (cachedKey && cachedKid === kid)
18
+ return { key: cachedKey, kid };
19
+ cachedKey = await importSPKI(pem, "RS256");
20
+ cachedKid = kid;
21
+ return { key: cachedKey, kid };
22
+ }
23
+ export async function validateOAuthJwt(token) {
24
+ const pair = await getKeyPair();
25
+ let header;
26
+ try {
27
+ header = decodeProtectedHeader(token);
28
+ }
29
+ catch {
30
+ return { ok: false, reason: "invalid" };
31
+ }
32
+ if (!header.kid || header.kid !== pair.kid) {
33
+ return { ok: false, reason: "unknown_key" };
34
+ }
35
+ let payload;
36
+ try {
37
+ ({ payload } = await jwtVerify(token, pair.key, {
38
+ algorithms: ["RS256"],
39
+ issuer: ISSUER,
40
+ audience: MCP_AUDIENCE,
41
+ }));
42
+ }
43
+ catch (e) {
44
+ if (e instanceof joseErrors.JWTExpired)
45
+ return { ok: false, reason: "expired" };
46
+ return { ok: false, reason: "invalid" };
47
+ }
48
+ if (typeof payload.sub !== "string" ||
49
+ typeof payload.scope !== "string" ||
50
+ typeof payload.grant_id !== "string" ||
51
+ typeof payload.client_id !== "string") {
52
+ return { ok: false, reason: "invalid" };
53
+ }
54
+ return {
55
+ ok: true,
56
+ claims: {
57
+ sub: payload.sub,
58
+ scope: payload.scope,
59
+ grant_id: payload.grant_id,
60
+ client_id: payload.client_id,
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,5 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ export interface McpRequestContext {
3
+ clientUserAgent?: string;
4
+ }
5
+ export declare const requestContext: AsyncLocalStorage<McpRequestContext>;
@@ -0,0 +1,2 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ export const requestContext = new AsyncLocalStorage();
@@ -0,0 +1,2 @@
1
+ export declare function scrubHeaders(headers: Record<string, string>): Record<string, string>;
2
+ export declare function scrubString(s: string): string;
@@ -0,0 +1,14 @@
1
+ const SENSITIVE_HEADERS = new Set([
2
+ "authorization", "cookie", "x-internal-auth", "x-api-key",
3
+ ]);
4
+ const API_KEY_PATTERN = /cngrm_[a-f0-9]+/g;
5
+ export function scrubHeaders(headers) {
6
+ const scrubbed = {};
7
+ for (const [key, value] of Object.entries(headers)) {
8
+ scrubbed[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) ? "[Filtered]" : value;
9
+ }
10
+ return scrubbed;
11
+ }
12
+ export function scrubString(s) {
13
+ return s.replace(API_KEY_PATTERN, "[Filtered]");
14
+ }
@@ -0,0 +1,2 @@
1
+ import * as Sentry from "@sentry/node";
2
+ export { Sentry };
package/dist/sentry.js ADDED
@@ -0,0 +1,29 @@
1
+ import * as Sentry from "@sentry/node";
2
+ import { scrubHeaders, scrubString } from "./sentry-scrub.js";
3
+ Sentry.init({
4
+ dsn: process.env.SENTRY_DSN,
5
+ environment: process.env.NODE_ENV || "development",
6
+ release: process.env.GIT_SHA || "unknown",
7
+ enabled: process.env.NODE_ENV === "production" && !!process.env.SENTRY_DSN,
8
+ tracesSampleRate: 0,
9
+ profilesSampleRate: 0,
10
+ initialScope: {
11
+ tags: { service: "mcp" },
12
+ },
13
+ beforeSend(event) {
14
+ if (event.request?.headers) {
15
+ event.request.headers = scrubHeaders(event.request.headers);
16
+ }
17
+ if (event.request?.url) {
18
+ event.request.url = scrubString(event.request.url);
19
+ }
20
+ if (event.exception?.values) {
21
+ for (const ex of event.exception.values) {
22
+ if (ex.value)
23
+ ex.value = scrubString(ex.value);
24
+ }
25
+ }
26
+ return event;
27
+ },
28
+ });
29
+ export { Sentry };
package/dist/tools.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function experimentalToolsEnabled(): boolean;
2
3
  export declare function registerTools(server: McpServer, apiKey?: string): void;