@cenogram/mcp-server 0.2.0 → 0.6.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,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 { /* fallback to hardcoded if dist/ used standalone */ }
27
- // ── 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
+ }
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 = process.argv.includes("--http") || process.env.MCP_TRANSPORT === "http" ? "http" : "stdio";
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");
@@ -113,14 +127,27 @@ async function main() {
113
127
  }
114
128
  });
115
129
  }
116
- else if (pathname === "/.well-known/oauth-protected-resource") {
130
+ else if (pathname === "/.well-known/oauth-protected-resource" || pathname === "/.well-known/oauth-protected-resource/mcp") {
131
+ const resource = pathname.endsWith("/mcp") ? "https://mcp.cenogram.pl/mcp" : "https://mcp.cenogram.pl";
117
132
  res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
118
- resource: "https://mcp.cenogram.pl",
133
+ resource,
119
134
  authorization_servers: ["https://api.cenogram.pl"],
120
135
  scopes_supported: ["mcp"],
121
136
  bearer_methods_supported: ["header"],
122
137
  }));
123
138
  }
139
+ else if (pathname === "/.well-known/glama.json") {
140
+ const maintainer = process.env.GLAMA_MAINTAINER_EMAIL;
141
+ if (!maintainer) {
142
+ res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not_found" }));
143
+ }
144
+ else {
145
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
146
+ $schema: "https://glama.ai/mcp/schemas/connector.json",
147
+ maintainers: [{ email: maintainer }],
148
+ }));
149
+ }
150
+ }
124
151
  else if (pathname === "/.well-known/mcp.json") {
125
152
  res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
126
153
  name: "Cenogram",
@@ -168,6 +195,7 @@ async function main() {
168
195
  }
169
196
  }
170
197
  catch (err) {
198
+ Sentry.captureException(err, { tags: { error_layer: "http_handler" } });
171
199
  process.stderr.write(`HTTP error: ${String(err)}\n`);
172
200
  if (!res.headersSent) {
173
201
  res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }));
@@ -185,7 +213,7 @@ async function main() {
185
213
  else {
186
214
  if (!process.env.CENOGRAM_API_KEY) {
187
215
  process.stderr.write("Error: CENOGRAM_API_KEY is required.\n" +
188
- "Get your free API key at https://cenogram.pl/api\n" +
216
+ `Get your free API key at ${signupUrl()}\n` +
189
217
  "Then add it to your MCP config:\n" +
190
218
  ' "env": { "CENOGRAM_API_KEY": "cngrm_..." }\n');
191
219
  process.exit(1);
@@ -195,7 +223,12 @@ async function main() {
195
223
  }
196
224
  }
197
225
  if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
198
- main().catch((err) => {
226
+ process.on("SIGTERM", () => {
227
+ void Sentry.flush(2000).then(() => process.exit(0));
228
+ });
229
+ main().catch(async (err) => {
230
+ Sentry.captureException(err, { tags: { error_layer: "fatal" } });
231
+ await Sentry.flush(2000);
199
232
  process.stderr.write(`Fatal: ${String(err)}\n`);
200
233
  process.exit(1);
201
234
  });
@@ -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, diacritics-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,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
- // ── 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
- 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(/\\\\n|\\n/g, "\n");
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,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;