@bbradar/mcp 0.1.19 → 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.
@@ -2,12 +2,26 @@ import { randomUUID } from "node:crypto";
2
2
  import { TtlCache } from "./cache.js";
3
3
  import { stableStringify } from "./json.js";
4
4
  import { encodeProgramIdPath } from "./url.js";
5
+ const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
6
+ const MAX_UPSTREAM_RATE_LIMIT_DELAY_MS = 10 * 60_000;
7
+ const CHANGE_FEED_CONCURRENCY_LIMIT = 2;
8
+ const TARGET_EXPORT_CONCURRENCY_LIMIT = 2;
9
+ const UPSTREAM_KEY_IP_REQUEST_LIMIT = 60;
10
+ const UPSTREAM_KEY_IP_WINDOW_MS = 60_000;
11
+ const MAX_RESPONSE_BODY_BYTES = 16 * 1024 * 1024;
12
+ const MAX_REQUEST_BODY_BYTES = 64 * 1024;
13
+ const DEFAULT_PROGRAM_SWR_MS = 5 * 60_000;
14
+ const DEFAULT_CIRCUIT_FAILURE_THRESHOLD = 3;
15
+ const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000;
5
16
  export class BBRadarApiError extends Error {
6
17
  requestId;
7
18
  upstreamRequestId;
8
19
  status;
9
20
  detail;
10
21
  errors;
22
+ cached;
23
+ coalesced;
24
+ cacheExpiresAt;
11
25
  constructor(params) {
12
26
  super(params.message, { cause: params.cause });
13
27
  this.name = "BBRadarApiError";
@@ -16,33 +30,98 @@ export class BBRadarApiError extends Error {
16
30
  this.status = params.status;
17
31
  this.detail = params.detail;
18
32
  this.errors = params.errors;
33
+ this.cached = params.cached ?? false;
34
+ this.coalesced = params.coalesced ?? false;
35
+ this.cacheExpiresAt = params.cacheExpiresAt;
19
36
  }
20
37
  }
21
38
  export class BBRadarClient {
22
39
  config;
23
40
  cache;
41
+ negativeCache;
24
42
  inFlight = new Map();
43
+ endpointMetrics = new Map();
44
+ requestQueue = [];
45
+ endpointRequestQueues = new Map();
46
+ endpointActiveRequests = new Map();
47
+ upstreamRateLimits = new Map();
48
+ circuitBreakers = new Map();
49
+ upstreamRequestTimestampsMs = [];
50
+ activeRequests = 0;
51
+ globalBlockedUntilMs;
52
+ capabilityNegotiation;
53
+ capabilityState = { status: "not_started" };
25
54
  constructor(config) {
26
55
  this.config = config;
27
- this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs);
56
+ this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs, config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS);
57
+ this.negativeCache = new TtlCache(config.cacheMaxEntries, config.negativeCacheTtlMs);
28
58
  }
29
59
  diagnostics() {
60
+ const endpointEntries = [...this.endpointMetrics.entries()];
61
+ const totalRequests = endpointEntries.reduce((total, [, metric]) => total + metric.requests, 0);
62
+ const totalErrors = endpointEntries.reduce((total, [, metric]) => total + metric.errors, 0);
63
+ const totalMs = endpointEntries.reduce((total, [, metric]) => total + metric.totalMs, 0);
30
64
  return {
31
65
  response_cache: {
32
66
  enabled: this.config.cacheTtlMs > 0 && this.config.cacheMaxEntries > 0,
33
67
  ...this.cache.stats()
34
68
  },
35
- in_flight_requests: this.inFlight.size
69
+ negative_response_cache: {
70
+ enabled: this.config.negativeCacheTtlMs > 0 && this.config.cacheMaxEntries > 0,
71
+ ...this.negativeCache.stats()
72
+ },
73
+ capability_negotiation: this.capabilitySnapshot(),
74
+ circuit_breakers: this.circuitBreakerDiagnostics(),
75
+ in_flight_requests: this.inFlight.size,
76
+ request_concurrency: {
77
+ limit: this.config.maxConcurrentRequests,
78
+ active: this.activeRequests,
79
+ queued: this.requestQueue.length,
80
+ endpoint_limits: {
81
+ change_feeds: {
82
+ limit: CHANGE_FEED_CONCURRENCY_LIMIT,
83
+ active: this.endpointActiveRequests.get("change_feeds") ?? 0,
84
+ queued: this.endpointRequestQueues.get("change_feeds")?.length ?? 0
85
+ },
86
+ target_export: {
87
+ limit: TARGET_EXPORT_CONCURRENCY_LIMIT,
88
+ active: this.endpointActiveRequests.get("target_export") ?? 0,
89
+ queued: this.endpointRequestQueues.get("target_export")?.length ?? 0
90
+ }
91
+ }
92
+ },
93
+ upstream_request_window: this.upstreamRequestWindowDiagnostics(),
94
+ upstream_rate_limit: this.upstreamRateLimitDiagnostics(),
95
+ upstream_requests: {
96
+ actual_requests: totalRequests,
97
+ errors: totalErrors,
98
+ avg_ms: totalRequests > 0 ? Math.round(totalMs / totalRequests) : 0,
99
+ max_ms: endpointEntries.reduce((maximum, [, metric]) => Math.max(maximum, metric.maxMs), 0),
100
+ by_endpoint: Object.fromEntries(endpointEntries.map(([endpoint, metric]) => [
101
+ endpoint,
102
+ {
103
+ requests: metric.requests,
104
+ errors: metric.errors,
105
+ avg_ms: metric.requests > 0 ? Math.round(metric.totalMs / metric.requests) : 0,
106
+ max_ms: metric.maxMs,
107
+ last_ms: metric.lastMs,
108
+ statuses: Object.fromEntries(metric.statuses)
109
+ }
110
+ ]))
111
+ }
36
112
  };
37
113
  }
