@cenogram/mcp-server 0.2.0 → 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 +25 -6
- package/dist/api-client.d.ts +678 -7
- package/dist/api-client.js +169 -22
- package/dist/auth-dispatch.js +0 -27
- package/dist/client-id.js +0 -5
- package/dist/error-messages.d.ts +4 -0
- package/dist/error-messages.js +50 -7
- package/dist/formatters.d.ts +20 -1
- package/dist/formatters.js +1094 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.js +58 -38
- package/dist/mappings.d.ts +12 -5
- package/dist/mappings.js +131 -14
- package/dist/oauth-jwt.js +1 -3
- 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 +485 -84
- package/dist/transport-mode.d.ts +2 -0
- package/dist/transport-mode.js +6 -0
- package/package.json +15 -7
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,66 +1,80 @@
|
|
|
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
8
|
import { fetch } from "undici";
|
|
8
|
-
import { registerTools } from "./tools.js";
|
|
9
|
+
import { registerTools, experimentalToolsEnabled } from "./tools.js";
|
|
9
10
|
import { dispatchAuth, sanitizeForLog } from "./auth-dispatch.js";
|
|
10
11
|
import { requestContext } from "./request-context.js";
|
|
12
|
+
import { signupUrl } from "./error-messages.js";
|
|
13
|
+
import { channelSrc, isHttpMode } from "./transport-mode.js";
|
|
11
14
|
function logAuth(payload, level) {
|
|
12
15
|
process.stderr.write(JSON.stringify({ level, ...payload }) + "\n");
|
|
13
16
|
}
|
|
14
|
-
// ── Node version check ─────────────────────────────────────────────
|
|
15
17
|
const [nodeMajor] = process.versions.node.split(".").map(Number);
|
|
16
18
|
if (nodeMajor != null && nodeMajor < 18) {
|
|
17
19
|
process.stderr.write(`Warning: @cenogram/mcp-server recommends Node.js >= 18 (current: ${process.version}). ` +
|
|
18
20
|
"The server will try to run, but some features may not work.\n");
|
|
19
21
|
}
|
|
20
|
-
// ── Version ────────────────────────────────────────────────────────
|
|
21
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
let PKG_VERSION = "0.1.0";
|
|
23
24
|
try {
|
|
24
25
|
PKG_VERSION = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
|
|
25
26
|
}
|
|
26
|
-
catch {
|
|
27
|
-
|
|
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
|
+
}
|
|
28
71
|
export function createMcpServer(apiKey) {
|
|
29
|
-
const server = new McpServer({ name: "cenogram-mcp-server", version: PKG_VERSION }, {
|
|
30
|
-
instructions: [
|
|
31
|
-
"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.",
|
|
32
|
-
"",
|
|
33
|
-
"CRITICAL - District names (ALWAYS verify first):",
|
|
34
|
-
"- NEVER guess district names. Call list_locations(search=\"city\") first.",
|
|
35
|
-
"- Warsaw: 'Warszawa' auto-includes all 18 districts. Or use specific: Mokotów, Wola, Śródmieście",
|
|
36
|
-
"- Kraków/Łódź: 'Kraków'/'Łódź' auto-include all sub-districts. Or use specific: Kraków-Podgórze, etc.",
|
|
37
|
-
"- Most cities (Gdańsk, Gdynia, Sopot, Poznań, Wrocław): just the city name, no sub-districts",
|
|
38
|
-
"- 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.",
|
|
39
|
-
"- TERYT hierarchy: For precise administrative filtering (avoids name ambiguity), use list_locations(parent) to browse TERYT codes, then search_transactions(teryt=code).",
|
|
40
|
-
"- Use 'location' for quick city searches, 'teryt' when you need exact administrative boundaries.",
|
|
41
|
-
"",
|
|
42
|
-
"Workflows:",
|
|
43
|
-
"- Market analysis: get_market_overview → get_price_statistics(location) → search_transactions",
|
|
44
|
-
"- Compare locations: list_locations → compare_locations (2-5 districts, requires at least one filter e.g. propertyType)",
|
|
45
|
-
"- Parcel lookup: search_parcels(q, min 3 chars) → search_by_area (use returned lat/lng)",
|
|
46
|
-
"- Address search: search_transactions(location, street, buildingNumber)",
|
|
47
|
-
"- Radius search: search_by_area(lat, lng, radiusKm) - for geographic proximity",
|
|
48
|
-
"- Polygon search: search_by_polygon - coordinates are [longitude, latitude], first=last point, max 500 vertices",
|
|
49
|
-
"- TERYT drill-down: list_locations() → list_locations(parent=voivodeshipCode) → list_locations(parent=countyCode) → search_transactions(teryt=municipalityCode)",
|
|
50
|
-
"",
|
|
51
|
-
"Data notes:",
|
|
52
|
-
"- price_per_m2 only meaningful for apartments (propertyType=\"unit\")",
|
|
53
|
-
"- 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",
|
|
54
|
-
"- Results paginated (default 10-20). Use page parameter for more.",
|
|
55
|
-
"- For §79-compliant export table or interactive map - direct user to cenogram.pl",
|
|
56
|
-
].join("\n"),
|
|
57
|
-
});
|
|
72
|
+
const server = new McpServer({ name: "cenogram-mcp-server", version: PKG_VERSION }, { instructions: serverInstructions() });
|
|
58
73
|
registerTools(server, apiKey);
|
|
59
74
|
return server;
|
|
60
75
|
}
|
|
61
|
-
// ── Start ───────────────────────────────────────────────────────────
|
|
62
76
|
async function main() {
|
|
63
|
-
const mode =
|
|
77
|
+
const mode = isHttpMode() ? "http" : "stdio";
|
|
64
78
|
if (mode === "http") {
|
|
65
79
|
const { createServer } = await import("node:http");
|
|
66
80
|
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
@@ -168,6 +182,7 @@ async function main() {
|
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
catch (err) {
|
|
185
|
+
Sentry.captureException(err, { tags: { error_layer: "http_handler" } });
|
|
171
186
|
process.stderr.write(`HTTP error: ${String(err)}\n`);
|
|
172
187
|
if (!res.headersSent) {
|
|
173
188
|
res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }));
|
|
@@ -185,7 +200,7 @@ async function main() {
|
|
|
185
200
|
else {
|
|
186
201
|
if (!process.env.CENOGRAM_API_KEY) {
|
|
187
202
|
process.stderr.write("Error: CENOGRAM_API_KEY is required.\n" +
|
|
188
|
-
|
|
203
|
+
`Get your free API key at ${signupUrl()}\n` +
|
|
189
204
|
"Then add it to your MCP config:\n" +
|
|
190
205
|
' "env": { "CENOGRAM_API_KEY": "cngrm_..." }\n');
|
|
191
206
|
process.exit(1);
|
|
@@ -195,7 +210,12 @@ async function main() {
|
|
|
195
210
|
}
|
|
196
211
|
}
|
|
197
212
|
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
198
|
-
|
|
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);
|
|
199
219
|
process.stderr.write(`Fatal: ${String(err)}\n`);
|
|
200
220
|
process.exit(1);
|
|
201
221
|
});
|
package/dist/mappings.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,34 +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
|
-
|
|
85
|
-
|
|
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,
|
|
91
|
-
lat - latDelta,
|
|
92
|
-
lng + lngDelta,
|
|
93
|
-
lat + latDelta,
|
|
156
|
+
lng - lngDelta,
|
|
157
|
+
lat - latDelta,
|
|
158
|
+
lng + lngDelta,
|
|
159
|
+
lat + latDelta,
|
|
94
160
|
];
|
|
95
161
|
}
|
|
96
|
-
|
|
97
|
-
function stripDiacritics(s) {
|
|
162
|
+
export function stripDiacritics(s) {
|
|
98
163
|
return s.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[łŁ]/g, (c) => c === "ł" ? "l" : "L");
|
|
99
164
|
}
|
|
100
|
-
/** Filter districts by location name (case-insensitive, diacritics-insensitive includes match) */
|
|
101
165
|
export function filterByLocation(location, districts) {
|
|
102
166
|
const needle = stripDiacritics(location.toLowerCase());
|
|
103
167
|
return districts.filter((d) => stripDiacritics(d.toLowerCase()).includes(needle));
|
|
104
168
|
}
|
|
105
|
-
// ── City → sub-district expansion ───────────────────────────────────
|
|
106
169
|
export const CITY_SUBDISTRICTS = new Map([
|
|
107
170
|
["Warszawa", [
|
|
108
171
|
"Warszawa", "Bemowo", "Białołęka", "Bielany", "Mokotów", "Ochota",
|
|
@@ -116,7 +179,61 @@ export const CITY_SUBDISTRICTS = new Map([
|
|
|
116
179
|
"Łódź", "Łódź-Bałuty", "Łódź-Górna", "Łódź-Polesie", "Łódź-Śródmieście", "Łódź-Widzew",
|
|
117
180
|
]],
|
|
118
181
|
]);
|
|
119
|
-
/** Returns sub-districts for known multi-district cities, or [district] for everything else. */
|
|
120
182
|
export function expandDistrict(district) {
|
|
121
183
|
return CITY_SUBDISTRICTS.get(district)?.slice() ?? [district];
|
|
122
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
|
+
}
|
package/dist/oauth-jwt.js
CHANGED
|
@@ -11,7 +11,7 @@ let cachedKey = null;
|
|
|
11
11
|
let cachedKid;
|
|
12
12
|
async function getKeyPair() {
|
|
13
13
|
const kid = process.env.OAUTH_JWT_KID;
|
|
14
|
-
const pem = (process.env.OAUTH_JWT_PUBLIC_KEY ?? "").replace(
|
|
14
|
+
const pem = (process.env.OAUTH_JWT_PUBLIC_KEY ?? "").replace(/\\+n/g, "\n");
|
|
15
15
|
if (!kid || !pem)
|
|
16
16
|
throw new OAuthConfigError();
|
|
17
17
|
if (cachedKey && cachedKid === kid)
|
|
@@ -20,9 +20,7 @@ async function getKeyPair() {
|
|
|
20
20
|
cachedKid = kid;
|
|
21
21
|
return { key: cachedKey, kid };
|
|
22
22
|
}
|
|
23
|
-
// kid check BEFORE signature verify - rejects unknown kid without expensive crypto op
|
|
24
23
|
export async function validateOAuthJwt(token) {
|
|
25
|
-
// Config errors propagate (caller maps to 500). Token errors return reason.
|
|
26
24
|
const pair = await getKeyPair();
|
|
27
25
|
let header;
|
|
28
26
|
try {
|
|
@@ -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
|
+
}
|
package/dist/sentry.d.ts
ADDED
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