@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.
- package/README.md +28 -24
- package/dist/api-client.d.ts +687 -7
- package/dist/api-client.js +202 -25
- package/dist/auth-dispatch.d.ts +59 -0
- package/dist/auth-dispatch.js +141 -0
- package/dist/client-id.js +0 -5
- package/dist/error-messages.d.ts +12 -0
- package/dist/error-messages.js +80 -0
- package/dist/formatters.d.ts +21 -1
- package/dist/formatters.js +1133 -24
- package/dist/index.d.ts +1 -0
- package/dist/index.js +149 -44
- package/dist/mappings.d.ts +12 -5
- package/dist/mappings.js +135 -17
- package/dist/oauth-jwt.d.ts +19 -0
- package/dist/oauth-jwt.js +63 -0
- package/dist/request-context.d.ts +5 -0
- package/dist/request-context.js +2 -0
- package/dist/sentry-scrub.d.ts +2 -0
- package/dist/sentry-scrub.js +14 -0
- package/dist/sentry.d.ts +2 -0
- package/dist/sentry.js +29 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +544 -81
- package/dist/transport-mode.d.ts +2 -0
- package/dist/transport-mode.js +6 -0
- package/package.json +16 -7
package/dist/api-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
-
if (
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
31
|
-
const
|
|
32
|
-
|
|
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
|
-
|
|
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
|
+
}
|