38
114
  async validateKey() {
39
115
  try {
40
- await this.request({
41
- path: "/pro/programs",
42
- query: { page: 1, page_size: 1 },
43
- cache: false,
44
- coalesce: false
45
- });
116
+ await this.negotiateCapabilities();
117
+ if (this.capabilityState.status === "legacy") {
118
+ await this.request({
119
+ path: "/pro/programs",
120
+ query: { page: 1, page_size: 1 },
121
+ cache: false,
122
+ coalesce: false
123
+ });
124
+ }
46
125
  }
47
126
  catch (error) {
48
127
  if (!(error instanceof BBRadarApiError)) {
@@ -63,26 +142,105 @@ export class BBRadarClient {
63
142
  throw error;
64
143
  }
65
144
  }
145
+ negotiateCapabilities() {
146
+ if (!this.capabilityNegotiation) {
147
+ this.capabilityNegotiation = this.fetchCapabilityContract();
148
+ }
149
+ return this.capabilityNegotiation;
150
+ }
151
+ capabilitySnapshot() {
152
+ return { ...this.capabilityState };
153
+ }
154
+ supportsEndpoint(endpoint) {
155
+ if (this.capabilityState.status !== "negotiated") {
156
+ return true;
157
+ }
158
+ const contract = asRecord(this.capabilityState.contract);
159
+ const endpoints = asRecord(contract?.endpoints);
160
+ return endpoints !== undefined && asRecord(endpoints[endpoint]) !== undefined;
161
+ }
162
+ endpointCapability(endpoint) {
163
+ if (this.capabilityState.status !== "negotiated") {
164
+ return undefined;
165
+ }
166
+ const contract = asRecord(this.capabilityState.contract);
167
+ return asRecord(asRecord(contract?.endpoints)?.[endpoint]);
168
+ }
169
+ endpointNumber(endpoint, field, fallback) {
170
+ const value = this.endpointCapability(endpoint)?.[field];
171
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
172
+ }
173
+ endpointStringArray(endpoint, field, fallback) {
174
+ const value = this.endpointCapability(endpoint)?.[field];
175
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string")
176
+ ? [...value]
177
+ : [...fallback];
178
+ }
179
+ async fetchCapabilityContract() {
180
+ try {
181
+ const result = await this.getCapabilities({ cache: false, coalesce: false });
182
+ this.capabilityState = {
183
+ status: "negotiated",
184
+ negotiatedAt: result.fetchedAt,
185
+ contract: result.data
186
+ };
187
+ }
188
+ catch (error) {
189
+ if (error instanceof BBRadarApiError && (error.status === 404 || error.status === 405)) {
190
+ this.capabilityState = {
191
+ status: "legacy",
192
+ negotiatedAt: new Date().toISOString()
193
+ };
194
+ return;
195
+ }
196
+ this.capabilityState = {
197
+ status: "failed",
198
+ negotiatedAt: new Date().toISOString(),
199
+ error: error instanceof Error ? error.message : String(error)
200
+ };
201
+ throw error;
202
+ }
203
+ }
66
204
  listPrograms(query) {
67
205
  return this.request({
68
206
  path: "/pro/programs",
69
207
  query
70
208
  });
71
209
  }
210
+ getCapabilities(options = {}) {
211
+ return this.request({
212
+ path: "/pro/capabilities",
213
+ ...options
214
+ });
215
+ }
72
216
  getProgram(programId) {
73
217
  return this.request({
74
- path: `/pro/programs/${encodeProgramIdPath(programId)}`
218
+ path: `/pro/programs/${encodeProgramIdPath(programId)}`,
219
+ staleWhileRevalidateMs: this.config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS
75
220
  });
76
221
  }
77
- getProgramTargets(programId) {
222
+ getProgramTargets(programId, query = {}) {
78
223
  return this.request({
79
- path: `/pro/programs/${encodeProgramIdPath(programId)}/targets`
224
+ path: `/pro/programs/${encodeProgramIdPath(programId)}/targets`,
225
+ query: {
226
+ page: 1,
227
+ page_size: this.endpointNumber("program_targets", "max_page_size", 500),
228
+ ...query
229
+ }
80
230
  });
81
231
  }
82
232
  getRecentChanges(query) {
83
233
  return this.request({
84
234
  path: "/pro/targets/changes",
85
- query
235
+ query,
236
+ cache: query.after_id === undefined
237
+ });
238
+ }
239
+ getRepositoryChanges(query) {
240
+ return this.request({
241
+ path: "/pro/repositories/changes",
242
+ query,
243
+ cache: query.after_id === undefined
86
244
  });
87
245
  }
88
246
  searchTargets(query) {
@@ -116,18 +274,37 @@ export class BBRadarClient {
116
274
  body: options.body ?? null
117
275
  });
118
276
  const cacheable = method === "GET" && (options.cache ?? true) && this.config.cacheTtlMs > 0 && this.config.cacheMaxEntries > 0;
277
+ const negativeCacheable = method === "GET" &&
278
+ (options.cache ?? true) &&
279
+ this.config.negativeCacheTtlMs > 0 &&
280
+ this.config.cacheMaxEntries > 0;
119
281
  const coalesceable = method === "GET" && (options.coalesce ?? true);
120
282
  if (cacheable) {
121
- const cached = this.cache.get(requestKey);
283
+ const cached = this.cache.getWithStale(requestKey, options.staleWhileRevalidateMs ?? 0);
122
284
  if (cached) {
285
+ if (cached.stale) {
286
+ this.refreshInBackground(options, method, url, requestKey);
287
+ }
123
288
  return {
124
289
  ...cached.value,
125
290
  requestId,
126
291
  cached: true,
292
+ stale: cached.stale,
127
293
  cacheExpiresAt: new Date(cached.expiresAt).toISOString()
128
294
  };
129
295
  }
130
296
  }
297
+ if (negativeCacheable) {
298
+ const cachedError = this.negativeCache.get(requestKey);
299
+ if (cachedError) {
300
+ throw new BBRadarApiError({
301
+ ...cachedError.value,
302
+ requestId,
303
+ cached: true,
304
+ cacheExpiresAt: new Date(cachedError.expiresAt).toISOString()
305
+ });
306
+ }
307
+ }
131
308
  if (coalesceable) {
132
309
  const pending = this.inFlight.get(requestKey);
133
310
  if (pending) {
@@ -141,7 +318,7 @@ export class BBRadarClient {
141
318
  };
142
319
  }
143
320
  catch (error) {
144
- throw cloneApiError(error, requestId);
321
+ throw cloneApiError(error, requestId, true);
145
322
  }
146
323
  }
147
324
  }
@@ -154,12 +331,30 @@ export class BBRadarClient {
154
331
  if (cacheable) {
155
332
  this.cache.set(requestKey, result);
156
333
  }
334
+ if (negativeCacheable) {
335
+ this.negativeCache.delete(requestKey);
336
+ }
157
337
  return {
158
338
  ...result,
159
339
  requestId,
160
340
  cached: false
161
341
  };
162
342
  }
343
+ catch (error) {
344
+ if (negativeCacheable &&
345
+ error instanceof BBRadarApiError &&
346
+ error.status !== undefined &&
347
+ isNegativeCacheStatus(error.status)) {
348
+ this.negativeCache.set(requestKey, {
349
+ message: error.message,
350
+ upstreamRequestId: error.upstreamRequestId,
351
+ status: error.status,
352
+ detail: error.detail,
353
+ errors: error.errors
354
+ });
355
+ }
356
+ throw error;
357
+ }
163
358
  finally {
164
359
  if (coalesceable) {
165
360
  this.inFlight.delete(requestKey);
@@ -167,58 +362,430 @@ export class BBRadarClient {
167
362
  }
168
363
  }
169
364
  async fetchFromApi(options, method, url, requestId) {
170
- const controller = new AbortController();
171
- const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
172
- try {
173
- const init = {
174
- method,
175
- headers: this.headers(options.body),
176
- cache: "no-store",
177
- signal: controller.signal
178
- };
179
- if (options.body !== undefined) {
180
- init.body = JSON.stringify(options.body);
181
- }
182
- const response = await fetch(url, init);
183
- const upstreamRequestId = extractRequestId(response.headers);
184
- const data = await parseResponseBody(response);
185
- if (!response.ok) {
186
- const params = {
187
- message: formatApiErrorMessage(response.status, data),
365
+ const serializedBody = options.body === undefined ? undefined : JSON.stringify(options.body);
366
+ if (serializedBody !== undefined) {
367
+ const bodyBytes = Buffer.byteLength(serializedBody, "utf8");
368
+ if (bodyBytes > MAX_REQUEST_BODY_BYTES) {
369
+ throw new BBRadarApiError({
370
+ message: `BBRadar API request body exceeds the ${MAX_REQUEST_BODY_BYTES}-byte limit.`,
188
371
  requestId,
372
+ status: 413,
373
+ detail: { body_bytes: bodyBytes, max_body_bytes: MAX_REQUEST_BODY_BYTES }
374
+ });
375
+ }
376
+ }
377
+ return this.withRequestSlot(options.path, async () => {
378
+ const rateLimitBucket = rateLimitEndpointGroup(options.path);
379
+ this.assertCircuitClosed(requestId, rateLimitBucket);
380
+ this.reserveUpstreamRequest(requestId, rateLimitBucket);
381
+ const startedAt = Date.now();
382
+ const controller = new AbortController();
383
+ const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
384
+ let status;
385
+ let failed = true;
386
+ try {
387
+ const init = {
388
+ method,
389
+ headers: this.headers(options.body),
390
+ signal: controller.signal
391
+ };
392
+ if (serializedBody !== undefined) {
393
+ init.body = serializedBody;
394
+ }
395
+ const response = await fetch(url, init);
396
+ status = response.status;
397
+ this.observeCircuitResponse(rateLimitBucket, response.status);
398
+ this.observeUpstreamRateLimit(rateLimitBucket, response.headers, response.status);
399
+ const upstreamRequestId = extractRequestId(response.headers);
400
+ const data = await parseResponseBody(response);
401
+ if (!response.ok) {
402
+ this.observeUpstreamErrorBody(rateLimitBucket, response.status, data);
403
+ const params = {
404
+ message: formatApiErrorMessage(response.status, data),
405
+ requestId,
406
+ status: response.status,
407
+ detail: extractObjectField(data, "detail"),
408
+ errors: extractObjectField(data, "errors")
409
+ };
410
+ if (upstreamRequestId) {
411
+ params.upstreamRequestId = upstreamRequestId;
412
+ }
413
+ throw new BBRadarApiError(params);
414
+ }
415
+ const result = {
416
+ data: data,
189
417
  status: response.status,
190
- detail: extractObjectField(data, "detail"),
191
- errors: extractObjectField(data, "errors")
418
+ fetchedAt: new Date().toISOString()
192
419
  };
193
420
  if (upstreamRequestId) {
194
- params.upstreamRequestId = upstreamRequestId;
421
+ result.upstreamRequestId = upstreamRequestId;
195
422
  }
196
- throw new BBRadarApiError(params);
423
+ failed = false;
424
+ return result;
197
425
  }
198
- const result = {
199
- data: data,
200
- status: response.status,
201
- fetchedAt: new Date().toISOString()
202
- };
203
- if (upstreamRequestId) {
204
- result.upstreamRequestId = upstreamRequestId;
426
+ catch (error) {
427
+ if (error instanceof BBRadarApiError) {
428
+ throw error;
429
+ }
430
+ this.observeCircuitFailure(rateLimitBucket);
431
+ throw new BBRadarApiError({
432
+ message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
433
+ requestId,
434
+ cause: error
435
+ });
436
+ }
437
+ finally {
438
+ clearTimeout(timeout);
439
+ this.recordEndpointMetric(options.path, Date.now() - startedAt, status, failed);
205
440
  }
206
- return result;
441
+ });
442
+ }
443
+ refreshInBackground(options, method, url, requestKey) {
444
+ if (this.inFlight.has(requestKey)) {
445
+ return;
207
446
  }
208
- catch (error) {
209
- if (error instanceof BBRadarApiError) {
210
- throw error;
447
+ const refresh = this.fetchFromApi({ ...options, staleWhileRevalidateMs: 0 }, method, url, randomUUID());
448
+ this.inFlight.set(requestKey, refresh);
449
+ void refresh
450
+ .then((result) => {
451
+ this.cache.set(requestKey, result);
452
+ this.negativeCache.delete(requestKey);
453
+ })
454
+ .catch(() => undefined)
455
+ .finally(() => {
456
+ if (this.inFlight.get(requestKey) === refresh) {
457
+ this.inFlight.delete(requestKey);
211
458
  }
212
- throw new BBRadarApiError({
213
- message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
214
- requestId,
215
- cause: error
459
+ });
460
+ }
461
+ async withRequestSlot(path, callback) {
462
+ const endpointGroup = concurrencyEndpointGroup(path);
463
+ if (endpointGroup === undefined) {
464
+ return this.withGlobalRequestSlot(callback);
465
+ }
466
+ const endpointLimit = endpointConcurrencyLimit(endpointGroup);
467
+ if (endpointLimit === undefined) {
468
+ return this.withGlobalRequestSlot(callback);
469
+ }
470
+ return this.withEndpointRequestSlot(endpointGroup, endpointLimit, () => this.withGlobalRequestSlot(callback));
471
+ }
472
+ async withGlobalRequestSlot(callback) {
473
+ const limit = Math.max(1, this.config.maxConcurrentRequests);
474
+ if (this.activeRequests >= limit) {
475
+ await new Promise((resolve) => this.requestQueue.push(resolve));
476
+ }
477
+ else {
478
+ this.activeRequests += 1;
479
+ }
480
+ try {
481
+ return await callback();
482
+ }
483
+ finally {
484
+ const next = this.requestQueue.shift();
485
+ if (next) {
486
+ next();
487
+ }
488
+ else {
489
+ this.activeRequests -= 1;
490
+ }
491
+ }
492
+ }
493
+ async withEndpointRequestSlot(endpointGroup, limit, callback) {
494
+ const active = this.endpointActiveRequests.get(endpointGroup) ?? 0;
495
+ if (active >= limit) {
496
+ const queue = this.endpointRequestQueues.get(endpointGroup) ?? [];
497
+ await new Promise((resolve) => {
498
+ queue.push(resolve);
499
+ this.endpointRequestQueues.set(endpointGroup, queue);
216
500
  });
217
501
  }
502
+ else {
503
+ this.endpointActiveRequests.set(endpointGroup, active + 1);
504
+ }
505
+ try {
506
+ return await callback();
507
+ }
218
508
  finally {
219
- clearTimeout(timeout);
509
+ const queue = this.endpointRequestQueues.get(endpointGroup);
510
+ const next = queue?.shift();
511
+ if (next) {
512
+ next();
513
+ }
514
+ else {
515
+ this.endpointActiveRequests.set(endpointGroup, Math.max(0, (this.endpointActiveRequests.get(endpointGroup) ?? 1) - 1));
516
+ }
517
+ if (queue?.length === 0) {
518
+ this.endpointRequestQueues.delete(endpointGroup);
519
+ }
520
+ }
521
+ }
522
+ recordEndpointMetric(path, durationMs, status, failed) {
523
+ const endpoint = metricEndpoint(path);
524
+ const metric = this.endpointMetrics.get(endpoint) ??
525
+ {
526
+ requests: 0,
527
+ errors: 0,
528
+ totalMs: 0,
529
+ maxMs: 0,
530
+ lastMs: 0,
531
+ statuses: new Map()
532
+ };
533
+ const statusKey = status === undefined ? "network_error" : String(status);
534
+ metric.requests += 1;
535
+ metric.errors += failed ? 1 : 0;
536
+ metric.totalMs += durationMs;
537
+ metric.maxMs = Math.max(metric.maxMs, durationMs);
538
+ metric.lastMs = durationMs;
539
+ metric.statuses.set(statusKey, (metric.statuses.get(statusKey) ?? 0) + 1);
540
+ this.endpointMetrics.set(endpoint, metric);
541
+ }
542
+ reserveUpstreamRequest(requestId, bucket) {
543
+ const now = Date.now();
544
+ const state = this.rateLimitState(bucket);
545
+ this.refreshUpstreamRateLimitWindow(state, now);
546
+ this.refreshUpstreamRequestWindow(now);
547
+ if ((this.globalBlockedUntilMs ?? 0) > now || (state.blockedUntilMs ?? 0) > now) {
548
+ throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
549
+ }
550
+ if (this.shouldEnforceSharedRequestWindow() &&
551
+ this.upstreamRequestTimestampsMs.length >= UPSTREAM_KEY_IP_REQUEST_LIMIT) {
552
+ this.globalBlockedUntilMs =
553
+ (this.upstreamRequestTimestampsMs[0] ?? now) + UPSTREAM_KEY_IP_WINDOW_MS;
554
+ throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
555
+ }
556
+ if (state.remaining !== undefined) {
557
+ if (state.remaining <= 0) {
558
+ state.blockedUntilMs =
559
+ state.resetAtMs && state.resetAtMs > now
560
+ ? state.resetAtMs
561
+ : now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
562
+ throw localRateLimitError(requestId, bucket, state, this.globalBlockedUntilMs, this.upstreamRequestTimestampsMs.length);
563
+ }
564
+ state.remaining -= 1;
565
+ }
566
+ this.upstreamRequestTimestampsMs.push(now);
567
+ }
568
+ observeUpstreamRateLimit(bucket, headers, status) {
569
+ const now = Date.now();
570
+ const state = this.rateLimitState(bucket);
571
+ const limit = readRateLimitNumber(headers, ["x-ratelimit-limit", "ratelimit-limit"]);
572
+ const remaining = readRateLimitNumber(headers, ["x-ratelimit-remaining", "ratelimit-remaining"]);
573
+ const resetAtMs = parseRateLimitReset(headers, now);
574
+ const retryAtMs = parseRetryAfter(headers, now);
575
+ const previousResetAtMs = state.resetAtMs;
576
+ const sameWindow = resetAtMs === undefined ||
577
+ previousResetAtMs === undefined ||
578
+ Math.abs(resetAtMs - previousResetAtMs) < 1_000;
579
+ if (limit !== undefined) {
580
+ state.limit = limit;
581
+ }
582
+ if (remaining !== undefined) {
583
+ state.remaining =
584
+ sameWindow && state.remaining !== undefined
585
+ ? Math.min(state.remaining, remaining)
586
+ : remaining;
587
+ }
588
+ if (resetAtMs !== undefined) {
589
+ state.resetAtMs = resetAtMs;
590
+ }
591
+ state.observedAtMs = now;
592
+ state.lastStatus = status;
593
+ if (status === 429 || ((status === 423 || status === 503) && retryAtMs !== undefined)) {
594
+ const advertisedBlockUntil = Math.max(retryAtMs ?? 0, resetAtMs && resetAtMs > now ? resetAtMs : 0);
595
+ const blockedUntil = advertisedBlockUntil > now ? advertisedBlockUntil : now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
596
+ if (status === 429) {
597
+ state.remaining = 0;
598
+ }
599
+ state.blockedUntilMs = blockedUntil;
600
+ this.globalBlockedUntilMs = Math.max(this.globalBlockedUntilMs ?? 0, blockedUntil);
601
+ return;
602
+ }
603
+ if (remaining === 0) {
604
+ state.blockedUntilMs =
605
+ resetAtMs && resetAtMs > now ? resetAtMs : now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
606
+ }
607
+ else if ((state.blockedUntilMs ?? 0) <= now) {
608
+ state.blockedUntilMs = undefined;
609
+ }
610
+ }
611
+ observeUpstreamErrorBody(bucket, status, data) {
612
+ if (status !== 423) {
613
+ return;
614
+ }
615
+ const payload = asRecord(data);
616
+ const detail = asRecord(payload?.detail);
617
+ const rawLockedUntil = payload?.locked_until ?? detail?.locked_until;
618
+ if (typeof rawLockedUntil !== "string") {
619
+ return;
620
+ }
621
+ const parsed = Date.parse(rawLockedUntil);
622
+ if (!Number.isFinite(parsed) || parsed <= Date.now()) {
623
+ return;
624
+ }
625
+ const blockedUntil = Math.min(parsed, Date.now() + MAX_UPSTREAM_RATE_LIMIT_DELAY_MS);
626
+ const state = this.rateLimitState(bucket);
627
+ state.blockedUntilMs = Math.max(state.blockedUntilMs ?? 0, blockedUntil);
628
+ this.globalBlockedUntilMs = Math.max(this.globalBlockedUntilMs ?? 0, blockedUntil);
629
+ }
630
+ rateLimitState(bucket) {
631
+ const state = this.upstreamRateLimits.get(bucket) ?? {};
632
+ this.upstreamRateLimits.set(bucket, state);
633
+ return state;
634
+ }
635
+ refreshUpstreamRequestWindow(now) {
636
+ const cutoff = now - UPSTREAM_KEY_IP_WINDOW_MS;
637
+ while (this.upstreamRequestTimestampsMs.length > 0 &&
638
+ (this.upstreamRequestTimestampsMs[0] ?? now) <= cutoff) {
639
+ this.upstreamRequestTimestampsMs.shift();
640
+ }
641
+ }
642
+ upstreamRequestWindowDiagnostics() {
643
+ const now = Date.now();
644
+ this.refreshUpstreamRequestWindow(now);
645
+ const enforced = this.shouldEnforceSharedRequestWindow();
646
+ return {
647
+ enforced,
648
+ limit: UPSTREAM_KEY_IP_REQUEST_LIMIT,
649
+ window_ms: UPSTREAM_KEY_IP_WINDOW_MS,
650
+ used: this.upstreamRequestTimestampsMs.length,
651
+ remaining: enforced
652
+ ? Math.max(0, UPSTREAM_KEY_IP_REQUEST_LIMIT - this.upstreamRequestTimestampsMs.length)
653
+ : undefined,
654
+ resets_at: toIsoString(this.upstreamRequestTimestampsMs.length > 0
655
+ ? (this.upstreamRequestTimestampsMs[0] ?? now) + UPSTREAM_KEY_IP_WINDOW_MS
656
+ : undefined)
657
+ };
658
+ }
659
+ shouldEnforceSharedRequestWindow() {
660
+ try {
661
+ const hostname = new URL(this.config.apiBaseUrl).hostname.toLowerCase();
662
+ if (hostname === "bbradar.io" || hostname.endsWith(".bbradar.io")) {
663
+ return true;
664
+ }
665
+ }
666
+ catch {
667
+ // Invalid base URLs fail when a request URL is constructed.
668
+ }
669
+ return [...this.upstreamRateLimits.values()].some((state) => state.limit !== undefined ||
670
+ state.remaining !== undefined ||
671
+ state.resetAtMs !== undefined);
672
+ }
673
+ refreshUpstreamRateLimitWindow(state, now) {
674
+ if (state.resetAtMs !== undefined && state.resetAtMs <= now) {
675
+ state.remaining = undefined;
676
+ state.resetAtMs = undefined;
677
+ }
678
+ if (state.blockedUntilMs !== undefined && state.blockedUntilMs <= now) {
679
+ state.blockedUntilMs = undefined;
680
+ }
681
+ }
682
+ upstreamRateLimitDiagnostics() {
683
+ const now = Date.now();
684
+ const entries = [...this.upstreamRateLimits.entries()];
685
+ for (const [, state] of entries) {
686
+ this.refreshUpstreamRateLimitWindow(state, now);
687
+ }
688
+ if (this.globalBlockedUntilMs !== undefined && this.globalBlockedUntilMs <= now) {
689
+ this.globalBlockedUntilMs = undefined;
690
+ }
691
+ const observedEntries = entries.filter(([, state]) => state.observedAtMs !== undefined);
692
+ const latest = [...observedEntries].sort((left, right) => (right[1].observedAtMs ?? 0) - (left[1].observedAtMs ?? 0))[0];
693
+ const latestState = latest?.[1];
694
+ const globallyBlocked = (this.globalBlockedUntilMs ?? 0) > now;
695
+ const anyBucketBlocked = observedEntries.some(([, state]) => (state.blockedUntilMs ?? 0) > now);
696
+ const blockedUntil = Math.max(this.globalBlockedUntilMs ?? 0, ...observedEntries.map(([, state]) => state.blockedUntilMs ?? 0));
697
+ return {
698
+ observed: observedEntries.length > 0,
699
+ bucket: latest?.[0],
700
+ limit: latestState?.limit,
701
+ remaining: latestState?.remaining,
702
+ reset_at: toIsoString(latestState?.resetAtMs),
703
+ blocked: globallyBlocked || anyBucketBlocked,
704
+ globally_blocked: globallyBlocked,
705
+ blocked_until: toIsoString(blockedUntil > 0 ? blockedUntil : undefined),
706
+ observed_at: toIsoString(latestState?.observedAtMs),
707
+ last_status: latestState?.lastStatus,
708
+ buckets: Object.fromEntries(observedEntries.map(([bucket, state]) => [
709
+ bucket,
710
+ {
711
+ limit: state.limit,
712
+ remaining: state.remaining,
713
+ reset_at: toIsoString(state.resetAtMs),
714
+ blocked: (state.blockedUntilMs ?? 0) > now,
715
+ blocked_until: toIsoString(state.blockedUntilMs),
716
+ observed_at: toIsoString(state.observedAtMs),
717
+ last_status: state.lastStatus
718
+ }
719
+ ]))
720
+ };
721
+ }
722
+ assertCircuitClosed(requestId, bucket) {
723
+ const state = this.circuitBreakers.get(bucket);
724
+ const now = Date.now();
725
+ if (!state?.openUntilMs || state.openUntilMs <= now) {
726
+ if (state?.openUntilMs !== undefined) {
727
+ state.openUntilMs = undefined;
728
+ }
729
+ return;
730
+ }
731
+ throw new BBRadarApiError({
732
+ message: `BBRadar ${bucket} circuit is temporarily open; no upstream request was sent.`,
733
+ requestId,
734
+ status: 503,
735
+ detail: {
736
+ source: "circuit_breaker",
737
+ bucket,
738
+ consecutive_failures: state.consecutiveFailures,
739
+ retry_at: new Date(state.openUntilMs).toISOString()
740
+ }
741
+ });
742
+ }
743
+ observeCircuitResponse(bucket, status) {
744
+ if (status >= 200 && status < 400) {
745
+ const state = this.circuitBreakers.get(bucket);
746
+ if (state) {
747
+ state.consecutiveFailures = 0;
748
+ state.openUntilMs = undefined;
749
+ state.lastSuccessAtMs = Date.now();
750
+ state.lastStatus = status;
751
+ }
752
+ return;
753
+ }
754
+ if (status === 423 || status === 429 || status >= 500) {
755
+ this.observeCircuitFailure(bucket, status);
220
756
  }
221
757
  }
758
+ observeCircuitFailure(bucket, status) {
759
+ const state = this.circuitBreakers.get(bucket) ?? { consecutiveFailures: 0 };
760
+ state.consecutiveFailures += 1;
761
+ state.lastFailureAtMs = Date.now();
762
+ state.lastStatus = status;
763
+ if (state.consecutiveFailures >=
764
+ (this.config.circuitBreakerFailureThreshold ?? DEFAULT_CIRCUIT_FAILURE_THRESHOLD)) {
765
+ state.openUntilMs =
766
+ Date.now() +
767
+ (this.config.circuitBreakerCooldownMs ?? DEFAULT_CIRCUIT_COOLDOWN_MS);
768
+ }
769
+ this.circuitBreakers.set(bucket, state);
770
+ }
771
+ circuitBreakerDiagnostics() {
772
+ const now = Date.now();
773
+ return {
774
+ failure_threshold: this.config.circuitBreakerFailureThreshold ?? DEFAULT_CIRCUIT_FAILURE_THRESHOLD,
775
+ cooldown_ms: this.config.circuitBreakerCooldownMs ?? DEFAULT_CIRCUIT_COOLDOWN_MS,
776
+ by_endpoint: Object.fromEntries([...this.circuitBreakers.entries()].map(([bucket, state]) => [
777
+ bucket,
778
+ {
779
+ state: (state.openUntilMs ?? 0) > now ? "open" : state.consecutiveFailures > 0 ? "closed_degraded" : "closed",
780
+ consecutive_failures: state.consecutiveFailures,
781
+ open_until: toIsoString(state.openUntilMs),
782
+ last_failure_at: toIsoString(state.lastFailureAtMs),
783
+ last_success_at: toIsoString(state.lastSuccessAtMs),
784
+ last_status: state.lastStatus
785
+ }
786
+ ]))
787
+ };
788
+ }
222
789
  buildUrl(path, query) {
223
790
  const url = new URL(`${this.config.apiBaseUrl}${path}`);
224
791
  for (const [key, value] of Object.entries(query ?? {})) {
@@ -227,20 +794,19 @@ export class BBRadarClient {
227
794
  }
228
795
  if (Array.isArray(value)) {
229
796
  if (value.length > 0) {
230
- url.searchParams.set(key, value.join(","));
797
+ url.searchParams.set(key, [...value].map(String).sort().join(","));
231
798
  }
232
799
  continue;
233
800
  }
234
801
  url.searchParams.set(key, String(value));
235
802
  }
803
+ url.searchParams.sort();
236
804
  return url;
237
805
  }
238
806
  headers(body) {
239
807
  const headers = {
240
808
  authorization: `Bearer ${this.config.apiKey}`,
241
809
  accept: "application/json",
242
- "cache-control": "no-cache, no-store, max-age=0",
243
- pragma: "no-cache",
244
810
  "user-agent": "@bbradar/mcp"
245
811
  };
246
812
  if (body !== undefined) {
@@ -249,12 +815,155 @@ export class BBRadarClient {
249
815
  return headers;
250
816
  }
251
817
  }
818
+ function localRateLimitError(requestId, bucket, state, globalBlockedUntilMs, upstreamWindowUsed) {
819
+ return new BBRadarApiError({
820
+ message: "BBRadar API quota is temporarily exhausted; no upstream request was sent.",
821
+ requestId,
822
+ status: 429,
823
+ detail: {
824
+ source: "local_rate_limit_guard",
825
+ bucket,
826
+ limit: state.limit,
827
+ remaining: state.remaining,
828
+ reset_at: toIsoString(state.resetAtMs),
829
+ retry_at: toIsoString(Math.max(state.blockedUntilMs ?? 0, globalBlockedUntilMs ?? 0)),
830
+ shared_key_ip_window: {
831
+ limit: UPSTREAM_KEY_IP_REQUEST_LIMIT,
832
+ used: upstreamWindowUsed,
833
+ remaining: Math.max(0, UPSTREAM_KEY_IP_REQUEST_LIMIT - upstreamWindowUsed)
834
+ }
835
+ }
836
+ });
837
+ }
838
+ function readRateLimitNumber(headers, names) {
839
+ for (const name of names) {
840
+ const value = headers.get(name);
841
+ if (value === null) {
842
+ continue;
843
+ }
844
+ const parsed = Number(value.trim());
845
+ if (Number.isFinite(parsed) && parsed >= 0) {
846
+ return Math.floor(parsed);
847
+ }
848
+ }
849
+ return undefined;
850
+ }
851
+ function parseRateLimitReset(headers, now) {
852
+ const value = headers.get("x-ratelimit-reset") ?? headers.get("ratelimit-reset");
853
+ if (!value) {
854
+ return undefined;
855
+ }
856
+ const numeric = Number(value.trim());
857
+ if (Number.isFinite(numeric) && numeric >= 0) {
858
+ if (numeric >= 1_000_000_000_000) {
859
+ return boundUpstreamRateLimitTimestamp(numeric, now);
860
+ }
861
+ if (numeric >= 1_000_000_000) {
862
+ return boundUpstreamRateLimitTimestamp(numeric * 1_000, now);
863
+ }
864
+ return boundUpstreamRateLimitTimestamp(now + numeric * 1_000, now);
865
+ }
866
+ const timestamp = Date.parse(value);
867
+ return boundUpstreamRateLimitTimestamp(timestamp, now);
868
+ }
869
+ function parseRetryAfter(headers, now) {
870
+ const value = headers.get("retry-after");
871
+ if (!value) {
872
+ return undefined;
873
+ }
874
+ const seconds = Number(value.trim());
875
+ if (Number.isFinite(seconds) && seconds >= 0) {
876
+ return boundUpstreamRateLimitTimestamp(now + seconds * 1_000, now);
877
+ }
878
+ const timestamp = Date.parse(value);
879
+ return boundUpstreamRateLimitTimestamp(timestamp, now);
880
+ }
881
+ function boundUpstreamRateLimitTimestamp(timestamp, now) {
882
+ if (!Number.isFinite(timestamp)) {
883
+ return undefined;
884
+ }
885
+ return Math.min(timestamp, now + MAX_UPSTREAM_RATE_LIMIT_DELAY_MS);
886
+ }
887
+ function toIsoString(timestamp) {
888
+ if (timestamp === undefined || !Number.isFinite(timestamp)) {
889
+ return undefined;
890
+ }
891
+ const date = new Date(timestamp);
892
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
893
+ }
894
+ function metricEndpoint(path) {
895
+ if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
896
+ return "/pro/programs/:program_id/targets";
897
+ }
898
+ if (/^\/pro\/programs\/.+/.test(path)) {
899
+ return "/pro/programs/:program_id";
900
+ }
901
+ if (/^\/pro\/opportunities\/[^/]+$/.test(path)) {
902
+ return "/pro/opportunities/:level";
903
+ }
904
+ return path;
905
+ }
906
+ function rateLimitEndpointGroup(path) {
907
+ if (path === "/pro/targets/changes") {
908
+ return "change_feeds";
909
+ }
910
+ if (path === "/pro/repositories/changes") {
911
+ return "change_feeds";
912
+ }
913
+ if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
914
+ return "target_operations";
915
+ }
916
+ if (path === "/pro/capabilities" ||
917
+ path === "/pro/programs" ||
918
+ /^\/pro\/programs\/.+/.test(path) ||
919
+ /^\/pro\/opportunities\/[^/]+$/.test(path)) {
920
+ return "programs_and_opportunities";
921
+ }
922
+ if (path === "/pro/targets/search") {
923
+ return "target_operations";
924
+ }
925
+ if (path === "/pro/targets/export") {
926
+ return "target_operations";
927
+ }
928
+ return "other";
929
+ }
930
+ function concurrencyEndpointGroup(path) {
931
+ if (path === "/pro/targets/changes" || path === "/pro/repositories/changes") {
932
+ return "change_feeds";
933
+ }
934
+ if (path === "/pro/targets/export") {
935
+ return "target_export";
936
+ }
937
+ return undefined;
938
+ }
939
+ function endpointConcurrencyLimit(endpointGroup) {
940
+ if (endpointGroup === "change_feeds") {
941
+ return CHANGE_FEED_CONCURRENCY_LIMIT;
942
+ }
943
+ if (endpointGroup === "target_export") {
944
+ return TARGET_EXPORT_CONCURRENCY_LIMIT;
945
+ }
946
+ return undefined;
947
+ }
948
+ function isNegativeCacheStatus(status) {
949
+ return status === 404 || status === 405;
950
+ }
951
+ function asRecord(value) {
952
+ return typeof value === "object" && value !== null && !Array.isArray(value)
953
+ ? value
954
+ : undefined;
955
+ }
252
956
  async function parseResponseBody(response) {
253
957
  if (response.status === 204) {
254
958
  return null;
255
959
  }
256
960
  const contentType = response.headers.get("content-type") ?? "";
257
- const text = await response.text();
961
+ const advertisedLength = Number(response.headers.get("content-length"));
962
+ if (Number.isFinite(advertisedLength) && advertisedLength > MAX_RESPONSE_BODY_BYTES) {
963
+ await response.body?.cancel();
964
+ throw new Error("BBRadar API response exceeded the MCP response-size limit.");
965
+ }
966
+ const text = await readBoundedResponseText(response, MAX_RESPONSE_BODY_BYTES);
258
967
  if (text.length === 0) {
259
968
  return null;
260
969
  }
@@ -268,6 +977,34 @@ async function parseResponseBody(response) {
268
977
  }
269
978
  return text;
270
979
  }
980
+ async function readBoundedResponseText(response, maxBytes) {
981
+ if (!response.body) {
982
+ return "";
983
+ }
984
+ const reader = response.body.getReader();
985
+ const decoder = new TextDecoder();
986
+ let totalBytes = 0;
987
+ let text = "";
988
+ try {
989
+ while (true) {
990
+ const { done, value } = await reader.read();
991
+ if (done) {
992
+ break;
993
+ }
994
+ totalBytes += value.byteLength;
995
+ if (totalBytes > maxBytes) {
996
+ await reader.cancel();
997
+ throw new Error("BBRadar API response exceeded the MCP response-size limit.");
998
+ }
999
+ text += decoder.decode(value, { stream: true });
1000
+ }
1001
+ text += decoder.decode();
1002
+ return text;
1003
+ }
1004
+ finally {
1005
+ reader.releaseLock();
1006
+ }
1007
+ }
271
1008
  function extractRequestId(headers) {
272
1009
  return (headers.get("x-request-id") ??
273
1010
  headers.get("x-correlation-id") ??
@@ -290,7 +1027,7 @@ function formatApiErrorMessage(status, data) {
290
1027
  : "Request was rejected.";
291
1028
  return `BBRadar API returned ${status}: ${suffix}`;
292
1029
  }
293
- function cloneApiError(error, requestId) {
1030
+ function cloneApiError(error, requestId, coalesced = error instanceof BBRadarApiError && error.coalesced) {
294
1031
  if (!(error instanceof BBRadarApiError)) {
295
1032
  return error;
296
1033
  }
@@ -301,6 +1038,9 @@ function cloneApiError(error, requestId) {
301
1038
  status: error.status,
302
1039
  detail: error.detail,
303
1040
  errors: error.errors,
1041
+ cached: error.cached,
1042
+ coalesced,
1043
+ cacheExpiresAt: error.cacheExpiresAt,
304
1044
  cause: error
305
1045
  });
306
1046
  }