@bbradar/mcp 0.1.20 → 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.
@@ -3,9 +3,16 @@ import { TtlCache } from "./cache.js";
3
3
  import { stableStringify } from "./json.js";
4
4
  import { encodeProgramIdPath } from "./url.js";
5
5
  const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
6
- const TARGET_CHANGES_CONCURRENCY_LIMIT = 3;
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;
7
9
  const UPSTREAM_KEY_IP_REQUEST_LIMIT = 60;
8
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;
9
16
  export class BBRadarApiError extends Error {
10
17
  requestId;
11
18
  upstreamRequestId;
@@ -38,12 +45,15 @@ export class BBRadarClient {
38
45
  endpointRequestQueues = new Map();
39
46
  endpointActiveRequests = new Map();
40
47
  upstreamRateLimits = new Map();
48
+ circuitBreakers = new Map();
41
49
  upstreamRequestTimestampsMs = [];
42
50
  activeRequests = 0;
43
51
  globalBlockedUntilMs;
52
+ capabilityNegotiation;
53
+ capabilityState = { status: "not_started" };
44
54
  constructor(config) {
45
55
  this.config = config;
46
- this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs);
56
+ this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs, config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS);
47
57
  this.negativeCache = new TtlCache(config.cacheMaxEntries, config.negativeCacheTtlMs);
48
58
  }
