@cenogram/mcp-server 0.6.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  export declare function serverInstructions(): string;
4
4
  export declare function createMcpServer(apiKey?: string): McpServer;
5
+ export declare function handleHttpRequest(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse): Promise<void>;
package/dist/index.js CHANGED
@@ -29,12 +29,13 @@ export function serverInstructions() {
29
29
  return [
30
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
31
  "",
32
- "CRITICAL - District names (ALWAYS verify first):",
33
- "- NEVER guess district names. Call list_locations(search=\"city\") first.",
32
+ "CRITICAL - Addressing a place (resolve the name to a code first):",
33
+ "- To point at a place, resolve its name to a code: list_locations(search=\"name\") returns a TERYT code for every match; pass that as search_transactions(teryt=code) for filtering that carries no name ambiguity.",
34
+ "- The name is a parallel path, not a fallback. A match the result flags as an RCN district (and any match that has no TERYT code) also accepts its name directly as search_transactions(location=name). Use location= only for a name a result handed you, never one you guessed.",
34
35
  "- Warsaw: 'Warszawa' auto-includes all 18 districts. Or use specific: Mokotów, Wola, Śródmieście",
35
36
  "- Kraków/Łódź: 'Kraków'/'Łódź' auto-include all sub-districts. Or use specific: Kraków-Podgórze, etc.",
36
37
  "- 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
+ "- Neighborhoods/osiedla (Nowy Dwór, Oliwa, Jeżyce) are usually NOT TERYT districts - three cases: (a) if the name is an administrative unit or cadastral precinct, list_locations(search=name) returns its teryt, use that; (b) for a precinct name PLUS a parcel number, resolve_parcel(q=\"name number\") turns the pair into one parcel; (c) for a true neighbourhood outside TERYT, get coordinates FROM THE USER and pass them to search_by_area (radiusKm 0.5-1.0), then refine with search_by_polygon - never invent coordinates. resolve_parcel does not resolve a bare neighbourhood name; it needs a name and a parcel number.",
38
39
  "- TERYT hierarchy: For precise administrative filtering (avoids name ambiguity), use list_locations(parent) to browse TERYT codes, then search_transactions(teryt=code).",
39
40
  "- Use 'location' for quick city searches, 'teryt' when you need exact administrative boundaries.",
40
41
  "",
@@ -44,14 +45,17 @@ export function serverInstructions() {
44
45
  ? [
45
46
  "- 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
47
  "- 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.",
48
+ "- Flood-hazard exposure: (experimental - may change/withdraw) list_flood_risk_locations (catalog of covered counties) → get_flood_risk(location|teryt) - share of a county's transactions on land in a mapped flood-hazard zone, by severity (return periods). County level, all-time. Counts a mapped zone only; absence is never asserted as 'safe'. For any name that is not a major city you know is covered, call the catalog FIRST or pass a county teryt.",
47
49
  ]
48
50
  : []),
49
51
  "- 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
52
  "- 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)",
53
+ "- Parcel lookup: search_parcels(q, min 3 chars) → get_parcel_report(parcelId) for everything on the parcel, or search_transactions(parcelId) for its sale history.",
54
+ "- Find parcels in an area: list_parcels_in_area (name it with teryt, location, bbox or a circle) filtered by street, buildingNumber, minArea, maxArea → get_parcel_land_class for the soil class of one you pick, or get_parcel_report for everything on it.",
52
55
  "- 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
56
  "- 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.",
57
+ "- 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, intensive livestock farm, and overhead high-voltage and extra-high-voltage power line. A null distance = nothing within the search radius in reference data, never a guarantee.",
58
+ "- Road access: search_transactions → get_transaction_roads(transaction_id) — per-plot distance to the nearest public road (with its category, class and the estimated distance to the carriageway edge), to the nearest road of any kind, and to the nearest motorway/expressway/dual-carriageway, plus a three-state access_indicator. It is geometric evidence from carriageway centrelines, NOT a determination of legal access - easements and rights of way live in the land register and are not published here.",
55
59
  "- Address search: search_transactions(location, street, buildingNumber)",
56
60
  "- Radius search: search_by_area(lat, lng, radiusKm) - for geographic proximity",
57
61
  "- Polygon search: search_by_polygon - coordinates are [longitude, latitude], first=last point, max 500 vertices",
@@ -73,11 +77,130 @@ export function createMcpServer(apiKey) {
73
77
  registerTools(server, apiKey);
74
78
  return server;
75
79
  }
80
+ export async function handleHttpRequest(req, res) {
81
+ try {
82
+ const pathname = req.url?.split("?")[0];
83
+ if (pathname === "/mcp") {
84
+ const dispatch = await dispatchAuth(req.headers.authorization);
85
+ if (dispatch.kind === "passthrough") {
86
+ logAuth(dispatch.log, "info");
87
+ }
88
+ else if (dispatch.kind === "500") {
89
+ logAuth(dispatch.log, "error");
90
+ res.writeHead(500, dispatch.headers).end(JSON.stringify(dispatch.body));
91
+ return;
92
+ }
93
+ else {
94
+ const level = dispatch.kind === "401" && dispatch.log.reason === "missing" ? "info" : "warn";
95
+ const clientIp = (req.headers["cf-connecting-ip"] ?? req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "");
96
+ logAuth({
97
+ ...dispatch.log,
98
+ method: req.method,
99
+ ua: sanitizeForLog((req.headers["user-agent"] ?? "").slice(0, 128)),
100
+ ip: sanitizeForLog(clientIp.split(",")[0].trim().slice(0, 45)),
101
+ }, level);
102
+ const status = dispatch.kind === "401" ? 401 : 403;
103
+ res.writeHead(status, dispatch.headers).end(JSON.stringify(dispatch.body));
104
+ return;
105
+ }
106
+ if (req.method === "GET") {
107
+ res.writeHead(405, { Allow: "POST, DELETE", "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }));
108
+ return;
109
+ }
110
+ const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
111
+ const clientUA = (req.headers["user-agent"] ?? "").slice(0, 512) || undefined;
112
+ await requestContext.run({ clientUserAgent: clientUA }, async () => {
113
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
114
+ const mcpServer = createMcpServer(dispatch.apiKey);
115
+ try {
116
+ await mcpServer.connect(transport);
117
+ await transport.handleRequest(req, res);
118
+ }
119
+ finally {
120
+ await transport.close();
121
+ await mcpServer.close();
122
+ }
123
+ });
124
+ }
125
+ else if (pathname === "/.well-known/oauth-protected-resource" || pathname === "/.well-known/oauth-protected-resource/mcp") {
126
+ const resource = pathname.endsWith("/mcp") ? "https://mcp.cenogram.pl/mcp" : "https://mcp.cenogram.pl";
127
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
128
+ resource,
129
+ authorization_servers: ["https://api.cenogram.pl"],
130
+ scopes_supported: ["mcp"],
131
+ bearer_methods_supported: ["header"],
132
+ }));
133
+ }
134
+ else if (pathname === "/.well-known/glama.json") {
135
+ const maintainer = process.env.GLAMA_MAINTAINER_EMAIL;
136
+ if (!maintainer) {
137
+ res.writeHead(404, { "Content-Type": "application/json" }).end(JSON.stringify({ error: "not_found" }));
138
+ }
139
+ else {
140
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
141
+ $schema: "https://glama.ai/mcp/schemas/connector.json",
142
+ maintainers: [{ email: maintainer }],
143
+ }));
144
+ }
145
+ }
146
+ else if (pathname === "/.well-known/mcp.json") {
147
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
148
+ name: "Cenogram",
149
+ description: "8M+ verified real estate transactions from Poland's official RCN registry. Transaction prices from notarial deeds, 380 counties, data from 2003.",
150
+ icon: "https://cenogram.pl/apple-touch-icon.png",
151
+ endpoint: "https://mcp.cenogram.pl/mcp",
152
+ }));
153
+ }
154
+ else if (pathname === "/health") {
155
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
156
+ }
157
+ else if (pathname === "/health/deep") {
158
+ const apiUrl = process.env.CENOGRAM_API_URL || "https://cenogram.pl";
159
+ const controller = new AbortController();
160
+ const timeout = setTimeout(() => controller.abort(), 3_000);
161
+ let apiStatus = "fail";
162
+ let apiError;
163
+ try {
164
+ const apiRes = await fetch(`${apiUrl}/api/health`, { signal: controller.signal });
165
+ if (apiRes.ok) {
166
+ apiStatus = "ok";
167
+ }
168
+ else {
169
+ apiError = `HTTP ${apiRes.status}`;
170
+ }
171
+ }
172
+ catch (err) {
173
+ apiError = err instanceof Error ? err.message : String(err);
174
+ }
175
+ finally {
176
+ clearTimeout(timeout);
177
+ }
178
+ const code = apiStatus === "ok" ? 200 : 503;
179
+ res.writeHead(code, { "Content-Type": "application/json" }).end(JSON.stringify({
180
+ status: apiStatus === "ok" ? "ok" : "degraded",
181
+ dependencies: { api: apiStatus, ...(apiError ? { apiError } : {}) },
182
+ apiUrl,
183
+ }));
184
+ }
185
+ else if (pathname === "/robots.txt") {
186
+ res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "public, max-age=86400" }).end("User-agent: *\nDisallow: /\n");
187
+ }
188
+ else {
189
+ res.writeHead(404).end();
190
+ }
191
+ }
192
+ catch (err) {
193
+ Sentry.captureException(err, { tags: { error_layer: "http_handler" } });
194
+ process.stderr.write(`HTTP error: ${String(err)}\n`);
195
+ if (!res.headersSent) {
196
+ res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }));
197
+ }
198
+ }
199
+ }
76
200
  async function main() {
77
201
  const mode = isHttpMode() ? "http" : "stdio";
78
202
  if (mode === "http") {
79
203
  const { createServer } = await import("node:http");
80
- const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
81
204
  if (process.env.NODE_ENV === "production") {
82
205
  if (!process.env.OAUTH_JWT_KID)
83
206
  throw new Error("OAUTH_JWT_KID required in production");
@@ -87,121 +210,6 @@ async function main() {
87
210
  throw new Error("INTERNAL_AUTH_SECRET required in production");
88
211
  }
89
212
  const port = parseInt(process.env.MCP_PORT || "3002", 10);
90
- const handleHttpRequest = async (req, res) => {
91
- try {
92
- const pathname = req.url?.split("?")[0];
93
- if (pathname === "/mcp") {
94
- const dispatch = await dispatchAuth(req.headers.authorization);
95
- if (dispatch.kind === "passthrough") {
96
- logAuth(dispatch.log, "info");
97
- }
98
- else if (dispatch.kind === "500") {
99
- logAuth(dispatch.log, "error");
100
- res.writeHead(500, dispatch.headers).end(JSON.stringify(dispatch.body));
101
- return;
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" || pathname === "/.well-known/oauth-protected-resource/mcp") {
131
- const resource = pathname.endsWith("/mcp") ? "https://mcp.cenogram.pl/mcp" : "https://mcp.cenogram.pl";
132
- res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
133
- resource,
134
- authorization_servers: ["https://api.cenogram.pl"],
135
- scopes_supported: ["mcp"],
136
- bearer_methods_supported: ["header"],
137
- }));
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
- }
151
- else if (pathname === "/.well-known/mcp.json") {
152
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
153
- name: "Cenogram",
154
- description: "8M+ verified real estate transactions from Poland's official RCN registry. Transaction prices from notarial deeds, 380 counties, data from 2003.",
155
- icon: "https://cenogram.pl/apple-touch-icon.png",
156
- endpoint: "https://mcp.cenogram.pl/mcp",
157
- }));
158
- }
159
- else if (pathname === "/health") {
160
- res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
161
- }
162
- else if (pathname === "/health/deep") {
163
- const apiUrl = process.env.CENOGRAM_API_URL || "https://cenogram.pl";
164
- const controller = new AbortController();
165
- const timeout = setTimeout(() => controller.abort(), 3_000);
166
- let apiStatus = "fail";
167
- let apiError;
168
- try {
169
- const apiRes = await fetch(`${apiUrl}/api/health`, { signal: controller.signal });
170
- if (apiRes.ok) {
171
- apiStatus = "ok";
172
- }
173
- else {
174
- apiError = `HTTP ${apiRes.status}`;
175
- }
176
- }
177
- catch (err) {
178
- apiError = err instanceof Error ? err.message : String(err);
179
- }
180
- finally {
181
- clearTimeout(timeout);
182
- }
183
- const code = apiStatus === "ok" ? 200 : 503;
184
- res.writeHead(code, { "Content-Type": "application/json" }).end(JSON.stringify({
185
- status: apiStatus === "ok" ? "ok" : "degraded",
186
- dependencies: { api: apiStatus, ...(apiError ? { apiError } : {}) },
187
- apiUrl,
188
- }));
189
- }
190
- else if (pathname === "/robots.txt") {
191
- res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "public, max-age=86400" }).end("User-agent: *\nDisallow: /\n");
192
- }
193
- else {
194
- res.writeHead(404).end();
195
- }
196
- }
197
- catch (err) {
198
- Sentry.captureException(err, { tags: { error_layer: "http_handler" } });
199
- process.stderr.write(`HTTP error: ${String(err)}\n`);
200
- if (!res.headersSent) {
201
- res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }));
202
- }
203
- }
204
- };
205
213
  createServer((req, res) => {
206
214
  handleHttpRequest(req, res).catch((err) => {
207
215
  process.stderr.write(`Unhandled HTTP error: ${String(err)}\n`);