@cenogram/mcp-server 0.1.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,24 @@
1
1
  import { fetch } from "undici";
2
2
  import { getClientId } from "./client-id.js";
3
+ import { authErrorMessage, getAuthMode } from "./error-messages.js";
4
+ import { requestContext } from "./request-context.js";
3
5
  const BASE_URL = process.env.CENOGRAM_API_URL || "https://cenogram.pl";
6
+ export function getDemographics(params, apiKey) {
7
+ return fetchApi("/api/v1/demographics", toQueryParams({
8
+ location: params.location,
9
+ teryt: params.teryt,
10
+ year: params.year,
11
+ yearFrom: params.yearFrom,
12
+ yearTo: params.yearTo,
13
+ category: params.category,
14
+ }), apiKey);
15
+ }
16
+ export function getInfrastructureSignals(params, apiKey) {
17
+ return fetchApi("/api/v1/infrastructure-signals", toQueryParams({
18
+ location: params.location,
19
+ teryt: params.teryt,
20
+ }), apiKey);
21
+ }
4
22
  function extractCreditInfo(res) {
5
23
  if (!res.headers)
6
24
  return null;
@@ -10,29 +28,81 @@ function extractCreditInfo(res) {
10
28
  return null;
11
29
  return { balance, cost };
12
30
  }
13
- // ── Shared HTTP helpers ────────────────────────────────────────────
31
+ export const OAUTH_CTX_PREFIX = "\x01";
32
+ export function encodeOAuthCtx(userId, grantId) {
33
+ return `${OAUTH_CTX_PREFIX}${userId}${OAUTH_CTX_PREFIX}${grantId}`;
34
+ }
35
+ export function decodeOAuthCtx(key) {
36
+ if (!key.startsWith(OAUTH_CTX_PREFIX))
37
+ return null;
38
+ const rest = key.slice(OAUTH_CTX_PREFIX.length);
39
+ const sepIdx = rest.indexOf(OAUTH_CTX_PREFIX);
40
+ if (sepIdx <= 0)
41
+ return null;
42
+ const grantId = rest.slice(sepIdx + OAUTH_CTX_PREFIX.length);
43
+ if (!grantId)
44
+ return null;
45
+ return { userId: rest.slice(0, sepIdx), grantId };
46
+ }
14
47
  function buildHeaders(apiKey) {
15
48
  const headers = {
16
49
  "X-Source": "mcp-server",
17
50
  "X-Cenogram-Client-Id": getClientId(),
18
51
  };
52
+ const ctx = requestContext.getStore();
53
+ if (ctx?.clientUserAgent)
54
+ headers["X-MCP-User-Agent"] = ctx.clientUserAgent;
19
55
  const key = apiKey ?? process.env.CENOGRAM_API_KEY;
20
- if (key)
56
+ if (key?.startsWith(OAUTH_CTX_PREFIX)) {
57
+ const rest = key.slice(OAUTH_CTX_PREFIX.length);
58
+ const sepIdx = rest.indexOf(OAUTH_CTX_PREFIX);
59
+ if (sepIdx <= 0) {
60
+ throw new Error("BUG: malformed OAuth context key");
61
+ }
62
+ const internalSecret = process.env.INTERNAL_AUTH_SECRET;
63
+ if (internalSecret) {
64
+ headers["X-Internal-Auth"] = internalSecret;
65
+ }
66
+ headers["X-OAuth-User"] = rest.slice(0, sepIdx);
67
+ headers["X-OAuth-Grant"] = rest.slice(sepIdx + OAUTH_CTX_PREFIX.length);
68
+ }
69
+ else if (key) {
21
70
  headers["Authorization"] = `Bearer ${key}`;
71
+ }
22
72
  return headers;
23
73
  }
24
- async function handleErrorResponse(res) {
25
- if (res.status === 402) {
26
- const body = await res.json().catch(() => ({}));
27
- throw new Error(`Niewystarczające tokeny API. Saldo: ${body.currentBalance ?? 0}, wymagane: ${body.creditsRequired ?? "?"}. Doładuj: https://cenogram.pl/api#cennik`);
28
- }
74
+ export function parseRetryAfterSeconds(headerValue) {
75
+ if (!headerValue)
76
+ return null;
77
+ const raw = headerValue.trim();
78
+ if (/^\d+$/.test(raw))
79
+ return Math.max(1, parseInt(raw, 10));
80
+ if (!/[A-Za-z]/.test(raw))
81
+ return null;
82
+ const asDate = Date.parse(raw);
83
+ if (Number.isNaN(asDate))
84
+ return null;
85
+ return Math.max(1, Math.ceil((asDate - Date.now()) / 1000));
86
+ }
87
+ export function formatRetryAfter(seconds) {
88
+ const unit = (value, name) => `${value} ${name}${value === 1 ? "" : "s"}`;
89
+ if (seconds < 60)
90
+ return unit(seconds, "second");
91
+ if (seconds < 3600)
92
+ return unit(Math.ceil(seconds / 60), "minute");
93
+ if (seconds < 86400)
94
+ return unit(Math.ceil(seconds / 3600), "hour");
95
+ return unit(Math.ceil(seconds / 86400), "day");
96
+ }
97
+ async function handleErrorResponse(res, apiKey) {
29
98
  if (res.status === 429) {
30
- const retryAfter = res.headers?.get?.("Retry-After");
31
- const days = retryAfter ? Math.ceil(parseInt(retryAfter, 10) / 86400) : null;
32
- const resetInfo = days !== null ? ` Reset za ${days} ${days === 1 ? "dzień" : "dni"}.` : "";
33
- throw new Error(`Zbyt wiele zapytań.${resetInfo}`);
99
+ const seconds = parseRetryAfterSeconds(res.headers?.get?.("Retry-After"));
100
+ const wait = seconds !== null ? ` Retry in ${formatRetryAfter(seconds)}.` : " Retry shortly.";
101
+ throw new Error(`Too many requests - this is a rate limit, not an exhausted allowance.${wait}`);
34
102
  }
35
- throw new Error(`API error: HTTP ${res.status}`);
103
+ const body = (await res.json().catch(() => ({})));
104
+ const mode = getAuthMode(apiKey ?? process.env.CENOGRAM_API_KEY);
105
+ throw new Error(authErrorMessage(res.status, mode, body));
36
106
  }
37
107
  function toQueryParams(obj) {
38
108
  const params = {};
@@ -42,7 +112,6 @@ function toQueryParams(obj) {
42
112
  }
43
113
  return params;
44
114
  }
45
- // ── HTTP client ─────────────────────────────────────────────────────
46
115
  export async function fetchApi(path, params, apiKey) {
47
116
  const url = new URL(path, BASE_URL);
48
117
  if (params) {
@@ -56,7 +125,7 @@ export async function fetchApi(path, params, apiKey) {
56
125
  try {
57
126
  const res = await fetch(url.toString(), { signal: controller.signal, headers: buildHeaders(apiKey) });
58
127
  if (!res.ok)
59
- await handleErrorResponse(res);
128
+ await handleErrorResponse(res, apiKey);
60
129
  return { data: (await res.json()), creditInfo: extractCreditInfo(res) };
61
130
  }
62
131
  finally {
@@ -77,28 +146,35 @@ export async function fetchApiPost(path, body, apiKey) {
77
146
  body: JSON.stringify(body),
78
147
  });
79
148
  if (!res.ok)
80
- await handleErrorResponse(res);
149
+ await handleErrorResponse(res, apiKey);
81
150
  return { data: (await res.json()), creditInfo: extractCreditInfo(res) };
82
151
  }
83
152
  finally {
84
153
  clearTimeout(timeout);
85
154
  }
86
155
  }
87
- // ── Typed wrappers ──────────────────────────────────────────────────
88
156
  export function getStats(apiKey) {
89
- return fetchApi("/api/stats", undefined, apiKey);
157
+ return fetchApi("/api/v1/stats", undefined, apiKey);
90
158
  }
91
159
  export function getTransactions(p, apiKey) {
92
- return fetchApi("/api/transactions", toQueryParams({
160
+ return fetchApi("/api/v1/transactions", toQueryParams({
93
161
  district: p.district,
162
+ teryt: p.teryt,
94
163
  street: p.street,
95
164
  buildingNumber: p.buildingNumber,
96
165
  parcelId: p.parcelId,
97
166
  propertyType: p.propertyType,
98
167
  marketType: p.marketType,
99
168
  unitFunction: p.unitFunction,
169
+ ownershipType: p.ownershipType,
100
170
  buildingType: p.buildingType,
101
171
  mpzpDesignation: p.mpzpDesignation,
172
+ transactionType: p.transactionType,
173
+ rooms: p.rooms,
174
+ floor: p.floor,
175
+ floodRisk: p.floodRisk,
176
+ heritageStatus: p.heritageStatus,
177
+ landslideRisk: p.landslideRisk,
102
178
  minPrice: p.minPrice,
103
179
  maxPrice: p.maxPrice,
104
180
  dateFrom: p.dateFrom,
@@ -113,14 +189,24 @@ export function getTransactions(p, apiKey) {
113
189
  }), apiKey);
114
190
  }
115
191
  export function getTransactionsSummary(p, apiKey) {
116
- return fetchApi("/api/transactions/summary", toQueryParams({
192
+ return fetchApi("/api/v1/transactions/summary", toQueryParams({
117
193
  district: p.district,
194
+ teryt: p.teryt,
118
195
  street: p.street,
196
+ buildingNumber: p.buildingNumber,
197
+ parcelId: p.parcelId,
119
198
  propertyType: p.propertyType,
120
199
  marketType: p.marketType,
121
200
  unitFunction: p.unitFunction,
201
+ ownershipType: p.ownershipType,
122
202
  buildingType: p.buildingType,
123
203
  mpzpDesignation: p.mpzpDesignation,
204
+ transactionType: p.transactionType,
205
+ rooms: p.rooms,
206
+ floor: p.floor,
207
+ floodRisk: p.floodRisk,
208
+ heritageStatus: p.heritageStatus,
209
+ landslideRisk: p.landslideRisk,
124
210
  minPrice: p.minPrice,
125
211
  maxPrice: p.maxPrice,
126
212
  dateFrom: p.dateFrom,
@@ -131,16 +217,67 @@ export function getTransactionsSummary(p, apiKey) {
131
217
  }), apiKey);
132
218
  }
133
219
  export function getPricePerM2(apiKey) {
134
- return fetchApi("/api/price-per-m2", undefined, apiKey);
220
+ return fetchApi("/api/v1/price-per-m2", undefined, apiKey);
135
221
  }
136
222
  export function getDistricts(apiKey) {
137
- return fetchApi("/api/districts", undefined, apiKey);
223
+ return fetchApi("/api/v1/districts", undefined, apiKey);
224
+ }
225
+ export function getRentalYield(params, apiKey) {
226
+ return fetchApi("/api/v1/rental-yield", toQueryParams({
227
+ location: params.location,
228
+ teryt: params.teryt,
229
+ areaBucket: params.areaBucket,
230
+ }), apiKey);
231
+ }
232
+ export function getRentalYieldLocations(params, apiKey) {
233
+ return fetchApi("/api/v1/rental-yield/locations", toQueryParams({
234
+ search: params.search,
235
+ }), apiKey);
236
+ }
237
+ export function getPriceSpread(params, apiKey) {
238
+ return fetchApi("/api/v1/price-spread", toQueryParams({
239
+ location: params.location,
240
+ teryt: params.teryt,
241
+ marketType: params.marketType,
242
+ areaBucket: params.areaBucket,
243
+ }), apiKey);
244
+ }
245
+ export function getPriceSpreadLocations(params, apiKey) {
246
+ return fetchApi("/api/v1/price-spread/locations", toQueryParams({
247
+ search: params.search,
248
+ }), apiKey);
249
+ }
250
+ export function getValuation(params, apiKey) {
251
+ return fetchApi("/api/v1/valuations", toQueryParams({
252
+ lat: params.lat,
253
+ lng: params.lng,
254
+ parcelId: params.parcelId,
255
+ area: params.area,
256
+ rooms: params.rooms,
257
+ market: params.market,
258
+ includeComps: params.includeComps ? "true" : undefined,
259
+ }), apiKey);
260
+ }
261
+ export function getLocations(parent, apiKey) {
262
+ return fetchApi("/api/v1/locations", parent ? { parent } : undefined, apiKey);
138
263
  }
139
264
  export function getPriceHistogram(bins = 20, max = 3_000_000, apiKey) {
140
- return fetchApi("/api/stats/price-histogram", toQueryParams({ bins, max }), apiKey);
265
+ return fetchApi("/api/v1/stats/price-histogram", toQueryParams({ bins, max }), apiKey);
141
266
  }
142
267
  export function searchParcels(q, limit, apiKey) {
143
- return fetchApi("/api/parcels/search", toQueryParams({ q, limit }), apiKey);
268
+ return fetchApi("/api/v1/parcels/search", toQueryParams({ q, limit }), apiKey);
269
+ }
270
+ export function resolveParcel(params, apiKey) {
271
+ return fetchApi("/api/v1/parcels/resolve", toQueryParams({
272
+ q: params.q,
273
+ parcelId: params.parcelId,
274
+ lat: params.lat,
275
+ lng: params.lng,
276
+ }), apiKey);
277
+ }
278
+ export function getParcelReport(parcelKey, apiKey) {
279
+ const urlKey = parcelKey.trim().replace(/\//g, "-");
280
+ return fetchApi(`/api/v1/parcels/${encodeURIComponent(urlKey)}/report`, undefined, apiKey);
144
281
  }
145
282
  export function searchByPolygon(p, apiKey) {
146
283
  const body = { polygon: p.polygon };
@@ -152,6 +289,8 @@ export function searchByPolygon(p, apiKey) {
152
289
  body.unitFunction = p.unitFunction;
153
290
  if (p.buildingType != null)
154
291
  body.buildingType = p.buildingType;
292
+ if (p.ownershipType != null)
293
+ body.ownershipType = p.ownershipType;
155
294
  if (p.mpzpDesignation)
156
295
  body.mpzpDesignation = p.mpzpDesignation;
157
296
  if (p.minPrice != null)
@@ -170,18 +309,29 @@ export function searchByPolygon(p, apiKey) {
170
309
  body.district = p.district;
171
310
  if (p.street)
172
311
  body.street = p.street;
312
+ if (p.transactionType)
313
+ body.transactionType = p.transactionType;
314
+ if (p.rooms)
315
+ body.rooms = p.rooms;
316
+ if (p.floor)
317
+ body.floor = p.floor;
173
318
  if (p.limit != null)
174
319
  body.limit = p.limit;
175
- return fetchApiPost("/api/transactions/spatial", body, apiKey);
320
+ return fetchApiPost("/api/v1/transactions/spatial", body, apiKey);
176
321
  }
177
322
  export function compareLocations(p, apiKey) {
178
- return fetchApi("/api/transactions/summary/compare", toQueryParams({
323
+ return fetchApi("/api/v1/transactions/summary/compare", toQueryParams({
179
324
  districts: p.districts,
325
+ include: p.include,
180
326
  propertyType: p.propertyType,
181
327
  marketType: p.marketType,
182
328
  unitFunction: p.unitFunction,
329
+ ownershipType: p.ownershipType,
183
330
  buildingType: p.buildingType,
184
331
  mpzpDesignation: p.mpzpDesignation,
332
+ transactionType: p.transactionType,
333
+ rooms: p.rooms,
334
+ floor: p.floor,
185
335
  minPrice: p.minPrice,
186
336
  maxPrice: p.maxPrice,
187
337
  dateFrom: p.dateFrom,
@@ -191,3 +341,30 @@ export function compareLocations(p, apiKey) {
191
341
  street: p.street,
192
342
  }), apiKey);
193
343
  }
344
+ export function getBuildingBreakdown(transactionId, apiKey) {
345
+ return fetchApi(`/api/v1/transactions/${transactionId}/buildings`, undefined, apiKey);
346
+ }
347
+ export function getTransactionFlood(transactionId, apiKey) {
348
+ return fetchApi(`/api/v1/transactions/${transactionId}/flood`, undefined, apiKey);
349
+ }
350
+ export function getTransactionHeritage(transactionId, apiKey) {
351
+ return fetchApi(`/api/v1/transactions/${transactionId}/heritage`, undefined, apiKey);
352
+ }
353
+ export function getTransactionLandslide(transactionId, apiKey) {
354
+ return fetchApi(`/api/v1/transactions/${transactionId}/landslide`, undefined, apiKey);
355
+ }
356
+ export function getTransactionSurroundings(transactionId, apiKey) {
357
+ return fetchApi(`/api/v1/transactions/${transactionId}/surroundings`, undefined, apiKey);
358
+ }
359
+ export function getTransactionTransit(transactionId, apiKey) {
360
+ return fetchApi(`/api/v1/transactions/${transactionId}/transit`, undefined, apiKey);
361
+ }
362
+ export function getTransactionPermits(transactionId, apiKey) {
363
+ return fetchApi(`/api/v1/transactions/${transactionId}/permits`, undefined, apiKey);
364
+ }
365
+ export function getTransactionPlanning(transactionId, apiKey) {
366
+ return fetchApi(`/api/v1/transactions/${transactionId}/planning`, undefined, apiKey);
367
+ }
368
+ export function getTransactionFarmland(transactionId, apiKey) {
369
+ return fetchApi(`/api/v1/transactions/${transactionId}/farmland`, undefined, apiKey);
370
+ }
@@ -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,141 @@
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
+ const QUOTED_RE = /^[\x20\x21\x23-\x5B\x5D-\x7E]*$/;
5
+ const BEARER_RE = /^Bearer[ \t]+(.+)$/i;
6
+ const JWT_SHAPE_RE = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
7
+ const LOG_UNSAFE_RE = /[\x00-\x1F\x7F-\x9F\u2028\u2029\u202A-\u202E\u2066-\u2069\uFEFF]/g;
8
+ export function sanitizeForLog(s) {
9
+ return s.replace(LOG_UNSAFE_RE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
10
+ }
11
+ const REASON_DESC = {
12
+ expired: "Token expired",
13
+ unknown_key: "Token validation failed",
14
+ invalid: "Token validation failed",
15
+ };
16
+ function quotedParam(name, value) {
17
+ if (!QUOTED_RE.test(value)) {
18
+ throw new Error(`BUG: WWW-Authenticate ${name} contains illegal chars: ${JSON.stringify(value)}`);
19
+ }
20
+ return `${name}="${value}"`;
21
+ }
22
+ function safeKid(h) {
23
+ return typeof h === "string" ? sanitizeForLog(h.slice(0, 64)) : null;
24
+ }
25
+ function build401(opts) {
26
+ const parts = ['Bearer realm="cenogram"'];
27
+ if (opts.errorParam)
28
+ parts.push(`error="${opts.errorParam}"`);
29
+ if (opts.description)
30
+ parts.push(quotedParam("error_description", opts.description));
31
+ parts.push(quotedParam("resource_metadata", RESOURCE_METADATA));
32
+ return {
33
+ headers: { "Content-Type": "application/json", "WWW-Authenticate": parts.join(", ") },
34
+ body: { error: opts.bodyError, error_description: opts.bodyDesc },
35
+ };
36
+ }
37
+ function build403(scope, description) {
38
+ const parts = [
39
+ 'Bearer realm="cenogram"',
40
+ `error="insufficient_scope"`,
41
+ quotedParam("scope", scope),
42
+ quotedParam("error_description", description),
43
+ quotedParam("resource_metadata", RESOURCE_METADATA),
44
+ ];
45
+ return {
46
+ headers: { "Content-Type": "application/json", "WWW-Authenticate": parts.join(", ") },
47
+ body: { error: "insufficient_scope", error_description: description },
48
+ };
49
+ }
50
+ export async function dispatchAuth(authHeader, validateJwt = validateOAuthJwt) {
51
+ const MAX_AUTH_HEADER_BYTES = 8192;
52
+ if (authHeader && Buffer.byteLength(authHeader, "utf-8") > MAX_AUTH_HEADER_BYTES) {
53
+ return {
54
+ kind: "401",
55
+ ...build401({
56
+ errorParam: "invalid_token",
57
+ description: "Authorization header too large",
58
+ bodyError: "invalid_token",
59
+ bodyDesc: "Authorization header exceeds maximum size",
60
+ }),
61
+ log: { evt: "auth.rejected", reason: "oversized_header", kid: null },
62
+ };
63
+ }
64
+ const match = authHeader?.match(BEARER_RE);
65
+ const rawToken = match?.[1]?.trim() ?? "";
66
+ if (!rawToken) {
67
+ return {
68
+ kind: "401",
69
+ ...build401({
70
+ bodyError: "missing_token",
71
+ bodyDesc: "Provide an OAuth access token or API key in Authorization: Bearer header.",
72
+ }),
73
+ log: { evt: "auth.rejected", reason: "missing", kid: null },
74
+ };
75
+ }
76
+ if (rawToken.startsWith("cngrm_")) {
77
+ return {
78
+ kind: "passthrough",
79
+ apiKey: rawToken,
80
+ log: { evt: "auth.passthrough", mode: "api_key", key_prefix: sanitizeForLog(rawToken.slice(0, 10)) },
81
+ };
82
+ }
83
+ if (!JWT_SHAPE_RE.test(rawToken)) {
84
+ return {
85
+ kind: "401",
86
+ ...build401({
87
+ errorParam: "invalid_token",
88
+ description: "Token format not recognized",
89
+ bodyError: "invalid_token",
90
+ bodyDesc: "Expected OAuth JWT or Cenogram API key (cngrm_...)",
91
+ }),
92
+ log: { evt: "auth.rejected", reason: "invalid_format", kid: null },
93
+ };
94
+ }
95
+ let result;
96
+ try {
97
+ result = await validateJwt(rawToken);
98
+ }
99
+ catch (e) {
100
+ if (e instanceof OAuthConfigError) {
101
+ return {
102
+ kind: "500",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: { error: "server_misconfigured", error_description: "OAuth not configured on server" },
105
+ log: { evt: "auth.config_missing" },
106
+ };
107
+ }
108
+ throw e;
109
+ }
110
+ if (!result.ok) {
111
+ let headerKid = null;
112
+ try {
113
+ const headerJson = JSON.parse(Buffer.from(rawToken.split(".")[0], "base64url").toString("utf-8"));
114
+ headerKid = safeKid(headerJson.kid);
115
+ }
116
+ catch {
117
+ }
118
+ return {
119
+ kind: "401",
120
+ ...build401({
121
+ errorParam: "invalid_token",
122
+ description: REASON_DESC[result.reason],
123
+ bodyError: "invalid_token",
124
+ bodyDesc: REASON_DESC[result.reason],
125
+ }),
126
+ log: { evt: "auth.rejected", reason: result.reason, kid: headerKid },
127
+ };
128
+ }
129
+ if (!result.claims.scope.split(" ").includes("mcp")) {
130
+ return {
131
+ kind: "403",
132
+ ...build403("mcp", "Token does not include 'mcp' scope"),
133
+ log: { evt: "auth.rejected", reason: "insufficient_scope" },
134
+ };
135
+ }
136
+ return {
137
+ kind: "passthrough",
138
+ apiKey: encodeOAuthCtx(result.claims.sub, result.claims.grant_id),
139
+ log: { evt: "auth.passthrough", mode: "oauth", grant_id: sanitizeForLog(result.claims.grant_id) },
140
+ };
141
+ }
package/dist/client-id.js CHANGED
@@ -8,12 +8,10 @@ let cachedId = null;
8
8
  export function getClientId() {
9
9
  if (cachedId)
10
10
  return cachedId;
11
- // Allow override via env var
12
11
  if (process.env.CENOGRAM_CLIENT_ID?.trim()) {
13
12
  cachedId = process.env.CENOGRAM_CLIENT_ID.trim();
14
13
  return cachedId;
15
14
  }
16
- // Try reading persisted ID
17
15
  try {
18
16
  const stored = readFileSync(CLIENT_ID_FILE, "utf-8").trim();
19
17
  if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(stored)) {
@@ -22,16 +20,13 @@ export function getClientId() {
22
20
  }
23
21
  }
24
22
  catch {
25
- // File doesn't exist or isn't readable - generate new
26
23
  }
27
- // Generate and persist
28
24
  const id = randomUUID();
29
25
  try {
30
26
  mkdirSync(CONFIG_DIR, { recursive: true });
31
27
  writeFileSync(CLIENT_ID_FILE, id + "\n", { mode: 0o600 });
32
28
  }
33
29
  catch {
34
- // Read-only fs (Docker, sandbox) - use ephemeral ID
35
30
  }
36
31
  cachedId = id;
37
32
  return id;
@@ -0,0 +1,12 @@
1
+ export type AuthMode = "oauth" | "api_key" | "stdio_env" | "none";
2
+ export declare function getAuthMode(apiKey?: string): AuthMode;
3
+ export declare function signupUrl(): string;
4
+ export interface ErrorBody {
5
+ error?: string;
6
+ message?: string;
7
+ currentBalance?: number;
8
+ creditsRequired?: number;
9
+ upgrade?: string;
10
+ successor?: string;
11
+ }
12
+ export declare function authErrorMessage(status: number, mode: AuthMode, body?: ErrorBody): string;
@@ -0,0 +1,80 @@
1
+ import { channelSrc } from "./transport-mode.js";
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 signupUrl() {
13
+ return `https://cenogram.pl/api?src=${channelSrc()}`;
14
+ }
15
+ function specificMessage(body) {
16
+ const rawError = typeof body.error === "string" ? body.error.trim() : "";
17
+ const rawMessage = typeof body.message === "string" ? body.message.trim() : "";
18
+ const candidate = rawMessage || rawError;
19
+ return candidate.length > 0 && candidate.length <= 500 ? candidate : "";
20
+ }
21
+ function sentence(text) {
22
+ return /[.!?]$/.test(text) ? text : `${text}.`;
23
+ }
24
+ function specificField(value) {
25
+ if (typeof value !== "string")
26
+ return "";
27
+ const trimmed = value.trim();
28
+ return trimmed.length > 0 && trimmed.length <= 500 ? trimmed : "";
29
+ }
30
+ export function authErrorMessage(status, mode, body = {}) {
31
+ const specific = specificMessage(body);
32
+ switch (status) {
33
+ case 401:
34
+ if (mode === "oauth") {
35
+ return "Connection to Cenogram expired or was revoked. In Claude open: Settings > Connectors > Cenogram, disconnect and reconnect.";
36
+ }
37
+ if (mode === "none") {
38
+ return (`No Cenogram API key configured. Get a free key at ${signupUrl()}, ` +
39
+ "then set it as the CENOGRAM_API_KEY environment variable of this MCP server.");
40
+ }
41
+ return "API key rejected. Check https://cenogram.pl/ustawienia#api-keys if it's still active.";
42
+ case 402: {
43
+ const explanation = specificField(body.message);
44
+ if (body.error === "trial_expired" && explanation) {
45
+ const upgrade = specificField(body.upgrade);
46
+ return upgrade && !explanation.includes(upgrade) ? `${sentence(explanation)} (${upgrade})` : explanation;
47
+ }
48
+ const balance = body.currentBalance ?? 0;
49
+ const required = body.creditsRequired ?? "?";
50
+ if (mode === "oauth") {
51
+ return `Insufficient credits (balance: ${balance}, query cost: ${required}). Top up: https://cenogram.pl/api?src=${channelSrc()}#cennik`;
52
+ }
53
+ return `Insufficient credits for key's account (balance: ${balance}, query cost: ${required}). Top up: https://cenogram.pl/api?src=${channelSrc()}#cennik`;
54
+ }
55
+ case 403:
56
+ if (body.error === "email_not_verified") {
57
+ return "Account email not verified. Check your inbox, click the activation link, then retry.";
58
+ }
59
+ return specific ? `Access denied: ${specific}` : "Access denied (HTTP 403).";
60
+ case 503: {
61
+ if (!specific)
62
+ return "Cenogram temporarily unavailable. Try again shortly.";
63
+ const saysRetry = /try again|retry|spr[oó]buj ponownie/i.test(specific);
64
+ return `Cenogram temporarily unavailable: ${sentence(specific)}${saysRetry ? "" : " Try again shortly."}`;
65
+ }
66
+ case 410: {
67
+ const successor = specificField(body.successor);
68
+ const reason = sentence(specific || "This endpoint has been retired.");
69
+ const where = successor ? ` It was replaced by ${successor}.` : "";
70
+ return `${reason}${where} This is permanent - retrying will not help. Update @cenogram/mcp-server to the latest version.`;
71
+ }
72
+ case 404:
73
+ return specific || "Not found (HTTP 404). Check the location name or TERYT code.";
74
+ case 400:
75
+ case 422:
76
+ return specific ? `Invalid request: ${specific}` : `Invalid request (HTTP ${status}). Check parameters.`;
77
+ default:
78
+ return `Cenogram API unavailable (HTTP ${status}). Try again shortly.`;
79
+ }
80
+ }