@bbradar/mcp 0.1.20 → 0.2.1
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 +44 -8
- package/benchmark/mcp-benchmark.mjs +167 -0
- package/dist/bbradarClient.d.ts +51 -2
- package/dist/bbradarClient.js +331 -36
- package/dist/bbradarClient.js.map +1 -1
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +16 -5
- package/dist/cache.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.js +48 -8
- package/dist/config.js.map +1 -1
- package/dist/cursorStore.d.ts +33 -0
- package/dist/cursorStore.js +123 -0
- package/dist/cursorStore.js.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +164 -7
- package/dist/sanitize.js.map +1 -1
- package/dist/server.js +917 -64
- package/dist/server.js.map +1 -1
- package/package.json +4 -2
package/dist/bbradarClient.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
70
|
-
limit:
|
|
71
|
-
active: this.endpointActiveRequests.get("
|
|
72
|
-
queued: this.endpointRequestQueues.get("
|
|
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.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
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 (
|
|
277
|
-
init.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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
914
|
+
return "target_operations";
|
|
674
915
|
}
|
|
675
|
-
if (path === "/pro/
|
|
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 "
|
|
923
|
+
return "target_operations";
|
|
682
924
|
}
|
|
683
925
|
if (path === "/pro/targets/export") {
|
|
684
|
-
return "
|
|
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
|
-
|
|
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
|
|
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") ??
|