49
59
  diagnostics() {
@@ -60,16 +70,23 @@ export class BBRadarClient {
60
70
  enabled: this.config.negativeCacheTtlMs > 0 && this.config.cacheMaxEntries > 0,
61
71
  ...this.negativeCache.stats()
62
72
  },
73
+ capability_negotiation: this.capabilitySnapshot(),
74
+ circuit_breakers: this.circuitBreakerDiagnostics(),
63
75
  in_flight_requests: this.inFlight.size,
64
76
  request_concurrency: {
65
77
  limit: this.config.maxConcurrentRequests,
66
78
  active: this.activeRequests,
67
79
  queued: this.requestQueue.length,
68
80
  endpoint_limits: {
69
- target_changes: {
70
- limit: TARGET_CHANGES_CONCURRENCY_LIMIT,
71
- active: this.endpointActiveRequests.get("target_changes") ?? 0,
72
- queued: this.endpointRequestQueues.get("target_changes")?.length ?? 0
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
73
90
  }
74
91
  }
75
92
  },
@@ -96,12 +113,15 @@ export class BBRadarClient {
96
113
  }
97
114
  async validateKey() {
98
115
  try {
99
- await this.request({
100
- path: "/pro/programs",
101
- query: { page: 1, page_size: 1 },
102
- cache: false,
103
- coalesce: false
104
- });
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
+ }
105
125
  }
106
126
  catch (error) {
107
127
  if (!(error instanceof BBRadarApiError)) {
@@ -122,26 +142,105 @@ export class BBRadarClient {
122
142
  throw error;
123
143
  }
124
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
+ }
125
204
  listPrograms(query) {
126
205
  return this.request({
127
206
  path: "/pro/programs",
128
207
  query
129
208
  });
130
209
  }
210
+ getCapabilities(options = {}) {
211
+ return this.request({
212
+ path: "/pro/capabilities",
213
+ ...options
214
+ });
215
+ }
131
216
  getProgram(programId) {
132
217
  return this.request({
133
- path: `/pro/programs/${encodeProgramIdPath(programId)}`
218
+ path: `/pro/programs/${encodeProgramIdPath(programId)}`,
219
+ staleWhileRevalidateMs: this.config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS
134
220
  });
135
221
  }
136
- getProgramTargets(programId) {
222
+ getProgramTargets(programId, query = {}) {
137
223
  return this.request({
138
- 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
+ }
139
230
  });
140
231
  }
141
232
  getRecentChanges(query) {
142
233
  return this.request({
143
234
  path: "/pro/targets/changes",
144
- 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
145
244
  });
146
245
  }
147
246
  searchTargets(query) {
@@ -181,12 +280,16 @@ export class BBRadarClient {
181
280
  this.config.cacheMaxEntries > 0;
182
281
  const coalesceable = method === "GET" && (options.coalesce ?? true);
183
282
  if (cacheable) {
184
- const cached = this.cache.get(requestKey);
283
+ const cached = this.cache.getWithStale(requestKey, options.staleWhileRevalidateMs ?? 0);
185
284
  if (cached) {
285
+ if (cached.stale) {
286
+ this.refreshInBackground(options, method, url, requestKey);
287
+ }
186
288
  return {
187
289
  ...cached.value,
188
290
  requestId,
189
291
  cached: true,
292
+ stale: cached.stale,
190
293
  cacheExpiresAt: new Date(cached.expiresAt).toISOString()
191
294
  };
192
295
  }
@@ -259,8 +362,21 @@ export class BBRadarClient {
259
362
  }
260
363
  }
261
364
  async fetchFromApi(options, method, url, requestId) {
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.`,
371
+ requestId,
372
+ status: 413,
373
+ detail: { body_bytes: bodyBytes, max_body_bytes: MAX_REQUEST_BODY_BYTES }
374
+ });
375
+ }
376
+ }
262
377
  return this.withRequestSlot(options.path, async () => {
263
378
  const rateLimitBucket = rateLimitEndpointGroup(options.path);
379
+ this.assertCircuitClosed(requestId, rateLimitBucket);
264
380
  this.reserveUpstreamRequest(requestId, rateLimitBucket);
265
381
  const startedAt = Date.now();
266
382
  const controller = new AbortController();
@@ -273,15 +389,17 @@ export class BBRadarClient {
273
389
  headers: this.headers(options.body),
274
390
  signal: controller.signal
275
391
  };
276
- if (options.body !== undefined) {
277
- init.body = JSON.stringify(options.body);
392
+ if (serializedBody !== undefined) {
393
+ init.body = serializedBody;
278
394
  }
279
395
  const response = await fetch(url, init);
280
396
  status = response.status;
397
+ this.observeCircuitResponse(rateLimitBucket, response.status);
281
398
  this.observeUpstreamRateLimit(rateLimitBucket, response.headers, response.status);
282
399
  const upstreamRequestId = extractRequestId(response.headers);
283
400
  const data = await parseResponseBody(response);
284
401
  if (!response.ok) {
402
+ this.observeUpstreamErrorBody(rateLimitBucket, response.status, data);
285
403
  const params = {
286
404
  message: formatApiErrorMessage(response.status, data),
287
405
  requestId,
@@ -309,6 +427,7 @@ export class BBRadarClient {
309
427
  if (error instanceof BBRadarApiError) {
310
428
  throw error;
311
429
  }
430
+ this.observeCircuitFailure(rateLimitBucket);
312
431
  throw new BBRadarApiError({
313
432
  message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
314
433
  requestId,
@@ -321,8 +440,29 @@ export class BBRadarClient {
321
440
  }
322
441
  });
323
442
  }
443
+ refreshInBackground(options, method, url, requestKey) {
444
+ if (this.inFlight.has(requestKey)) {
445
+ return;
446
+ }
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);
458
+ }
459
+ });
460
+ }
324
461
  async withRequestSlot(path, callback) {
325
- const endpointGroup = rateLimitEndpointGroup(path);
462
+ const endpointGroup = concurrencyEndpointGroup(path);
463
+ if (endpointGroup === undefined) {
464
+ return this.withGlobalRequestSlot(callback);
465
+ }
326
466
  const endpointLimit = endpointConcurrencyLimit(endpointGroup);
327
467
  if (endpointLimit === undefined) {
328
468
  return this.withGlobalRequestSlot(callback);
@@ -450,10 +590,12 @@ export class BBRadarClient {
450
590
  }
451
591
  state.observedAtMs = now;
452
592
  state.lastStatus = status;
453
- if (status === 429) {
593
+ if (status === 429 || ((status === 423 || status === 503) && retryAtMs !== undefined)) {
454
594
  const advertisedBlockUntil = Math.max(retryAtMs ?? 0, resetAtMs && resetAtMs > now ? resetAtMs : 0);
455
595
  const blockedUntil = advertisedBlockUntil > now ? advertisedBlockUntil : now + DEFAULT_RATE_LIMIT_COOLDOWN_MS;
456
- state.remaining = 0;
596
+ if (status === 429) {
597
+ state.remaining = 0;
598
+ }
457
599
  state.blockedUntilMs = blockedUntil;
458
600
  this.globalBlockedUntilMs = Math.max(this.globalBlockedUntilMs ?? 0, blockedUntil);
459
601
  return;
@@ -466,6 +608,25 @@ export class BBRadarClient {
466
608
  state.blockedUntilMs = undefined;
467
609
  }
468
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
+ }
469
630
  rateLimitState(bucket) {
470
631
  const state = this.upstreamRateLimits.get(bucket) ?? {};
471
632
  this.upstreamRateLimits.set(bucket, state);
@@ -558,6 +719,73 @@ export class BBRadarClient {
558
719
  ]))
559
720
  };
560
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);
756
+ }
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
+ }
561
789
  buildUrl(path, query) {
562
790
  const url = new URL(`${this.config.apiBaseUrl}${path}`);
563
791
  for (const [key, value] of Object.entries(query ?? {})) {
@@ -628,15 +856,15 @@ function parseRateLimitReset(headers, now) {
628
856
  const numeric = Number(value.trim());
629
857
  if (Number.isFinite(numeric) && numeric >= 0) {
630
858
  if (numeric >= 1_000_000_000_000) {
631
- return numeric;
859
+ return boundUpstreamRateLimitTimestamp(numeric, now);
632
860
  }
633
861
  if (numeric >= 1_000_000_000) {
634
- return numeric * 1_000;
862
+ return boundUpstreamRateLimitTimestamp(numeric * 1_000, now);
635
863
  }
636
- return now + numeric * 1_000;
864
+ return boundUpstreamRateLimitTimestamp(now + numeric * 1_000, now);
637
865
  }
638
866
  const timestamp = Date.parse(value);
639
- return Number.isFinite(timestamp) ? timestamp : undefined;
867
+ return boundUpstreamRateLimitTimestamp(timestamp, now);
640
868
  }
641
869
  function parseRetryAfter(headers, now) {
642
870
  const value = headers.get("retry-after");
@@ -645,13 +873,23 @@ function parseRetryAfter(headers, now) {
645
873
  }
646
874
  const seconds = Number(value.trim());
647
875
  if (Number.isFinite(seconds) && seconds >= 0) {
648
- return now + seconds * 1_000;
876
+ return boundUpstreamRateLimitTimestamp(now + seconds * 1_000, now);
649
877
  }
650
878
  const timestamp = Date.parse(value);
651
- return Number.isFinite(timestamp) ? timestamp : undefined;
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);
652
886
  }
653
887
  function toIsoString(timestamp) {
654
- return timestamp === undefined || !Number.isFinite(timestamp) ? undefined : new Date(timestamp).toISOString();
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();
655
893
  }
656
894
  function metricEndpoint(path) {
657
895
  if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
@@ -667,36 +905,65 @@ function metricEndpoint(path) {
667
905
  }
668
906
  function rateLimitEndpointGroup(path) {
669
907
  if (path === "/pro/targets/changes") {
670
- return "target_changes";
908
+ return "change_feeds";
909
+ }
910
+ if (path === "/pro/repositories/changes") {
911
+ return "change_feeds";
671
912
  }
672
913
  if (/^\/pro\/programs\/.+\/targets$/.test(path)) {
673
- return "program_targets";
914
+ return "target_operations";
674
915
  }
675
- if (path === "/pro/programs" ||
916
+ if (path === "/pro/capabilities" ||
917
+ path === "/pro/programs" ||
676
918
  /^\/pro\/programs\/.+/.test(path) ||
677
919
  /^\/pro\/opportunities\/[^/]+$/.test(path)) {
678
920
  return "programs_and_opportunities";
679
921
  }
680
922
  if (path === "/pro/targets/search") {
681
- return "target_search";
923
+ return "target_operations";
682
924
  }
683
925
  if (path === "/pro/targets/export") {
684
- return "target_export";
926
+ return "target_operations";
685
927
  }
686
928
  return "other";
687
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
+ }
688
939
  function endpointConcurrencyLimit(endpointGroup) {
689
- return endpointGroup === "target_changes" ? TARGET_CHANGES_CONCURRENCY_LIMIT : undefined;
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;
690
947
  }
691
948
  function isNegativeCacheStatus(status) {
692
949
  return status === 404 || status === 405;
693
950
  }
951
+ function asRecord(value) {
952
+ return typeof value === "object" && value !== null && !Array.isArray(value)
953
+ ? value
954
+ : undefined;
955
+ }
694
956
  async function parseResponseBody(response) {
695
957
  if (response.status === 204) {
696
958
  return null;
697
959
  }
698
960
  const contentType = response.headers.get("content-type") ?? "";
699
- 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);
700
967
  if (text.length === 0) {
701
968
  return null;
702
969
  }
@@ -710,6 +977,34 @@ async function parseResponseBody(response) {
710
977
  }
711
978
  return text;
712
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
+ }
713
1008
  function extractRequestId(headers) {
714
1009
  return (headers.get("x-request-id") ??
715
1010
  headers.get("x-correlation-id") ??