@cenogram/mcp-server 0.1.6 → 0.2.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 +3 -18
- package/dist/api-client.d.ts +9 -0
- package/dist/api-client.js +42 -11
- package/dist/auth-dispatch.d.ts +59 -0
- package/dist/auth-dispatch.js +168 -0
- package/dist/error-messages.d.ts +8 -0
- package/dist/error-messages.js +37 -0
- package/dist/formatters.d.ts +2 -1
- package/dist/formatters.js +40 -4
- package/dist/index.js +103 -12
- package/dist/mappings.d.ts +1 -1
- package/dist/mappings.js +6 -5
- package/dist/oauth-jwt.d.ts +19 -0
- package/dist/oauth-jwt.js +65 -0
- package/dist/request-context.d.ts +5 -0
- package/dist/request-context.js +2 -0
- package/dist/tools.js +93 -31
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**Polish Real Estate Transaction Data for AI**
|
|
8
8
|
|
|
9
|
-
MCP server for Polish real estate data. Access
|
|
9
|
+
MCP server for Polish real estate data. Access 8M+ real estate transactions from the national Registry of Prices and Values (Rejestr Cen Nieruchomosci, RCN) directly from Claude, Cursor, or any MCP-compatible AI assistant.
|
|
10
10
|
|
|
11
11
|
Data source: Polish national RCN registry (Rejestr Cen Nieruchomosci) | Platform: [cenogram.pl](https://cenogram.pl)
|
|
12
12
|
|
|
@@ -62,22 +62,7 @@ Add to your config file:
|
|
|
62
62
|
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
63
63
|
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
64
64
|
|
|
65
|
-
**
|
|
66
|
-
```json
|
|
67
|
-
{
|
|
68
|
-
"mcpServers": {
|
|
69
|
-
"cenogram": {
|
|
70
|
-
"type": "http",
|
|
71
|
-
"url": "https://mcp.cenogram.pl/mcp",
|
|
72
|
-
"headers": {
|
|
73
|
-
"Authorization": "Bearer YOUR_API_KEY"
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
**Stdio fallback** (older versions - requires Node.js >= 18):
|
|
65
|
+
**npx (stdio):**
|
|
81
66
|
```json
|
|
82
67
|
{
|
|
83
68
|
"mcpServers": {
|
|
@@ -262,7 +247,7 @@ This mimics how a property appraiser finds comparable transactions for valuation
|
|
|
262
247
|
|
|
263
248
|
## Data
|
|
264
249
|
|
|
265
|
-
- **
|
|
250
|
+
- **8M+ transactions** from all of Poland (380 counties)
|
|
266
251
|
- **Date range:** 2003 - present
|
|
267
252
|
- **Source:** Polish national RCN registry (Rejestr Cen Nieruchomosci)
|
|
268
253
|
- **Refresh:** periodic updates from RCN
|
package/dist/api-client.d.ts
CHANGED
|
@@ -94,11 +94,13 @@ export interface ApiResponse<T> {
|
|
|
94
94
|
data: T;
|
|
95
95
|
creditInfo: CreditInfo | null;
|
|
96
96
|
}
|
|
97
|
+
export declare function encodeOAuthCtx(userId: string, grantId: string): string;
|
|
97
98
|
export declare function fetchApi<T>(path: string, params?: Record<string, string>, apiKey?: string): Promise<ApiResponse<T>>;
|
|
98
99
|
export declare function fetchApiPost<T>(path: string, body: unknown, apiKey?: string): Promise<ApiResponse<T>>;
|
|
99
100
|
export declare function getStats(apiKey?: string): Promise<ApiResponse<StatsResponse>>;
|
|
100
101
|
export interface TransactionParams {
|
|
101
102
|
district?: string;
|
|
103
|
+
teryt?: string;
|
|
102
104
|
street?: string;
|
|
103
105
|
buildingNumber?: string;
|
|
104
106
|
parcelId?: string;
|
|
@@ -123,6 +125,13 @@ export declare function getTransactions(p: TransactionParams, apiKey?: string):
|
|
|
123
125
|
export declare function getTransactionsSummary(p: TransactionParams, apiKey?: string): Promise<ApiResponse<TransactionsSummary>>;
|
|
124
126
|
export declare function getPricePerM2(apiKey?: string): Promise<ApiResponse<PricePerM2Row[]>>;
|
|
125
127
|
export declare function getDistricts(apiKey?: string): Promise<ApiResponse<string[]>>;
|
|
128
|
+
export interface LocationItem {
|
|
129
|
+
code: string;
|
|
130
|
+
name: string;
|
|
131
|
+
typeName: string | null;
|
|
132
|
+
level: "voivodeship" | "county" | "municipality" | "precinct";
|
|
133
|
+
}
|
|
134
|
+
export declare function getLocations(parent?: string, apiKey?: string): Promise<ApiResponse<LocationItem[]>>;
|
|
126
135
|
export declare function getPriceHistogram(bins?: number, max?: number, apiKey?: string): Promise<ApiResponse<HistogramBin[]>>;
|
|
127
136
|
export interface ParcelSearchResult {
|
|
128
137
|
parcel_id: string;
|
package/dist/api-client.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { fetch } from "undici";
|
|
1
2
|
import { getClientId } from "./client-id.js";
|
|
3
|
+
import { authErrorMessage, getAuthMode } from "./error-messages.js";
|
|
4
|
+
import { requestContext } from "./request-context.js";
|
|
2
5
|
const BASE_URL = process.env.CENOGRAM_API_URL || "https://cenogram.pl";
|
|
3
6
|
function extractCreditInfo(res) {
|
|
4
7
|
if (!res.headers)
|
|
@@ -9,29 +12,52 @@ function extractCreditInfo(res) {
|
|
|
9
12
|
return null;
|
|
10
13
|
return { balance, cost };
|
|
11
14
|
}
|
|
15
|
+
// ── OAuth internal auth ────────────────────────────────────────────
|
|
16
|
+
// SOH char (\x01) is impossible in base64url or ctx_ keys - used as both prefix and separator
|
|
17
|
+
const OAUTH_CTX_PREFIX = "\x01";
|
|
18
|
+
export function encodeOAuthCtx(userId, grantId) {
|
|
19
|
+
// \x01{userId}\x01{grantId} - \x01 cannot appear in UUIDs (hex + hyphens only)
|
|
20
|
+
return `${OAUTH_CTX_PREFIX}${userId}${OAUTH_CTX_PREFIX}${grantId}`;
|
|
21
|
+
}
|
|
12
22
|
// ── Shared HTTP helpers ────────────────────────────────────────────
|
|
13
23
|
function buildHeaders(apiKey) {
|
|
14
24
|
const headers = {
|
|
15
25
|
"X-Source": "mcp-server",
|
|
16
26
|
"X-Cenogram-Client-Id": getClientId(),
|
|
17
27
|
};
|
|
28
|
+
const ctx = requestContext.getStore();
|
|
29
|
+
if (ctx?.clientUserAgent)
|
|
30
|
+
headers["X-MCP-User-Agent"] = ctx.clientUserAgent;
|
|
18
31
|
const key = apiKey ?? process.env.CENOGRAM_API_KEY;
|
|
19
|
-
if (key)
|
|
32
|
+
if (key?.startsWith(OAUTH_CTX_PREFIX)) {
|
|
33
|
+
const rest = key.slice(OAUTH_CTX_PREFIX.length);
|
|
34
|
+
const sepIdx = rest.indexOf(OAUTH_CTX_PREFIX);
|
|
35
|
+
if (sepIdx <= 0) {
|
|
36
|
+
throw new Error("BUG: malformed OAuth context key");
|
|
37
|
+
}
|
|
38
|
+
const internalSecret = process.env.INTERNAL_AUTH_SECRET;
|
|
39
|
+
if (internalSecret) {
|
|
40
|
+
headers["X-Internal-Auth"] = internalSecret;
|
|
41
|
+
}
|
|
42
|
+
headers["X-OAuth-User"] = rest.slice(0, sepIdx);
|
|
43
|
+
headers["X-OAuth-Grant"] = rest.slice(sepIdx + OAUTH_CTX_PREFIX.length);
|
|
44
|
+
}
|
|
45
|
+
else if (key) {
|
|
20
46
|
headers["Authorization"] = `Bearer ${key}`;
|
|
47
|
+
}
|
|
21
48
|
return headers;
|
|
22
49
|
}
|
|
23
|
-
async function handleErrorResponse(res) {
|
|
24
|
-
|
|
25
|
-
const body = await res.json().catch(() => ({}));
|
|
26
|
-
throw new Error(`Niewystarczające tokeny API. Saldo: ${body.currentBalance ?? 0}, wymagane: ${body.creditsRequired ?? "?"}. Doładuj: https://cenogram.pl/api#cennik`);
|
|
27
|
-
}
|
|
50
|
+
async function handleErrorResponse(res, apiKey) {
|
|
51
|
+
// 429 has special Retry-After handling - keep dedicated path
|
|
28
52
|
if (res.status === 429) {
|
|
29
53
|
const retryAfter = res.headers?.get?.("Retry-After");
|
|
30
54
|
const days = retryAfter ? Math.ceil(parseInt(retryAfter, 10) / 86400) : null;
|
|
31
|
-
const resetInfo = days !== null ? `
|
|
32
|
-
throw new Error(`
|
|
55
|
+
const resetInfo = days !== null ? ` Resets in ${days} day(s).` : "";
|
|
56
|
+
throw new Error(`Too many requests.${resetInfo}`);
|
|
33
57
|
}
|
|
34
|
-
|
|
58
|
+
const body = (await res.json().catch(() => ({})));
|
|
59
|
+
const mode = getAuthMode(apiKey ?? process.env.CENOGRAM_API_KEY);
|
|
60
|
+
throw new Error(authErrorMessage(res.status, mode, body));
|
|
35
61
|
}
|
|
36
62
|
function toQueryParams(obj) {
|
|
37
63
|
const params = {};
|
|
@@ -55,7 +81,7 @@ export async function fetchApi(path, params, apiKey) {
|
|
|
55
81
|
try {
|
|
56
82
|
const res = await fetch(url.toString(), { signal: controller.signal, headers: buildHeaders(apiKey) });
|
|
57
83
|
if (!res.ok)
|
|
58
|
-
await handleErrorResponse(res);
|
|
84
|
+
await handleErrorResponse(res, apiKey);
|
|
59
85
|
return { data: (await res.json()), creditInfo: extractCreditInfo(res) };
|
|
60
86
|
}
|
|
61
87
|
finally {
|
|
@@ -76,7 +102,7 @@ export async function fetchApiPost(path, body, apiKey) {
|
|
|
76
102
|
body: JSON.stringify(body),
|
|
77
103
|
});
|
|
78
104
|
if (!res.ok)
|
|
79
|
-
await handleErrorResponse(res);
|
|
105
|
+
await handleErrorResponse(res, apiKey);
|
|
80
106
|
return { data: (await res.json()), creditInfo: extractCreditInfo(res) };
|
|
81
107
|
}
|
|
82
108
|
finally {
|
|
@@ -90,6 +116,7 @@ export function getStats(apiKey) {
|
|
|
90
116
|
export function getTransactions(p, apiKey) {
|
|
91
117
|
return fetchApi("/api/transactions", toQueryParams({
|
|
92
118
|
district: p.district,
|
|
119
|
+
teryt: p.teryt,
|
|
93
120
|
street: p.street,
|
|
94
121
|
buildingNumber: p.buildingNumber,
|
|
95
122
|
parcelId: p.parcelId,
|
|
@@ -114,6 +141,7 @@ export function getTransactions(p, apiKey) {
|
|
|
114
141
|
export function getTransactionsSummary(p, apiKey) {
|
|
115
142
|
return fetchApi("/api/transactions/summary", toQueryParams({
|
|
116
143
|
district: p.district,
|
|
144
|
+
teryt: p.teryt,
|
|
117
145
|
street: p.street,
|
|
118
146
|
propertyType: p.propertyType,
|
|
119
147
|
marketType: p.marketType,
|
|
@@ -135,6 +163,9 @@ export function getPricePerM2(apiKey) {
|
|
|
135
163
|
export function getDistricts(apiKey) {
|
|
136
164
|
return fetchApi("/api/districts", undefined, apiKey);
|
|
137
165
|
}
|
|
166
|
+
export function getLocations(parent, apiKey) {
|
|
167
|
+
return fetchApi("/api/locations", parent ? { parent } : undefined, apiKey);
|
|
168
|
+
}
|
|
138
169
|
export function getPriceHistogram(bins = 20, max = 3_000_000, apiKey) {
|
|
139
170
|
return fetchApi("/api/stats/price-histogram", toQueryParams({ bins, max }), apiKey);
|
|
140
171
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { validateOAuthJwt, type ValidationReason } from "./oauth-jwt.js";
|
|
2
|
+
export declare const RESOURCE_METADATA = "https://mcp.cenogram.pl/.well-known/oauth-protected-resource";
|
|
3
|
+
export declare function sanitizeForLog(s: string): string;
|
|
4
|
+
export type DispatchResult = {
|
|
5
|
+
kind: "passthrough";
|
|
6
|
+
apiKey: string;
|
|
7
|
+
log: {
|
|
8
|
+
evt: "auth.passthrough";
|
|
9
|
+
mode: "oauth";
|
|
10
|
+
grant_id: string;
|
|
11
|
+
} | {
|
|
12
|
+
evt: "auth.passthrough";
|
|
13
|
+
mode: "api_key";
|
|
14
|
+
key_prefix: string | null;
|
|
15
|
+
};
|
|
16
|
+
} | {
|
|
17
|
+
kind: "401";
|
|
18
|
+
headers: {
|
|
19
|
+
"Content-Type": string;
|
|
20
|
+
"WWW-Authenticate": string;
|
|
21
|
+
};
|
|
22
|
+
body: {
|
|
23
|
+
error: string;
|
|
24
|
+
error_description: string;
|
|
25
|
+
};
|
|
26
|
+
log: {
|
|
27
|
+
evt: "auth.rejected";
|
|
28
|
+
reason: "missing" | "invalid_format" | "oversized_header" | ValidationReason;
|
|
29
|
+
kid: string | null;
|
|
30
|
+
};
|
|
31
|
+
} | {
|
|
32
|
+
kind: "403";
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": string;
|
|
35
|
+
"WWW-Authenticate": string;
|
|
36
|
+
};
|
|
37
|
+
body: {
|
|
38
|
+
error: string;
|
|
39
|
+
error_description: string;
|
|
40
|
+
};
|
|
41
|
+
log: {
|
|
42
|
+
evt: "auth.rejected";
|
|
43
|
+
reason: "insufficient_scope";
|
|
44
|
+
};
|
|
45
|
+
} | {
|
|
46
|
+
kind: "500";
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": string;
|
|
49
|
+
};
|
|
50
|
+
body: {
|
|
51
|
+
error: string;
|
|
52
|
+
error_description: string;
|
|
53
|
+
};
|
|
54
|
+
log: {
|
|
55
|
+
evt: "auth.config_missing";
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
export type ValidateJwtFn = typeof validateOAuthJwt;
|
|
59
|
+
export declare function dispatchAuth(authHeader: string | undefined, validateJwt?: ValidateJwtFn): Promise<DispatchResult>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { validateOAuthJwt, OAuthConfigError } from "./oauth-jwt.js";
|
|
2
|
+
import { encodeOAuthCtx } from "./api-client.js";
|
|
3
|
+
export const RESOURCE_METADATA = "https://mcp.cenogram.pl/.well-known/oauth-protected-resource";
|
|
4
|
+
// RFC 6750 § 3 quoted-string alphabet: %x20-21 / %x23-5B / %x5D-7E (ASCII visible without " or \)
|
|
5
|
+
const QUOTED_RE = /^[\x20\x21\x23-\x5B\x5D-\x7E]*$/;
|
|
6
|
+
// RFC 7235 BWS (bad whitespace): SP / HTAB
|
|
7
|
+
const BEARER_RE = /^Bearer[ \t]+(.+)$/i;
|
|
8
|
+
// JWT shape: 3 base64url segments separated by dots, starting with eyJ
|
|
9
|
+
const JWT_SHAPE_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
10
|
+
// Log-unsafe codepoints (log poisoning via multi-byte UTF-8 controls):
|
|
11
|
+
// \x00-\x1F ASCII C0 controls
|
|
12
|
+
// \x7F-\x9F DEL + C1 controls (incl. U+0085 NEL = bytes 0xc2 0x85)
|
|
13
|
+
// U+2028, U+2029 Line/Paragraph Separator (NOT escaped by JSON.stringify)
|
|
14
|
+
// U+202A-U+202E BiDi controls LRE/RLE/PDF/LRO/RLO (Trojan Source attacks)
|
|
15
|
+
// U+2066-U+2069 BiDi isolates LRI/RLI/FSI/PDI
|
|
16
|
+
// U+FEFF Zero-Width No-Break Space / BOM
|
|
17
|
+
// Goal: prevent log injection / terminal RTL render manipulation when user-controlled
|
|
18
|
+
// bytes (kid in JWT header, suffix of cngrm_ API key) reach stderr JSON logs.
|
|
19
|
+
// eslint-disable-next-line no-control-regex -- control chars in regex are the whole point
|
|
20
|
+
const LOG_UNSAFE_RE = /[\x00-\x1F\x7F-\x9F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/g;
|
|
21
|
+
export function sanitizeForLog(s) {
|
|
22
|
+
return s.replace(LOG_UNSAFE_RE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
23
|
+
}
|
|
24
|
+
// Static descriptions for WWW-Authenticate header - quoted-safe ASCII, no user input echoed.
|
|
25
|
+
// Note: unknown_key and invalid intentionally share the same description to avoid leaking
|
|
26
|
+
// validation timing/identity info on the wire (security: minimize info oracle).
|
|
27
|
+
const REASON_DESC = {
|
|
28
|
+
expired: "Token expired",
|
|
29
|
+
unknown_key: "Token validation failed",
|
|
30
|
+
invalid: "Token validation failed",
|
|
31
|
+
};
|
|
32
|
+
function quotedParam(name, value) {
|
|
33
|
+
if (!QUOTED_RE.test(value)) {
|
|
34
|
+
throw new Error(`BUG: WWW-Authenticate ${name} contains illegal chars: ${JSON.stringify(value)}`);
|
|
35
|
+
}
|
|
36
|
+
return `${name}="${value}"`;
|
|
37
|
+
}
|
|
38
|
+
function safeKid(h) {
|
|
39
|
+
return typeof h === "string" ? sanitizeForLog(h.slice(0, 64)) : null;
|
|
40
|
+
}
|
|
41
|
+
function build401(opts) {
|
|
42
|
+
const parts = ['Bearer realm="cenogram"'];
|
|
43
|
+
if (opts.errorParam)
|
|
44
|
+
parts.push(`error="${opts.errorParam}"`);
|
|
45
|
+
if (opts.description)
|
|
46
|
+
parts.push(quotedParam("error_description", opts.description));
|
|
47
|
+
parts.push(quotedParam("resource_metadata", RESOURCE_METADATA));
|
|
48
|
+
return {
|
|
49
|
+
headers: { "Content-Type": "application/json", "WWW-Authenticate": parts.join(", ") },
|
|
50
|
+
body: { error: opts.bodyError, error_description: opts.bodyDesc },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function build403(scope, description) {
|
|
54
|
+
const parts = [
|
|
55
|
+
'Bearer realm="cenogram"',
|
|
56
|
+
`error="insufficient_scope"`,
|
|
57
|
+
quotedParam("scope", scope),
|
|
58
|
+
quotedParam("error_description", description),
|
|
59
|
+
quotedParam("resource_metadata", RESOURCE_METADATA),
|
|
60
|
+
];
|
|
61
|
+
return {
|
|
62
|
+
headers: { "Content-Type": "application/json", "WWW-Authenticate": parts.join(", ") },
|
|
63
|
+
body: { error: "insufficient_scope", error_description: description },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export async function dispatchAuth(authHeader, validateJwt = validateOAuthJwt) {
|
|
67
|
+
// RFC 6750 §3.1: oversized auth attempt → invalid_token (client tried auth, not missing).
|
|
68
|
+
// 8192 B = below Node default http.maxHeaderSize (16KB), accommodates JWT with claims +
|
|
69
|
+
// Bearer prefix (~700-1500 bytes for current claim set: sub, scope, grant_id, client_id).
|
|
70
|
+
// Check before regex match to avoid ReDoS / memory on huge string.
|
|
71
|
+
const MAX_AUTH_HEADER_BYTES = 8192;
|
|
72
|
+
if (authHeader && Buffer.byteLength(authHeader, "utf-8") > MAX_AUTH_HEADER_BYTES) {
|
|
73
|
+
return {
|
|
74
|
+
kind: "401",
|
|
75
|
+
...build401({
|
|
76
|
+
errorParam: "invalid_token",
|
|
77
|
+
description: "Authorization header too large",
|
|
78
|
+
bodyError: "invalid_token",
|
|
79
|
+
bodyDesc: "Authorization header exceeds maximum size",
|
|
80
|
+
}),
|
|
81
|
+
log: { evt: "auth.rejected", reason: "oversized_header", kid: null },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Parse Bearer token (RFC 7235: BWS = SP/HTAB)
|
|
85
|
+
const match = authHeader?.match(BEARER_RE);
|
|
86
|
+
const rawToken = match?.[1]?.trim() ?? "";
|
|
87
|
+
if (!rawToken) {
|
|
88
|
+
return {
|
|
89
|
+
kind: "401",
|
|
90
|
+
...build401({
|
|
91
|
+
bodyError: "missing_token",
|
|
92
|
+
bodyDesc: "Provide an OAuth access token or API key in Authorization: Bearer header.",
|
|
93
|
+
}),
|
|
94
|
+
log: { evt: "auth.rejected", reason: "missing", kid: null },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// Fast path: API key (most common). Format validation delegated to upstream API.
|
|
98
|
+
if (rawToken.startsWith("cngrm_")) {
|
|
99
|
+
return {
|
|
100
|
+
kind: "passthrough",
|
|
101
|
+
apiKey: rawToken,
|
|
102
|
+
log: { evt: "auth.passthrough", mode: "api_key", key_prefix: sanitizeForLog(rawToken.slice(0, 10)) },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// OAuth JWT shape check - reject anything that doesn't look like a JWT before crypto ops
|
|
106
|
+
if (!JWT_SHAPE_RE.test(rawToken)) {
|
|
107
|
+
return {
|
|
108
|
+
kind: "401",
|
|
109
|
+
...build401({
|
|
110
|
+
errorParam: "invalid_token",
|
|
111
|
+
description: "Token format not recognized",
|
|
112
|
+
bodyError: "invalid_token",
|
|
113
|
+
bodyDesc: "Expected OAuth JWT or Cenogram API key (cngrm_...)",
|
|
114
|
+
}),
|
|
115
|
+
log: { evt: "auth.rejected", reason: "invalid_format", kid: null },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// Validate JWT
|
|
119
|
+
let result;
|
|
120
|
+
try {
|
|
121
|
+
result = await validateJwt(rawToken);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
if (e instanceof OAuthConfigError) {
|
|
125
|
+
return {
|
|
126
|
+
kind: "500",
|
|
127
|
+
headers: { "Content-Type": "application/json" },
|
|
128
|
+
body: { error: "server_misconfigured", error_description: "OAuth not configured on server" },
|
|
129
|
+
log: { evt: "auth.config_missing" },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
if (!result.ok) {
|
|
135
|
+
// Extract kid from header for logging (best-effort, not on wire)
|
|
136
|
+
let headerKid = null;
|
|
137
|
+
try {
|
|
138
|
+
const headerJson = JSON.parse(Buffer.from(rawToken.split(".")[0], "base64url").toString("utf-8"));
|
|
139
|
+
headerKid = safeKid(headerJson.kid);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// ignore - kid logging is best-effort
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
kind: "401",
|
|
146
|
+
...build401({
|
|
147
|
+
errorParam: "invalid_token",
|
|
148
|
+
description: REASON_DESC[result.reason],
|
|
149
|
+
bodyError: "invalid_token",
|
|
150
|
+
bodyDesc: REASON_DESC[result.reason],
|
|
151
|
+
}),
|
|
152
|
+
log: { evt: "auth.rejected", reason: result.reason, kid: headerKid },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (!result.claims.scope.split(" ").includes("mcp")) {
|
|
156
|
+
return {
|
|
157
|
+
kind: "403",
|
|
158
|
+
...build403("mcp", "Token does not include 'mcp' scope"),
|
|
159
|
+
log: { evt: "auth.rejected", reason: "insufficient_scope" },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
kind: "passthrough",
|
|
164
|
+
apiKey: encodeOAuthCtx(result.claims.sub, result.claims.grant_id),
|
|
165
|
+
// Sanitize grant_id for log consistency with key_prefix/kid (defense in depth - JWT is signed but spec allows custom claims).
|
|
166
|
+
log: { evt: "auth.passthrough", mode: "oauth", grant_id: sanitizeForLog(result.claims.grant_id) },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type AuthMode = "oauth" | "api_key" | "stdio_env" | "none";
|
|
2
|
+
export declare function getAuthMode(apiKey?: string): AuthMode;
|
|
3
|
+
export interface ErrorBody {
|
|
4
|
+
error?: string;
|
|
5
|
+
currentBalance?: number;
|
|
6
|
+
creditsRequired?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function authErrorMessage(status: number, mode: AuthMode, body?: ErrorBody): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// SOH char (\x01) - must match OAUTH_CTX_PREFIX in api-client.ts
|
|
2
|
+
const OAUTH_CTX_PREFIX = "\x01";
|
|
3
|
+
export function getAuthMode(apiKey) {
|
|
4
|
+
if (!apiKey)
|
|
5
|
+
return "none";
|
|
6
|
+
if (apiKey.startsWith(OAUTH_CTX_PREFIX))
|
|
7
|
+
return "oauth";
|
|
8
|
+
if (apiKey.startsWith("cngrm_"))
|
|
9
|
+
return "api_key";
|
|
10
|
+
return "stdio_env";
|
|
11
|
+
}
|
|
12
|
+
export function authErrorMessage(status, mode, body = {}) {
|
|
13
|
+
switch (status) {
|
|
14
|
+
case 401:
|
|
15
|
+
if (mode === "oauth") {
|
|
16
|
+
return "Connection to Cenogram expired or was revoked. In Claude open: Settings > Connectors > Cenogram, disconnect and reconnect.";
|
|
17
|
+
}
|
|
18
|
+
return "API key rejected. Check https://cenogram.pl/api/keys if it's still active.";
|
|
19
|
+
case 402: {
|
|
20
|
+
const balance = body.currentBalance ?? 0;
|
|
21
|
+
const required = body.creditsRequired ?? "?";
|
|
22
|
+
if (mode === "oauth") {
|
|
23
|
+
return `Insufficient credits (balance: ${balance}, query cost: ${required}). Top up: https://cenogram.pl/api#cennik`;
|
|
24
|
+
}
|
|
25
|
+
return `Insufficient credits for key's account (balance: ${balance}, query cost: ${required}). Top up: https://cenogram.pl/api#cennik`;
|
|
26
|
+
}
|
|
27
|
+
case 403:
|
|
28
|
+
if (body.error === "email_not_verified") {
|
|
29
|
+
return "Account email not verified. Check your inbox, click the activation link, then retry.";
|
|
30
|
+
}
|
|
31
|
+
return `Access denied (HTTP 403).`;
|
|
32
|
+
case 503:
|
|
33
|
+
return "Cenogram temporarily unavailable (maintenance mode). Try again shortly.";
|
|
34
|
+
default:
|
|
35
|
+
return `Cenogram API unavailable (HTTP ${status}). Try again shortly.`;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/formatters.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Transaction, TransactionsResponse, TransactionsSummary, StatsResponse, PricePerM2Row, HistogramBin, ParcelSearchResponse, SpatialSearchResponse, CompareResponse } from "./api-client.js";
|
|
1
|
+
import type { Transaction, TransactionsResponse, TransactionsSummary, StatsResponse, PricePerM2Row, HistogramBin, ParcelSearchResponse, SpatialSearchResponse, CompareResponse, LocationItem } from "./api-client.js";
|
|
2
2
|
export declare function formatPLN(value: number | null | undefined): string;
|
|
3
3
|
export declare function formatArea(m2: number | null | undefined): string;
|
|
4
4
|
export declare function formatNumber(value: number | null | undefined): string;
|
|
@@ -9,4 +9,5 @@ export declare function formatPriceStats(rows: PricePerM2Row[], location?: strin
|
|
|
9
9
|
export declare function formatHistogram(bins: HistogramBin[]): string;
|
|
10
10
|
export declare function formatParcelResults(res: ParcelSearchResponse, query: string): string;
|
|
11
11
|
export declare function formatSpatialResults(res: SpatialSearchResponse): string;
|
|
12
|
+
export declare function formatLocationHierarchy(items: LocationItem[], parent?: string): string;
|
|
12
13
|
export declare function formatCompareResults(res: CompareResponse): string;
|
package/dist/formatters.js
CHANGED
|
@@ -24,10 +24,12 @@ export function formatNumber(value) {
|
|
|
24
24
|
function formatTransactionCore(f) {
|
|
25
25
|
const parts = [];
|
|
26
26
|
// Address with optional county/voivodeship
|
|
27
|
-
const
|
|
27
|
+
const streetAddr = [f.street, f.building_number].filter(Boolean).join(" ");
|
|
28
28
|
const district = f.district || f.city;
|
|
29
|
-
const region = [f.county_name ? `
|
|
30
|
-
const loc =
|
|
29
|
+
const region = [f.county_name ? `county: ${f.county_name}` : null, f.voivodeship_name ? `voivodeship: ${f.voivodeship_name}` : null].filter(Boolean).join(", ");
|
|
30
|
+
const loc = f.street
|
|
31
|
+
? [streetAddr, district].filter(Boolean).join(", ")
|
|
32
|
+
: [district, f.building_number].filter(Boolean).join(" ");
|
|
31
33
|
if (loc && region)
|
|
32
34
|
parts.push(`${loc} (${region})`);
|
|
33
35
|
else if (loc)
|
|
@@ -172,8 +174,9 @@ export function formatParcelResults(res, query) {
|
|
|
172
174
|
for (const [i, p] of res.results.entries()) {
|
|
173
175
|
const district = p.district ?? "Unknown";
|
|
174
176
|
const area = p.area_m2 != null ? formatArea(p.area_m2) : "N/A";
|
|
177
|
+
const location = `${p.lat.toFixed(4)}\u00B0N, ${p.lng.toFixed(4)}\u00B0E`;
|
|
175
178
|
lines.push(`${i + 1}. ${p.parcel_id}`);
|
|
176
|
-
lines.push(` District: ${district} | Area: ${area} | Location: ${
|
|
179
|
+
lines.push(` District: ${district} | Area: ${area} | Location: ${location}`);
|
|
177
180
|
}
|
|
178
181
|
return lines.join("\n");
|
|
179
182
|
}
|
|
@@ -206,6 +209,39 @@ export function formatSpatialResults(res) {
|
|
|
206
209
|
}
|
|
207
210
|
return lines.join("\n");
|
|
208
211
|
}
|
|
212
|
+
// ── Location hierarchy formatting ─────────────────────────────────
|
|
213
|
+
const LEVEL_TIPS = {
|
|
214
|
+
voivodeship: "Use a 2-digit code as 'parent' to browse counties.",
|
|
215
|
+
county: "Use a 4-digit code as 'parent' to browse municipalities.",
|
|
216
|
+
municipality: "Use a 6-digit code as 'parent' to browse precincts, or use any code with 'teryt' in search_transactions.",
|
|
217
|
+
precinct: "Use these precinct codes with 'teryt' in search_transactions for precise area filtering.",
|
|
218
|
+
};
|
|
219
|
+
export function formatLocationHierarchy(items, parent) {
|
|
220
|
+
if (items.length === 0) {
|
|
221
|
+
if (parent) {
|
|
222
|
+
if (parent.length >= 6) {
|
|
223
|
+
return `No sub-locations found for TERYT code '${parent}'. This may be a leaf code - use it directly with search_transactions(teryt='${parent}').`;
|
|
224
|
+
}
|
|
225
|
+
return `No sub-locations found for TERYT code '${parent}'. Verify the code is correct using list_locations.`;
|
|
226
|
+
}
|
|
227
|
+
return "No locations available.";
|
|
228
|
+
}
|
|
229
|
+
const level = items[0].level;
|
|
230
|
+
const header = parent
|
|
231
|
+
? `TERYT location hierarchy (parent: ${parent}, level: ${level}):`
|
|
232
|
+
: `TERYT location hierarchy (Poland, level: ${level}):`;
|
|
233
|
+
const plural = { voivodeship: "voivodeships", county: "counties", municipality: "municipalities", precinct: "precincts" };
|
|
234
|
+
const lines = [header, "", `Found ${items.length} ${plural[level] ?? `${level}s`}:`, ""];
|
|
235
|
+
for (const item of items) {
|
|
236
|
+
const typeSuffix = item.typeName ? ` (${item.typeName})` : "";
|
|
237
|
+
lines.push(` ${item.code} - ${item.name}${typeSuffix}`);
|
|
238
|
+
}
|
|
239
|
+
const tip = LEVEL_TIPS[level];
|
|
240
|
+
if (tip) {
|
|
241
|
+
lines.push("", `Tip: ${tip}`);
|
|
242
|
+
}
|
|
243
|
+
return lines.join("\n");
|
|
244
|
+
}
|
|
209
245
|
// ── Compare locations formatting ───────────────────────────────────
|
|
210
246
|
export function formatCompareResults(res) {
|
|
211
247
|
const districts = Object.keys(res);
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,19 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { readFileSync, realpathSync } from "node:fs";
|
|
7
|
+
import { fetch } from "undici";
|
|
7
8
|
import { registerTools } from "./tools.js";
|
|
9
|
+
import { dispatchAuth, sanitizeForLog } from "./auth-dispatch.js";
|
|
10
|
+
import { requestContext } from "./request-context.js";
|
|
11
|
+
function logAuth(payload, level) {
|
|
12
|
+
process.stderr.write(JSON.stringify({ level, ...payload }) + "\n");
|
|
13
|
+
}
|
|
14
|
+
// ── Node version check ─────────────────────────────────────────────
|
|
15
|
+
const [nodeMajor] = process.versions.node.split(".").map(Number);
|
|
16
|
+
if (nodeMajor != null && nodeMajor < 18) {
|
|
17
|
+
process.stderr.write(`Warning: @cenogram/mcp-server recommends Node.js >= 18 (current: ${process.version}). ` +
|
|
18
|
+
"The server will try to run, but some features may not work.\n");
|
|
19
|
+
}
|
|
8
20
|
// ── Version ────────────────────────────────────────────────────────
|
|
9
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
22
|
let PKG_VERSION = "0.1.0";
|
|
@@ -16,13 +28,16 @@ catch { /* fallback to hardcoded if dist/ used standalone */ }
|
|
|
16
28
|
export function createMcpServer(apiKey) {
|
|
17
29
|
const server = new McpServer({ name: "cenogram-mcp-server", version: PKG_VERSION }, {
|
|
18
30
|
instructions: [
|
|
19
|
-
"Cenogram MCP Server -
|
|
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.",
|
|
20
32
|
"",
|
|
21
33
|
"CRITICAL - District names (ALWAYS verify first):",
|
|
22
34
|
"- NEVER guess district names. Call list_locations(search=\"city\") first.",
|
|
23
35
|
"- Warsaw: 'Warszawa' auto-includes all 18 districts. Or use specific: Mokotów, Wola, Śródmieście",
|
|
24
36
|
"- Kraków/Łódź: 'Kraków'/'Łódź' auto-include all sub-districts. Or use specific: Kraków-Podgórze, etc.",
|
|
25
|
-
"- Most cities (Gdańsk, Gdynia, Sopot,
|
|
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.",
|
|
26
41
|
"",
|
|
27
42
|
"Workflows:",
|
|
28
43
|
"- Market analysis: get_market_overview → get_price_statistics(location) → search_transactions",
|
|
@@ -31,6 +46,7 @@ export function createMcpServer(apiKey) {
|
|
|
31
46
|
"- Address search: search_transactions(location, street, buildingNumber)",
|
|
32
47
|
"- Radius search: search_by_area(lat, lng, radiusKm) - for geographic proximity",
|
|
33
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)",
|
|
34
50
|
"",
|
|
35
51
|
"Data notes:",
|
|
36
52
|
"- price_per_m2 only meaningful for apartments (propertyType=\"unit\")",
|
|
@@ -48,27 +64,102 @@ async function main() {
|
|
|
48
64
|
if (mode === "http") {
|
|
49
65
|
const { createServer } = await import("node:http");
|
|
50
66
|
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
67
|
+
if (process.env.NODE_ENV === "production") {
|
|
68
|
+
if (!process.env.OAUTH_JWT_KID)
|
|
69
|
+
throw new Error("OAUTH_JWT_KID required in production");
|
|
70
|
+
if (!process.env.OAUTH_JWT_PUBLIC_KEY)
|
|
71
|
+
throw new Error("OAUTH_JWT_PUBLIC_KEY required in production");
|
|
72
|
+
if (!process.env.INTERNAL_AUTH_SECRET)
|
|
73
|
+
throw new Error("INTERNAL_AUTH_SECRET required in production");
|
|
74
|
+
}
|
|
51
75
|
const port = parseInt(process.env.MCP_PORT || "3002", 10);
|
|
52
76
|
const handleHttpRequest = async (req, res) => {
|
|
53
77
|
try {
|
|
54
78
|
const pathname = req.url?.split("?")[0];
|
|
55
79
|
if (pathname === "/mcp") {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const mcpServer = createMcpServer(apiKeyFromHeader);
|
|
60
|
-
try {
|
|
61
|
-
await mcpServer.connect(transport);
|
|
62
|
-
await transport.handleRequest(req, res);
|
|
80
|
+
const dispatch = await dispatchAuth(req.headers.authorization);
|
|
81
|
+
if (dispatch.kind === "passthrough") {
|
|
82
|
+
logAuth(dispatch.log, "info");
|
|
63
83
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
else if (dispatch.kind === "500") {
|
|
85
|
+
logAuth(dispatch.log, "error");
|
|
86
|
+
res.writeHead(500, dispatch.headers).end(JSON.stringify(dispatch.body));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const level = dispatch.kind === "401" && dispatch.log.reason === "missing" ? "info" : "warn";
|
|
91
|
+
const clientIp = (req.headers["cf-connecting-ip"] ?? req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "");
|
|
92
|
+
logAuth({
|
|
93
|
+
...dispatch.log,
|
|
94
|
+
method: req.method,
|
|
95
|
+
ua: sanitizeForLog((req.headers["user-agent"] ?? "").slice(0, 128)),
|
|
96
|
+
ip: sanitizeForLog(clientIp.split(",")[0].trim().slice(0, 45)),
|
|
97
|
+
}, level);
|
|
98
|
+
const status = dispatch.kind === "401" ? 401 : 403;
|
|
99
|
+
res.writeHead(status, dispatch.headers).end(JSON.stringify(dispatch.body));
|
|
100
|
+
return;
|
|
67
101
|
}
|
|
102
|
+
const clientUA = (req.headers["user-agent"] ?? "").slice(0, 512) || undefined;
|
|
103
|
+
await requestContext.run({ clientUserAgent: clientUA }, async () => {
|
|
104
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
105
|
+
const mcpServer = createMcpServer(dispatch.apiKey);
|
|
106
|
+
try {
|
|
107
|
+
await mcpServer.connect(transport);
|
|
108
|
+
await transport.handleRequest(req, res);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await transport.close();
|
|
112
|
+
await mcpServer.close();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else if (pathname === "/.well-known/oauth-protected-resource") {
|
|
117
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
|
|
118
|
+
resource: "https://mcp.cenogram.pl",
|
|
119
|
+
authorization_servers: ["https://api.cenogram.pl"],
|
|
120
|
+
scopes_supported: ["mcp"],
|
|
121
|
+
bearer_methods_supported: ["header"],
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
else if (pathname === "/.well-known/mcp.json") {
|
|
125
|
+
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }).end(JSON.stringify({
|
|
126
|
+
name: "Cenogram",
|
|
127
|
+
description: "8M+ verified real estate transactions from Poland's official RCN registry. Transaction prices from notarial deeds, 380 counties, data from 2003.",
|
|
128
|
+
icon: "https://cenogram.pl/apple-touch-icon.png",
|
|
129
|
+
endpoint: "https://mcp.cenogram.pl/mcp",
|
|
130
|
+
}));
|
|
68
131
|
}
|
|
69
132
|
else if (pathname === "/health") {
|
|
70
133
|
res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
|
|
71
134
|
}
|
|
135
|
+
else if (pathname === "/health/deep") {
|
|
136
|
+
const apiUrl = process.env.CENOGRAM_API_URL || "https://cenogram.pl";
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const timeout = setTimeout(() => controller.abort(), 3_000);
|
|
139
|
+
let apiStatus = "fail";
|
|
140
|
+
let apiError;
|
|
141
|
+
try {
|
|
142
|
+
const apiRes = await fetch(`${apiUrl}/api/health`, { signal: controller.signal });
|
|
143
|
+
if (apiRes.ok) {
|
|
144
|
+
apiStatus = "ok";
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
apiError = `HTTP ${apiRes.status}`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
apiError = err instanceof Error ? err.message : String(err);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
}
|
|
156
|
+
const code = apiStatus === "ok" ? 200 : 503;
|
|
157
|
+
res.writeHead(code, { "Content-Type": "application/json" }).end(JSON.stringify({
|
|
158
|
+
status: apiStatus === "ok" ? "ok" : "degraded",
|
|
159
|
+
dependencies: { api: apiStatus, ...(apiError ? { apiError } : {}) },
|
|
160
|
+
apiUrl,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
72
163
|
else if (pathname === "/robots.txt") {
|
|
73
164
|
res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "public, max-age=86400" }).end("User-agent: *\nDisallow: /\n");
|
|
74
165
|
}
|
package/dist/mappings.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export declare const BUILDING_TYPES: Record<number, string>;
|
|
|
8
8
|
export declare function mapBuildingType(value: string | undefined): number | undefined;
|
|
9
9
|
/** Convert lat/lng/radius to bbox [minLng, minLat, maxLng, maxLat] (lng-first!) */
|
|
10
10
|
export declare function radiusKmToBbox(lat: number, lng: number, radiusKm: number): [number, number, number, number];
|
|
11
|
-
/** Filter districts by location name (case-insensitive includes match) */
|
|
11
|
+
/** Filter districts by location name (case-insensitive, diacritics-insensitive includes match) */
|
|
12
12
|
export declare function filterByLocation(location: string, districts: string[]): string[];
|
|
13
13
|
export declare const CITY_SUBDISTRICTS: ReadonlyMap<string, readonly string[]>;
|
|
14
14
|
/** Returns sub-districts for known multi-district cities, or [district] for everything else. */
|
package/dist/mappings.js
CHANGED
|
@@ -94,14 +94,15 @@ export function radiusKmToBbox(lat, lng, radiusKm) {
|
|
|
94
94
|
];
|
|
95
95
|
}
|
|
96
96
|
// ── Location filtering ──────────────────────────────────────────────
|
|
97
|
-
|
|
97
|
+
function stripDiacritics(s) {
|
|
98
|
+
return s.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[łŁ]/g, (c) => c === "ł" ? "l" : "L");
|
|
99
|
+
}
|
|
100
|
+
/** Filter districts by location name (case-insensitive, diacritics-insensitive includes match) */
|
|
98
101
|
export function filterByLocation(location, districts) {
|
|
99
|
-
const
|
|
100
|
-
return districts.filter((d) => d.toLowerCase().includes(
|
|
102
|
+
const needle = stripDiacritics(location.toLowerCase());
|
|
103
|
+
return districts.filter((d) => stripDiacritics(d.toLowerCase()).includes(needle));
|
|
101
104
|
}
|
|
102
105
|
// ── 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
106
|
export const CITY_SUBDISTRICTS = new Map([
|
|
106
107
|
["Warszawa", [
|
|
107
108
|
"Warszawa", "Bemowo", "Białołęka", "Bielany", "Mokotów", "Ochota",
|
|
@@ -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,65 @@
|
|
|
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|\\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
|
+
// kid check BEFORE signature verify - rejects unknown kid without expensive crypto op
|
|
24
|
+
export async function validateOAuthJwt(token) {
|
|
25
|
+
// Config errors propagate (caller maps to 500). Token errors return reason.
|
|
26
|
+
const pair = await getKeyPair();
|
|
27
|
+
let header;
|
|
28
|
+
try {
|
|
29
|
+
header = decodeProtectedHeader(token);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { ok: false, reason: "invalid" };
|
|
33
|
+
}
|
|
34
|
+
if (!header.kid || header.kid !== pair.kid) {
|
|
35
|
+
return { ok: false, reason: "unknown_key" };
|
|
36
|
+
}
|
|
37
|
+
let payload;
|
|
38
|
+
try {
|
|
39
|
+
({ payload } = await jwtVerify(token, pair.key, {
|
|
40
|
+
algorithms: ["RS256"],
|
|
41
|
+
issuer: ISSUER,
|
|
42
|
+
audience: MCP_AUDIENCE,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e instanceof joseErrors.JWTExpired)
|
|
47
|
+
return { ok: false, reason: "expired" };
|
|
48
|
+
return { ok: false, reason: "invalid" };
|
|
49
|
+
}
|
|
50
|
+
if (typeof payload.sub !== "string" ||
|
|
51
|
+
typeof payload.scope !== "string" ||
|
|
52
|
+
typeof payload.grant_id !== "string" ||
|
|
53
|
+
typeof payload.client_id !== "string") {
|
|
54
|
+
return { ok: false, reason: "invalid" };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
claims: {
|
|
59
|
+
sub: payload.sub,
|
|
60
|
+
scope: payload.scope,
|
|
61
|
+
grant_id: payload.grant_id,
|
|
62
|
+
client_id: payload.client_id,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -1,42 +1,72 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { getStats, getTransactions, getPricePerM2, getDistricts, getPriceHistogram, getTransactionsSummary, searchParcels, searchByPolygon, compareLocations, } from "./api-client.js";
|
|
3
|
-
import { formatTransactionList, formatMarketOverview, formatPriceStats, formatHistogram, formatParcelResults, formatSpatialResults, formatCompareResults, } from "./formatters.js";
|
|
2
|
+
import { getStats, getTransactions, getPricePerM2, getDistricts, getLocations, getPriceHistogram, getTransactionsSummary, searchParcels, searchByPolygon, compareLocations, } from "./api-client.js";
|
|
3
|
+
import { formatTransactionList, formatMarketOverview, formatPriceStats, formatHistogram, formatParcelResults, formatSpatialResults, formatCompareResults, formatLocationHierarchy, } from "./formatters.js";
|
|
4
4
|
import { mapPropertyType, mapMarketType, mapUnitFunction, mapBuildingType, radiusKmToBbox, filterByLocation, expandDistrict, CITY_SUBDISTRICTS, } from "./mappings.js";
|
|
5
5
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
6
|
+
function sanitizeInput(s, maxLen = 50) {
|
|
7
|
+
return s.replace(/[<>]/g, "").slice(0, maxLen);
|
|
8
|
+
}
|
|
6
9
|
function textResponse(text) {
|
|
7
10
|
return { content: [{ type: "text", text }] };
|
|
8
11
|
}
|
|
9
12
|
function formatCreditFooter(creditInfo) {
|
|
10
13
|
if (!creditInfo)
|
|
11
14
|
return "";
|
|
12
|
-
return `\n---\
|
|
15
|
+
return `\n---\nAPI tokens: ${creditInfo.balance} remaining (query cost: ${creditInfo.cost})`;
|
|
13
16
|
}
|
|
14
17
|
function requireApiKey(apiKey) {
|
|
15
18
|
if (!apiKey) {
|
|
16
|
-
throw new Error("
|
|
19
|
+
throw new Error("Internal: missing auth context. " +
|
|
20
|
+
"stdio: set CENOGRAM_API_KEY env var (key from https://cenogram.pl/api/keys). " +
|
|
21
|
+
"HTTP MCP: report bug - https://github.com/cenogram/mcp-server/issues");
|
|
17
22
|
}
|
|
18
23
|
}
|
|
19
|
-
|
|
24
|
+
function extractKeyPrefix(apiKey) {
|
|
25
|
+
if (!apiKey)
|
|
26
|
+
return null;
|
|
27
|
+
if (apiKey.startsWith("\x01"))
|
|
28
|
+
return "oauth";
|
|
29
|
+
if (apiKey.startsWith("cngrm_"))
|
|
30
|
+
return apiKey.slice(0, 10);
|
|
31
|
+
return apiKey.slice(0, 4);
|
|
32
|
+
}
|
|
33
|
+
async function withErrorHandling(toolName, apiKey, fn) {
|
|
34
|
+
const start = Date.now();
|
|
35
|
+
let success = true;
|
|
20
36
|
try {
|
|
21
37
|
return await fn();
|
|
22
38
|
}
|
|
23
39
|
catch (error) {
|
|
40
|
+
success = false;
|
|
24
41
|
const message = error instanceof Error ? error.message : String(error);
|
|
25
42
|
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
26
43
|
}
|
|
44
|
+
finally {
|
|
45
|
+
process.stderr.write(JSON.stringify({
|
|
46
|
+
level: "info",
|
|
47
|
+
evt: "tool.call",
|
|
48
|
+
tool: toolName,
|
|
49
|
+
key_prefix: extractKeyPrefix(apiKey),
|
|
50
|
+
duration_ms: Date.now() - start,
|
|
51
|
+
success,
|
|
52
|
+
}) + "\n");
|
|
53
|
+
}
|
|
27
54
|
}
|
|
28
55
|
// ── Tool registration ──────────────────────────────────────────────
|
|
29
56
|
export function registerTools(server, apiKey) {
|
|
30
57
|
// ── Tool 1: search_transactions ─────────────────────────────────────
|
|
31
|
-
server.tool("search_transactions", `Search Polish real estate transactions from the national RCN registry (
|
|
58
|
+
server.tool("search_transactions", `Search Polish real estate transactions from the national RCN registry (8M+ records).
|
|
32
59
|
Returns transaction details: address, date, price, area, price/m², property type.
|
|
33
60
|
Use list_locations first to find valid location names.
|
|
34
|
-
Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN
|
|
61
|
+
Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN.
|
|
62
|
+
Data notes: marketType is NULL for ~55% of records (notary didn't classify) - filtering by marketType excludes them. ~1.7% of records have no transaction_date.
|
|
63
|
+
Location matches TERYT districts only - for neighborhoods (osiedla), use search_by_area instead.`, {
|
|
35
64
|
location: z.string().optional().describe("Location name - city (e.g. 'Warszawa', 'Kraków', 'Gdańsk') or district (e.g. 'Mokotów', 'Kraków-Podgórze'). 'Warszawa', 'Kraków', 'Łódź' auto-expand to all sub-districts. Use list_locations to find valid names."),
|
|
65
|
+
teryt: z.string().min(1).optional().describe("TERYT administrative code(s) for precise area filtering. Comma-separated, max 10. 2-digit (voivodeship), 4-digit (county), 6-digit (municipality), or full precinct code (e.g. '321705_2.0054'). Use list_locations to find codes. More precise than 'location' - avoids name ambiguity."),
|
|
36
66
|
propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional()
|
|
37
67
|
.describe("Property type filter"),
|
|
38
68
|
marketType: z.enum(["primary", "secondary"]).optional()
|
|
39
|
-
.describe("Market type: primary (developer) or secondary (resale)"),
|
|
69
|
+
.describe("Market type: primary (developer) or secondary (resale). ~55% of records have unknown market type and will be excluded when this filter is used."),
|
|
40
70
|
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional()
|
|
41
71
|
.describe("Unit/apartment function filter"),
|
|
42
72
|
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional()
|
|
@@ -60,10 +90,24 @@ Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN.`, {
|
|
|
60
90
|
.describe("Sort order (default: desc)"),
|
|
61
91
|
page: z.number().min(1).default(1).optional()
|
|
62
92
|
.describe("Page number for pagination (default: 1)"),
|
|
63
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
93
|
+
}, { readOnlyHint: true }, async (params) => withErrorHandling("search_transactions", apiKey, async () => {
|
|
64
94
|
requireApiKey(apiKey);
|
|
95
|
+
if (params.teryt) {
|
|
96
|
+
const TERYT_RE = /^(\d{2}|\d{4}|\d{6}|\d{6}_\d|\d{6}_\d\.\d{4})$/;
|
|
97
|
+
const codes = params.teryt.split(",").map((c) => c.trim());
|
|
98
|
+
if (codes.length > 10) {
|
|
99
|
+
return textResponse("Too many TERYT codes (max 10). Narrow your selection.");
|
|
100
|
+
}
|
|
101
|
+
const invalid = codes.filter((c) => !TERYT_RE.test(c));
|
|
102
|
+
if (invalid.length > 0) {
|
|
103
|
+
return textResponse(`Invalid TERYT code(s): ${invalid.map((c) => `'${sanitizeInput(c)}'`).join(", ")}. ` +
|
|
104
|
+
"Valid formats: 2-digit (voivodeship), 4-digit (county), 6-digit (municipality), " +
|
|
105
|
+
"or precinct (e.g. '321705_2.0054'). Use list_locations to find codes.");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
65
108
|
const txParams = {
|
|
66
109
|
district: params.location,
|
|
110
|
+
teryt: params.teryt,
|
|
67
111
|
propertyType: mapPropertyType(params.propertyType),
|
|
68
112
|
marketType: mapMarketType(params.marketType),
|
|
69
113
|
unitFunction: mapUnitFunction(params.unitFunction),
|
|
@@ -92,9 +136,10 @@ Example: search for apartments in Mokotów sold in 2024 above 500,000 PLN.`, {
|
|
|
92
136
|
// ── Tool 2: get_price_statistics ────────────────────────────────────
|
|
93
137
|
server.tool("get_price_statistics", `Get price per m² statistics by location for residential apartments in Poland.
|
|
94
138
|
Note: only covers residential units (lokale mieszkalne). For other property types, use search_transactions.
|
|
95
|
-
'Warszawa'/'Kraków'/'Łódź' auto-expand to all sub-districts (Warszawa=19, Kraków=5, Łódź=6). Other names use partial match
|
|
139
|
+
'Warszawa'/'Kraków'/'Łódź' auto-expand to all sub-districts (Warszawa=19, Kraków=5, Łódź=6). Other names use partial match.
|
|
140
|
+
Data quality: based on transaction prices from notarial deeds, not asking/listing prices. Coverage varies by county (some have data gaps of 5+ years).`, {
|
|
96
141
|
location: z.string().optional().describe("Filter by location name. 'Warszawa'/'Kraków'/'Łódź' auto-expand to all sub-districts. Other names use case-insensitive partial match (e.g. 'Wrocł' matches 'Wrocław'). Omit for all Poland."),
|
|
97
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
142
|
+
}, { readOnlyHint: true }, async (params) => withErrorHandling("get_price_statistics", apiKey, async () => {
|
|
98
143
|
requireApiKey(apiKey);
|
|
99
144
|
const { data: allRows, creditInfo } = await getPricePerM2(apiKey);
|
|
100
145
|
let rows = allRows;
|
|
@@ -116,26 +161,27 @@ Useful for understanding the overall market price structure in Poland.`, {
|
|
|
116
161
|
.describe("Number of price bins (5-50, default 20)"),
|
|
117
162
|
maxPrice: z.number().default(3_000_000)
|
|
118
163
|
.describe("Maximum price to include (default 3,000,000 PLN)"),
|
|
119
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
164
|
+
}, { readOnlyHint: true }, async (params) => withErrorHandling("get_price_distribution", apiKey, async () => {
|
|
120
165
|
requireApiKey(apiKey);
|
|
121
166
|
const { data: bins, creditInfo } = await getPriceHistogram(params.bins, params.maxPrice, apiKey);
|
|
122
167
|
return textResponse(formatHistogram(bins) + formatCreditFooter(creditInfo));
|
|
123
168
|
}));
|
|
124
169
|
// ── Tool 4: search_by_area ──────────────────────────────────────────
|
|
125
170
|
server.tool("search_by_area", `Search real estate transactions within a geographic radius.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
171
|
+
Best tool for neighborhood/osiedle searches (neighborhoods are not TERYT districts).
|
|
172
|
+
Radius guide: 0.3-0.5 km for a street, 0.5-1 km for a neighborhood, 2-5 km for a city area.
|
|
173
|
+
Example: apartments in Wrocław's Nowy Dwór (lat 51.143, lng 16.993, radiusKm=0.7).
|
|
174
|
+
Area filters (minArea/maxArea) work for all propertyType values.`, {
|
|
129
175
|
latitude: z.number().min(49).max(55)
|
|
130
176
|
.describe("Latitude (Poland range: 49-55)"),
|
|
131
177
|
longitude: z.number().min(14).max(25)
|
|
132
178
|
.describe("Longitude (Poland range: 14-25)"),
|
|
133
179
|
radiusKm: z.number().min(0.1).max(50).default(2)
|
|
134
|
-
.describe("Search radius in
|
|
180
|
+
.describe("Search radius in km (0.1-50, default 2). Use 0.5-1 for neighborhoods, 0.3-0.5 for streets."),
|
|
135
181
|
propertyType: z.enum(["land", "building", "developed_land", "unit"]).optional()
|
|
136
182
|
.describe("Property type filter"),
|
|
137
183
|
marketType: z.enum(["primary", "secondary"]).optional()
|
|
138
|
-
.describe("Market type filter"),
|
|
184
|
+
.describe("Market type: primary (developer) or secondary (resale). ~55% of records have unknown market type and will be excluded when this filter is used."),
|
|
139
185
|
unitFunction: z.enum(["residential", "commercial", "office", "production", "garage", "other"]).optional()
|
|
140
186
|
.describe("Unit/apartment function filter"),
|
|
141
187
|
buildingType: z.enum(["residential", "commercial", "industrial", "transport", "office", "warehouse", "education_sports", "farm_utility", "hospital", "other_nonresidential"]).optional()
|
|
@@ -150,7 +196,7 @@ Area filters (minArea/maxArea) work for all propertyType values via COALESCE(usa
|
|
|
150
196
|
dateTo: z.string().optional().describe("End date (YYYY-MM-DD)"),
|
|
151
197
|
limit: z.number().min(1).max(50).default(20)
|
|
152
198
|
.describe("Number of results (1-50, default 20)"),
|
|
153
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
199
|
+
}, { readOnlyHint: true }, async (params) => withErrorHandling("search_by_area", apiKey, async () => {
|
|
154
200
|
requireApiKey(apiKey);
|
|
155
201
|
const bbox = radiusKmToBbox(params.latitude, params.longitude, params.radiusKm);
|
|
156
202
|
const txParams = {
|
|
@@ -177,20 +223,37 @@ Area filters (minArea/maxArea) work for all propertyType values via COALESCE(usa
|
|
|
177
223
|
}));
|
|
178
224
|
// ── Tool 5: get_market_overview ─────────────────────────────────────
|
|
179
225
|
server.tool("get_market_overview", `Get a comprehensive overview of the Polish real estate transaction database.
|
|
180
|
-
Returns: total transaction count, date range, breakdown by property type and market type, top locations, price statistics
|
|
226
|
+
Returns: total transaction count, date range, breakdown by property type and market type, top locations, price statistics.
|
|
227
|
+
Note: data quality varies by field - marketType is unknown for ~55% of records, transaction_date missing for ~1.7%.`, {}, { readOnlyHint: true }, async () => withErrorHandling("get_market_overview", apiKey, async () => {
|
|
181
228
|
requireApiKey(apiKey);
|
|
182
229
|
const { data: stats, creditInfo } = await getStats(apiKey);
|
|
183
230
|
return textResponse(formatMarketOverview(stats) + formatCreditFooter(creditInfo));
|
|
184
231
|
}));
|
|
185
232
|
// ── Tool 6: list_locations ──────────────────────────────────────────
|
|
186
|
-
server.tool("list_locations", `
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
233
|
+
server.tool("list_locations", `Browse locations in two modes:
|
|
234
|
+
1. TERYT hierarchy (parent param): Navigate voivodeship → county → municipality → precinct. Returns TERYT codes for use in search_transactions(teryt=...).
|
|
235
|
+
- No parent: 16 voivodeships (2-digit codes)
|
|
236
|
+
- 2-digit: counties (4-digit), 4-digit: municipalities (6-digit), 6-digit: precincts
|
|
237
|
+
2. Name search (search param): Find districts by name (flat list, legacy).
|
|
238
|
+
If both provided, parent takes precedence.
|
|
239
|
+
Use 'location' for quick city searches, 'teryt' for precise administrative filtering (avoids name ambiguity, e.g. 'Wałcz' is both a county and a municipality).`, {
|
|
240
|
+
parent: z.string().min(1).optional().describe("TERYT parent code to browse children. 2-digit (voivodeship → counties), 4-digit (county → municipalities), 6-digit (municipality → precincts). Omit for all voivodeships."),
|
|
241
|
+
search: z.string().min(1).optional().describe("Filter locations by name (case-insensitive partial match, e.g. 'Krak' for Kraków districts). Ignored when parent is set."),
|
|
242
|
+
}, { readOnlyHint: true }, async (params) => withErrorHandling("list_locations", apiKey, async () => {
|
|
193
243
|
requireApiKey(apiKey);
|
|
244
|
+
if (params.parent !== undefined) {
|
|
245
|
+
const parent = params.parent.trim();
|
|
246
|
+
if (!/^(\d{2}|\d{4}|\d{6})$/.test(parent)) {
|
|
247
|
+
return textResponse(`Invalid parent code '${sanitizeInput(parent)}'. Parent must be 2, 4, or 6 digits (e.g. '14' for Mazowieckie voivodeship). ` +
|
|
248
|
+
"For precinct-level codes (e.g. '321705_2.0054'), use search_transactions(teryt=...) directly.");
|
|
249
|
+
}
|
|
250
|
+
const { data: locations, creditInfo } = await getLocations(parent, apiKey);
|
|
251
|
+
return textResponse(formatLocationHierarchy(locations, parent) + formatCreditFooter(creditInfo));
|
|
252
|
+
}
|
|
253
|
+
if (params.search === undefined) {
|
|
254
|
+
const { data: locations, creditInfo } = await getLocations(undefined, apiKey);
|
|
255
|
+
return textResponse(formatLocationHierarchy(locations) + formatCreditFooter(creditInfo));
|
|
256
|
+
}
|
|
194
257
|
const { data: allDistricts, creditInfo } = await getDistricts(apiKey);
|
|
195
258
|
let districts = allDistricts;
|
|
196
259
|
if (params.search) {
|
|
@@ -203,7 +266,6 @@ Use the search parameter to filter by name.`, {
|
|
|
203
266
|
return textResponse(msg + formatCreditFooter(creditInfo));
|
|
204
267
|
}
|
|
205
268
|
const lines = [`Found ${districts.length} locations:\n`];
|
|
206
|
-
// Show all if filtered, otherwise top 50
|
|
207
269
|
const shown = params.search ? districts : districts.slice(0, 50);
|
|
208
270
|
for (const d of shown) {
|
|
209
271
|
lines.push(` - ${d}`);
|
|
@@ -221,7 +283,7 @@ Example: search for parcels starting with '146518_8.01'.`, {
|
|
|
221
283
|
q: z.string().min(3).describe("Parcel ID prefix to search for (min 3 chars). E.g. '146518_8.01'"),
|
|
222
284
|
limit: z.number().min(1).max(10).default(10).optional()
|
|
223
285
|
.describe("Max results (1-10, default 10)"),
|
|
224
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
286
|
+
}, { readOnlyHint: true }, async (params) => withErrorHandling("search_parcels", apiKey, async () => {
|
|
225
287
|
requireApiKey(apiKey);
|
|
226
288
|
const { data, creditInfo } = await searchParcels(params.q, params.limit, apiKey);
|
|
227
289
|
return textResponse(formatParcelResults(data, params.q) + formatCreditFooter(creditInfo));
|
|
@@ -230,7 +292,7 @@ Example: search for parcels starting with '146518_8.01'.`, {
|
|
|
230
292
|
server.tool("search_by_polygon", `Search real estate transactions within a geographic polygon.
|
|
231
293
|
Provide a GeoJSON Polygon geometry to search within a custom area.
|
|
232
294
|
Returns transactions found inside the polygon with coordinates.
|
|
233
|
-
Use for precise
|
|
295
|
+
Use for precise neighborhood/osiedle boundaries. Can estimate coordinates from search_by_area results. For quick searches, start with search_by_area instead.
|
|
234
296
|
Coordinates are [longitude, latitude]. First and last point must be identical.
|
|
235
297
|
Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21],[21.0,52.21],[21.0,52.2]]]}`, {
|
|
236
298
|
polygon: z.object({
|
|
@@ -257,7 +319,7 @@ Example: {"type":"Polygon","coordinates":[[[21.0,52.2],[21.01,52.2],[21.01,52.21
|
|
|
257
319
|
street: z.string().optional().describe("Street name filter (partial match)"),
|
|
258
320
|
limit: z.number().min(1).max(5000).default(100).optional()
|
|
259
321
|
.describe("Max results (1-5000, default 100). MCP displays up to 50 transactions."),
|
|
260
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
322
|
+
}, { readOnlyHint: true }, async (params) => withErrorHandling("search_by_polygon", apiKey, async () => {
|
|
261
323
|
requireApiKey(apiKey);
|
|
262
324
|
const { data, creditInfo } = await searchByPolygon({
|
|
263
325
|
polygon: params.polygon,
|
|
@@ -302,7 +364,7 @@ Example: compare Mokotów, Wola, Ursynów for apartments.`, {
|
|
|
302
364
|
minArea: z.number().optional().describe("Minimum area in m²"),
|
|
303
365
|
maxArea: z.number().optional().describe("Maximum area in m²"),
|
|
304
366
|
street: z.string().optional().describe("Street name filter"),
|
|
305
|
-
}, { readOnlyHint: true }, async (params) => withErrorHandling(async () => {
|
|
367
|
+
}, { readOnlyHint: true }, async (params) => withErrorHandling("compare_locations", apiKey, async () => {
|
|
306
368
|
requireApiKey(apiKey);
|
|
307
369
|
const { data, creditInfo } = await compareLocations({
|
|
308
370
|
districts: params.districts,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cenogram/mcp-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP Server for Polish real estate transaction data (
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP Server for Polish real estate transaction data (8M+ transactions from RCN)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cenogram-mcp": "./dist/index.js"
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
23
|
+
"jose": "^6.2.3",
|
|
24
|
+
"undici": "^5.29.0",
|
|
23
25
|
"zod": "^3.24.0"
|
|
24
26
|
},
|
|
25
27
|
"devDependencies": {
|