@bbradar/mcp 0.1.6 → 0.1.8

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/dist/server.js CHANGED
@@ -29,8 +29,9 @@ const DEFAULT_FILTER_TARGET_SAMPLE_PROGRAMS = 10;
29
29
  const MAX_FILTER_TARGET_SAMPLE_PROGRAMS = 25;
30
30
  const MAX_LOCAL_EXPORT_RESOURCES = 25;
31
31
  const EXPORT_PREVIEW_LIMIT = 25;
32
- const STALE_PROGRAM_ID_RESOLVE_PAGES = 5;
33
- const STALE_PROGRAM_ID_RESOLVE_BUDGET = 5;
32
+ const STALE_PROGRAM_ID_RESOLVE_PAGES = 1;
33
+ const STALE_PROGRAM_ID_RESOLVE_BUDGET = 1;
34
+ const PROGRAM_FALLBACK_CACHE_MAX_ENTRIES = 200;
34
35
  const MAX_RESOLVE_SEARCH_QUERIES = 4;
35
36
  const SDK_VERSION = "1.29.0";
36
37
  const WEB3_TAGS = ["web3", "crypto", "blockchain", "smart-contract", "smart contract", "defi", "nft", "ethereum", "solana", "solidity"];
@@ -92,10 +93,9 @@ const filterValueSchema = z
92
93
  message: "Filter values cannot contain commas or control characters."
93
94
  });
94
95
  const stringListSchema = z
95
- .array(filterValueSchema)
96
- .max(50)
97
- .default([])
96
+ .preprocess(normalizeStringListInput, z.array(filterValueSchema).max(50).default([]))
98
97
  .describe("Filter values.");
98
+ const acceptedTargetLimitSchema = z.number().int().min(1).max(MAX_TARGETS_PER_PROGRAM);
99
99
  const searchTextSchema = z
100
100
  .string()
101
101
  .trim()
@@ -329,7 +329,7 @@ export function createBbradarServer(client, config) {
329
329
  include_out_of_scope: z.boolean().default(false),
330
330
  include_ineligible: z.boolean().default(false),
331
331
  page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
332
- max_targets: z.number().int().min(1).max(MAX_RECENT_CHANGES).default(25),
332
+ max_targets: acceptedTargetLimitSchema.default(25),
333
333
  group_limit: z.number().int().min(1).max(MAX_TARGETS_PER_PROGRAM).default(100),
334
334
  target_list_mode: targetListModeSchema.default("identifiers"),
335
335
  max_resolve_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
@@ -486,7 +486,7 @@ export function createBbradarServer(client, config) {
486
486
  include_out_of_scope: z.boolean().default(false),
487
487
  include_ineligible: z.boolean().default(false),
488
488
  page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
489
- max_targets: z.number().int().min(1).max(MAX_RECENT_CHANGES).default(50),
489
+ max_targets: acceptedTargetLimitSchema.default(50),
490
490
  target_list_mode: targetListModeSchema.default("identifiers")
491
491
  },
492
492
  outputSchema: {
@@ -498,7 +498,7 @@ export function createBbradarServer(client, config) {
498
498
  meta: jsonRecordSchema.optional()
499
499
  },
500
500
  annotations: readOnlyAnnotations
501
- }, (args) => runTool("get_program_scope_delta", config, rateLimiter, async () => runProgramIdTool(client, config, args.program_id, (programId) => getProgramScopeDelta(client, config, { ...args, program_id: programId }))));
501
+ }, (args) => runTool("get_program_scope_delta", config, rateLimiter, async () => runProgramIdTool(client, config, args.program_id, (programId) => getProgramScopeDelta(client, config, { ...args, program_id: programId }), (indexFallback) => getProgramScopeDeltaFromIndexedProgram(client, config, { ...args, program_id: indexFallback.resolvedProgramId }, indexFallback.program))));
502
502
  server.registerTool("get_recent_target_activity", {
503
503
  title: "Get Recent Target Activity",
504
504
  description: "Recent target changes grouped by program.",
@@ -534,7 +534,7 @@ export function createBbradarServer(client, config) {
534
534
  target_type: filterValueSchema.optional(),
535
535
  include_ineligible: z.boolean().default(false).describe("Include ineligible."),
536
536
  page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
537
- max_targets: z.number().int().min(1).max(MAX_RECENT_CHANGES).default(25),
537
+ max_targets: acceptedTargetLimitSchema.default(25),
538
538
  target_list_mode: targetListModeSchema.default("identifiers")
539
539
  },
540
540
  outputSchema: {
@@ -1366,24 +1366,33 @@ export function createBbradarServer(client, config) {
1366
1366
  },
1367
1367
  outputSchema: {
1368
1368
  ...apiEnvelopeOutputShape,
1369
- export: z.unknown().optional()
1369
+ export: z.unknown().optional(),
1370
+ meta: jsonRecordSchema.optional()
1370
1371
  },
1371
1372
  annotations: readOnlyAnnotations
1372
1373
  }, (args) => runTool("export_targets", config, rateLimiter, async () => {
1373
- const api = await client.exportTargets(compactRecord(args));
1374
- const exportId = randomUUID();
1375
- const sanitizedExport = sanitizeTargetsForExport(api.data, args.limit);
1376
- const resourceUri = exportResourceUri(exportId);
1377
- rememberExport(exportStore, exportId, sanitizedExport);
1378
- return withApiMetadata(api, {
1379
- export: {
1380
- export_id: exportId,
1381
- resource_uri: resourceUri,
1382
- preview: previewExportPayload(sanitizedExport),
1383
- returned_preview_count: previewExportCount(sanitizedExport),
1384
- limit: args.limit
1374
+ try {
1375
+ const api = await client.exportTargets(compactRecord(args));
1376
+ const exportId = randomUUID();
1377
+ const sanitizedExport = sanitizeTargetsForExport(api.data, args.limit);
1378
+ const resourceUri = exportResourceUri(exportId);
1379
+ rememberExport(exportStore, exportId, sanitizedExport);
1380
+ return withApiMetadata(api, {
1381
+ export: {
1382
+ export_id: exportId,
1383
+ resource_uri: resourceUri,
1384
+ preview: previewExportPayload(sanitizedExport),
1385
+ returned_preview_count: previewExportCount(sanitizedExport),
1386
+ limit: args.limit
1387
+ }
1388
+ });
1389
+ }
1390
+ catch (error) {
1391
+ if (!(error instanceof BBRadarApiError) || (error.status !== 404 && error.status !== 405)) {
1392
+ throw error;
1385
1393
  }
1386
- });
1394
+ return exportTargetsFromProgramTargetsFallback(client, config, exportStore, args, error);
1395
+ }
1387
1396
  }));
1388
1397
  registerPrompts(server);
1389
1398
  registerResources(server, client, config, exportStore);
@@ -1480,7 +1489,19 @@ function registerResources(server, client, config, exportStore) {
1480
1489
  }));
1481
1490
  });
1482
1491
  }
1492
+ const programFallbackCache = new Map();
1493
+ const programTargetsFallbackCaches = new WeakMap();
1483
1494
  async function getProgramWithIdFallback(client, config, requestedProgramId) {
1495
+ const cachedFallback = getCachedProgramFallback(config, requestedProgramId);
1496
+ if (cachedFallback && !isProgramIndexFallback(cachedFallback)) {
1497
+ return {
1498
+ api: await client.getProgram(cachedFallback.resolvedProgramId),
1499
+ programId: cachedFallback.resolvedProgramId,
1500
+ sourceRequests: cachedFallback.sourceRequests,
1501
+ warnings: cachedFallback.warnings,
1502
+ fallback: cachedFallback
1503
+ };
1504
+ }
1484
1505
  try {
1485
1506
  return {
1486
1507
  api: await client.getProgram(requestedProgramId),
@@ -1497,6 +1518,7 @@ async function getProgramWithIdFallback(client, config, requestedProgramId) {
1497
1518
  if (!fallback) {
1498
1519
  throw error;
1499
1520
  }
1521
+ rememberProgramFallback(config, fallback);
1500
1522
  return {
1501
1523
  api: await client.getProgram(fallback.resolvedProgramId),
1502
1524
  programId: fallback.resolvedProgramId,
@@ -1507,6 +1529,30 @@ async function getProgramWithIdFallback(client, config, requestedProgramId) {
1507
1529
  }
1508
1530
  }
1509
1531
  async function runProgramIdTool(client, config, requestedProgramId, callback, indexFallbackCallback) {
1532
+ const cachedFallback = getCachedProgramFallback(config, requestedProgramId);
1533
+ if (cachedFallback) {
1534
+ if (isProgramIndexFallback(cachedFallback) && indexFallbackCallback) {
1535
+ const payload = await indexFallbackCallback(cachedFallback);
1536
+ return withProgramIndexFallbackMetadata(payload, cachedFallback);
1537
+ }
1538
+ try {
1539
+ const payload = await callback(cachedFallback.resolvedProgramId);
1540
+ return withProgramIdFallbackMetadata(payload, cachedFallback);
1541
+ }
1542
+ catch (cachedFallbackError) {
1543
+ if (!isProgramNotFoundError(cachedFallbackError) || !indexFallbackCallback) {
1544
+ throw cachedFallbackError;
1545
+ }
1546
+ const indexFallback = createProgramIndexFallbackFromStaleResolution(cachedFallback, requestedProgramId, cachedFallbackError) ??
1547
+ (await resolveIndexedProgramId(client, config, cachedFallback.resolvedProgramId, cachedFallbackError));
1548
+ if (!indexFallback) {
1549
+ throw cachedFallbackError;
1550
+ }
1551
+ rememberProgramFallback(config, indexFallback);
1552
+ const payload = await indexFallbackCallback(indexFallback);
1553
+ return withProgramIndexFallbackMetadata(payload, indexFallback);
1554
+ }
1555
+ }
1510
1556
  try {
1511
1557
  return await callback(requestedProgramId);
1512
1558
  }
@@ -1523,10 +1569,12 @@ async function runProgramIdTool(client, config, requestedProgramId, callback, in
1523
1569
  if (!indexFallback) {
1524
1570
  throw error;
1525
1571
  }
1572
+ rememberProgramFallback(config, indexFallback);
1526
1573
  const payload = await indexFallbackCallback(indexFallback);
1527
1574
  return withProgramIndexFallbackMetadata(payload, indexFallback);
1528
1575
  }
1529
1576
  try {
1577
+ rememberProgramFallback(config, fallback);
1530
1578
  const payload = await callback(fallback.resolvedProgramId);
1531
1579
  return withProgramIdFallbackMetadata(payload, fallback);
1532
1580
  }
@@ -1534,21 +1582,107 @@ async function runProgramIdTool(client, config, requestedProgramId, callback, in
1534
1582
  if (!isProgramNotFoundError(fallbackError) || !indexFallbackCallback) {
1535
1583
  throw fallbackError;
1536
1584
  }
1537
- const indexFallback = await resolveIndexedProgramId(client, config, fallback.resolvedProgramId, fallbackError);
1585
+ const indexFallback = createProgramIndexFallbackFromStaleResolution(fallback, requestedProgramId, fallbackError) ??
1586
+ (await resolveIndexedProgramId(client, config, fallback.resolvedProgramId, fallbackError));
1538
1587
  if (!indexFallback) {
1539
1588
  throw fallbackError;
1540
1589
  }
1541
- const combinedFallback = {
1542
- ...indexFallback,
1543
- requestedProgramId,
1544
- sourceRequests: [...fallback.sourceRequests, ...indexFallback.sourceRequests],
1545
- warnings: uniqueStrings([...fallback.warnings, ...indexFallback.warnings])
1546
- };
1590
+ const combinedFallback = indexFallback.requestedProgramId === requestedProgramId
1591
+ ? indexFallback
1592
+ : {
1593
+ ...indexFallback,
1594
+ requestedProgramId,
1595
+ sourceRequests: [...fallback.sourceRequests, ...indexFallback.sourceRequests],
1596
+ warnings: uniqueStrings([...fallback.warnings, ...indexFallback.warnings])
1597
+ };
1598
+ rememberProgramFallback(config, combinedFallback);
1547
1599
  const payload = await indexFallbackCallback(combinedFallback);
1548
1600
  return withProgramIndexFallbackMetadata(payload, combinedFallback);
1549
1601
  }
1550
1602
  }
1551
1603
  }
1604
+ function getCachedProgramFallback(config, requestedProgramId) {
1605
+ if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0) {
1606
+ return undefined;
1607
+ }
1608
+ const key = programFallbackCacheKey(config, requestedProgramId);
1609
+ const cached = programFallbackCache.get(key);
1610
+ if (!cached) {
1611
+ return undefined;
1612
+ }
1613
+ if (cached.expiresAt <= Date.now()) {
1614
+ programFallbackCache.delete(key);
1615
+ return undefined;
1616
+ }
1617
+ return cloneProgramFallbackForRequest(cached.fallback, requestedProgramId, true);
1618
+ }
1619
+ function rememberProgramFallback(config, fallback) {
1620
+ if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0) {
1621
+ return;
1622
+ }
1623
+ pruneProgramFallbackCache();
1624
+ const key = programFallbackCacheKey(config, fallback.requestedProgramId);
1625
+ programFallbackCache.set(key, {
1626
+ expiresAt: Date.now() + config.cacheTtlMs,
1627
+ fallback: cloneProgramFallbackForRequest(fallback, fallback.requestedProgramId, false)
1628
+ });
1629
+ if (programFallbackCache.size > PROGRAM_FALLBACK_CACHE_MAX_ENTRIES) {
1630
+ const oldestKey = programFallbackCache.keys().next().value;
1631
+ if (oldestKey) {
1632
+ programFallbackCache.delete(oldestKey);
1633
+ }
1634
+ }
1635
+ }
1636
+ function pruneProgramFallbackCache() {
1637
+ const now = Date.now();
1638
+ for (const [key, cached] of programFallbackCache) {
1639
+ if (cached.expiresAt <= now) {
1640
+ programFallbackCache.delete(key);
1641
+ }
1642
+ }
1643
+ }
1644
+ function programFallbackCacheKey(config, requestedProgramId) {
1645
+ return `${config.apiBaseUrl}\n${normalizeTag(requestedProgramId)}`;
1646
+ }
1647
+ function cloneProgramFallbackForRequest(fallback, requestedProgramId, fromCache) {
1648
+ const warnings = fromCache
1649
+ ? uniqueStrings([...fallback.warnings, `Reused cached program_id resolution for ${requestedProgramId}.`])
1650
+ : [...fallback.warnings];
1651
+ const cloned = {
1652
+ ...fallback,
1653
+ requestedProgramId,
1654
+ warnings,
1655
+ sourceRequests: [...fallback.sourceRequests],
1656
+ resolution: { ...fallback.resolution }
1657
+ };
1658
+ if (isProgramIndexFallback(fallback)) {
1659
+ const indexClone = {
1660
+ ...cloned,
1661
+ program: { ...fallback.program }
1662
+ };
1663
+ return indexClone;
1664
+ }
1665
+ return cloned;
1666
+ }
1667
+ function isProgramIndexFallback(fallback) {
1668
+ return readObject(fallback.program) !== undefined;
1669
+ }
1670
+ function createProgramIndexFallbackFromStaleResolution(fallback, requestedProgramId, detailError) {
1671
+ const match = selectProgramIndexFallbackMatch(fallback.resolvedProgramId, fallback.resolution);
1672
+ if (!match) {
1673
+ return undefined;
1674
+ }
1675
+ const detailApiError = detailError instanceof BBRadarApiError ? detailError : undefined;
1676
+ const indexWarning = `program_id ${fallback.resolvedProgramId} exists in the BBRadar program index, but the per-program detail endpoint returned 404.`;
1677
+ return {
1678
+ ...fallback,
1679
+ requestedProgramId,
1680
+ staleRequestId: fallback.staleRequestId ?? detailApiError?.requestId,
1681
+ staleUpstreamRequestId: fallback.staleUpstreamRequestId ?? detailApiError?.upstreamRequestId,
1682
+ warnings: uniqueStrings([...fallback.warnings, indexWarning]),
1683
+ program: match.program
1684
+ };
1685
+ }
1552
1686
  async function resolveIndexedProgramId(client, config, requestedProgramId, staleError) {
1553
1687
  const query = programSearchText(requestedProgramId);
1554
1688
  if (query.length < 2) {
@@ -2827,7 +2961,7 @@ async function getProgramTargetsPayload(client, input, programId) {
2827
2961
  });
2828
2962
  }
2829
2963
  async function getProgramTargetsExportFallbackPayload(client, input, programId) {
2830
- const exportLookup = await fetchProgramTargetsFromExportFallback(client, programId);
2964
+ const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, programId);
2831
2965
  const sanitizedTargets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, input));
2832
2966
  const targets = sanitizedTargets.slice(input.offset, input.offset + input.limit);
2833
2967
  return stripUndefined({
@@ -2853,6 +2987,123 @@ async function getProgramTargetsExportFallbackPayload(client, input, programId)
2853
2987
  })
2854
2988
  });
2855
2989
  }
2990
+ async function fetchProgramTargetsWithEndpointFallback(client, programId) {
2991
+ const cachedLookup = getCachedProgramTargetsFallback(client, programId);
2992
+ if (cachedLookup) {
2993
+ return cachedLookup;
2994
+ }
2995
+ try {
2996
+ const api = await client.getProgramTargets(programId);
2997
+ const data = readObject(api.data);
2998
+ const rawTargets = readArray(data?.targets).map(sanitizeTarget);
2999
+ return {
3000
+ requestId: api.requestId,
3001
+ upstreamRequestId: api.upstreamRequestId,
3002
+ fetchedAt: api.fetchedAt,
3003
+ cache: readObject(stripUndefined({
3004
+ hit: api.cached,
3005
+ coalesced_live_request: api.coalesced,
3006
+ expires_at: api.cacheExpiresAt
3007
+ })),
3008
+ source: "program_targets",
3009
+ rawTargets,
3010
+ sourceRequests: [
3011
+ {
3012
+ source: "program_targets",
3013
+ program_id: programId,
3014
+ request_id: api.requestId,
3015
+ upstream_request_id: api.upstreamRequestId,
3016
+ ...apiSourceMetadata(api)
3017
+ }
3018
+ ],
3019
+ warnings: [],
3020
+ unavailable: false
3021
+ };
3022
+ }
3023
+ catch (error) {
3024
+ if (!isProgramNotFoundError(error)) {
3025
+ throw error;
3026
+ }
3027
+ const lookup = await fetchProgramTargetsFromExportFallback(client, programId);
3028
+ rememberProgramTargetsFallback(client, programId, lookup);
3029
+ return lookup;
3030
+ }
3031
+ }
3032
+ function getCachedProgramTargetsFallback(client, programId) {
3033
+ const diagnostics = client.diagnostics();
3034
+ if (!diagnostics.response_cache.enabled) {
3035
+ return undefined;
3036
+ }
3037
+ const cache = programTargetsFallbackCaches.get(client);
3038
+ const key = programTargetsFallbackCacheKey(programId);
3039
+ const cached = cache?.get(key);
3040
+ if (!cached) {
3041
+ return undefined;
3042
+ }
3043
+ if (cached.expiresAt <= Date.now()) {
3044
+ cache?.delete(key);
3045
+ return undefined;
3046
+ }
3047
+ return {
3048
+ ...cached.lookup,
3049
+ cache: readObject(stripUndefined({
3050
+ ...(cached.lookup.cache ?? {}),
3051
+ hit: true,
3052
+ expires_at: new Date(cached.expiresAt).toISOString()
3053
+ })),
3054
+ sourceRequests: cached.lookup.sourceRequests.map((request) => ({
3055
+ ...request,
3056
+ cache_hit: true,
3057
+ cache_expires_at: new Date(cached.expiresAt).toISOString()
3058
+ })),
3059
+ warnings: uniqueStrings([...cached.lookup.warnings, `Reused cached target fallback for ${programId}.`])
3060
+ };
3061
+ }
3062
+ function rememberProgramTargetsFallback(client, programId, lookup) {
3063
+ const diagnostics = client.diagnostics();
3064
+ if (!diagnostics.response_cache.enabled || lookup.unavailable) {
3065
+ return;
3066
+ }
3067
+ const cache = programTargetsFallbackCacheForClient(client);
3068
+ pruneProgramTargetsFallbackCache(cache);
3069
+ const ttlMs = cacheTtlMsFromDiagnostics(diagnostics.response_cache);
3070
+ if (ttlMs <= 0) {
3071
+ return;
3072
+ }
3073
+ cache.set(programTargetsFallbackCacheKey(programId), {
3074
+ expiresAt: Date.now() + ttlMs,
3075
+ lookup
3076
+ });
3077
+ if (cache.size > PROGRAM_FALLBACK_CACHE_MAX_ENTRIES) {
3078
+ const oldestKey = cache.keys().next().value;
3079
+ if (oldestKey) {
3080
+ cache.delete(oldestKey);
3081
+ }
3082
+ }
3083
+ }
3084
+ function programTargetsFallbackCacheForClient(client) {
3085
+ const existing = programTargetsFallbackCaches.get(client);
3086
+ if (existing) {
3087
+ return existing;
3088
+ }
3089
+ const cache = new Map();
3090
+ programTargetsFallbackCaches.set(client, cache);
3091
+ return cache;
3092
+ }
3093
+ function pruneProgramTargetsFallbackCache(cache) {
3094
+ const now = Date.now();
3095
+ for (const [key, cached] of cache) {
3096
+ if (cached.expiresAt <= now) {
3097
+ cache.delete(key);
3098
+ }
3099
+ }
3100
+ }
3101
+ function programTargetsFallbackCacheKey(programId) {
3102
+ return normalizeTag(programId);
3103
+ }
3104
+ function cacheTtlMsFromDiagnostics(cache) {
3105
+ return cache.enabled ? cache.ttl_ms : 0;
3106
+ }
2856
3107
  async function fetchProgramTargetsFromExportFallback(client, programId) {
2857
3108
  try {
2858
3109
  const api = await client.exportTargets({
@@ -2908,6 +3159,116 @@ async function fetchProgramTargetsFromExportFallback(client, programId) {
2908
3159
  };
2909
3160
  }
2910
3161
  }
3162
+ async function exportTargetsFromProgramTargetsFallback(client, config, exportStore, input, exportError) {
3163
+ const warnings = ["Target export endpoint is unavailable; assembled a fallback export from per-program target endpoints."];
3164
+ const sourceRequests = [
3165
+ {
3166
+ source: "target_export",
3167
+ failed: true,
3168
+ request_id: exportError.requestId,
3169
+ upstream_request_id: exportError.upstreamRequestId,
3170
+ status: exportError.status
3171
+ }
3172
+ ];
3173
+ const programIds = input.program_ids.length > 0 ? input.program_ids : await discoverExportProgramIds(client, input, sourceRequests, warnings);
3174
+ const targets = [];
3175
+ const errors = [];
3176
+ for (const programId of programIds) {
3177
+ if (targets.length >= input.limit) {
3178
+ break;
3179
+ }
3180
+ try {
3181
+ const payload = await runProgramIdTool(client, config, programId, (resolvedProgramId) => getProgramTargetsPayload(client, {
3182
+ include_out_of_scope: input.include_out_of_scope,
3183
+ include_ineligible: input.include_ineligible,
3184
+ strict_scope_filter: false,
3185
+ offset: 0,
3186
+ limit: input.limit - targets.length,
3187
+ output_mode: "full"
3188
+ }, resolvedProgramId), (indexFallback) => getProgramTargetsExportFallbackPayload(client, {
3189
+ include_out_of_scope: input.include_out_of_scope,
3190
+ include_ineligible: input.include_ineligible,
3191
+ strict_scope_filter: false,
3192
+ offset: 0,
3193
+ limit: input.limit - targets.length,
3194
+ output_mode: "full"
3195
+ }, indexFallback.resolvedProgramId));
3196
+ const resolvedProgramId = stringField(payload, "program_id") ?? programId;
3197
+ sourceRequests.push(...readArray(payload.source_requests).filter((request) => readObject(request) !== undefined));
3198
+ warnings.push(...readArray(payload.warnings).filter((warning) => typeof warning === "string"));
3199
+ for (const target of readArray(payload.targets)) {
3200
+ const object = readObject(target);
3201
+ if (!object) {
3202
+ continue;
3203
+ }
3204
+ targets.push({ program_id: resolvedProgramId, ...object });
3205
+ if (targets.length >= input.limit) {
3206
+ break;
3207
+ }
3208
+ }
3209
+ }
3210
+ catch (error) {
3211
+ errors.push(apiOperationError(programId, error));
3212
+ }
3213
+ }
3214
+ if (input.format === "csv") {
3215
+ warnings.push("Fallback target export returns JSON even when CSV was requested.");
3216
+ }
3217
+ const exportPayload = stripUndefined({
3218
+ targets,
3219
+ meta: {
3220
+ export_source: "program_targets_fallback",
3221
+ requested_program_count: programIds.length,
3222
+ returned: targets.length,
3223
+ limit: input.limit,
3224
+ format: "json"
3225
+ },
3226
+ generated_at: new Date().toISOString()
3227
+ });
3228
+ const exportId = randomUUID();
3229
+ const resourceUri = exportResourceUri(exportId);
3230
+ rememberExport(exportStore, exportId, exportPayload);
3231
+ return stripUndefined({
3232
+ request_id: randomUUID(),
3233
+ source_requests: sourceRequests,
3234
+ warnings: uniqueStrings(warnings),
3235
+ errors: errors.length > 0 ? errors : undefined,
3236
+ export: {
3237
+ export_id: exportId,
3238
+ resource_uri: resourceUri,
3239
+ preview: previewExportPayload(exportPayload),
3240
+ returned_preview_count: previewExportCount(exportPayload),
3241
+ limit: input.limit
3242
+ },
3243
+ meta: stripUndefined({
3244
+ export_source: "program_targets_fallback",
3245
+ requested_program_count: programIds.length,
3246
+ returned: targets.length,
3247
+ failed_program_count: errors.length,
3248
+ fallback_error: apiOperationError("target_export", exportError)
3249
+ })
3250
+ });
3251
+ }
3252
+ async function discoverExportProgramIds(client, input, sourceRequests, warnings) {
3253
+ const budget = {
3254
+ initial: 1,
3255
+ remaining: 1
3256
+ };
3257
+ const collected = await collectPrograms(client, {
3258
+ platforms: input.platforms,
3259
+ tags: input.tags,
3260
+ updated_since: undefined,
3261
+ opportunity_levels: input.opportunity_levels,
3262
+ max_pages: 1,
3263
+ budget,
3264
+ warnings
3265
+ });
3266
+ sourceRequests.push(...collected.sourceRequests);
3267
+ return uniqueStrings(collected.programs
3268
+ .map((program) => stringField(sanitizeProgram(program, ""), "id"))
3269
+ .filter((programId) => programId !== undefined)
3270
+ .slice(0, input.limit));
3271
+ }
2911
3272
  function readExportTargetRows(data) {
2912
3273
  if (Array.isArray(data)) {
2913
3274
  return data;
@@ -2958,7 +3319,7 @@ async function getProgramScopeSummary(client, config, input) {
2958
3319
  };
2959
3320
  }
2960
3321
  async function getProgramScopeSummaryFromIndexFallback(client, indexFallback, input) {
2961
- const exportLookup = await fetchProgramTargetsFromExportFallback(client, input.program_id);
3322
+ const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, input.program_id);
2962
3323
  const targets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, {
2963
3324
  include_out_of_scope: input.include_out_of_scope,
2964
3325
  include_ineligible: input.include_ineligible,
@@ -3031,7 +3392,7 @@ async function getProgramTargetBreakdown(client, config, input) {
3031
3392
  };
3032
3393
  }
3033
3394
  async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback, input) {
3034
- const exportLookup = await fetchProgramTargetsFromExportFallback(client, input.program_id);
3395
+ const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, input.program_id);
3035
3396
  const targets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, {
3036
3397
  include_out_of_scope: input.include_out_of_scope,
3037
3398
  include_ineligible: input.include_ineligible,
@@ -3069,19 +3430,8 @@ async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback,
3069
3430
  async function getProgramScopeDelta(client, config, input) {
3070
3431
  const programApi = await client.getProgram(input.program_id);
3071
3432
  const program = addProgramResourceLinks(sanitizeProgram(programApi.data, config.webBaseUrl));
3072
- const changesPayload = await fetchProgramChanges(client, config, input.program_id, input.page_size, input.include_removed || input.change_type === "removed", input.include_ineligible, input.include_out_of_scope);
3073
- const sinceTimestamp = input.since ? Date.parse(input.since) : undefined;
3074
- const filteredChanges = changesPayload.changes
3075
- .filter((change) => !input.change_type || stringField(change, "change_type") === input.change_type)
3076
- .filter((change) => sinceTimestamp === undefined || timestampField(change, "changed_at") >= sinceTimestamp)
3077
- .filter((change) => {
3078
- const target = readObject(change.target);
3079
- return !input.target_type || (target !== undefined && targetMatchesRequestedType(target, input.target_type));
3080
- })
3081
- .filter((change) => {
3082
- const target = readObject(change.target);
3083
- return input.language_tags.length === 0 || (target !== undefined && targetMatchesAnySignal(target, input.language_tags));
3084
- });
3433
+ const changesPayload = await fetchProgramChanges(client, config, input.program_id, input.page_size, input.include_removed || input.change_type === "removed", input.include_ineligible, input.include_out_of_scope, input.max_targets);
3434
+ const filteredChanges = filterProgramScopeDeltaChanges(changesPayload.changes, input);
3085
3435
  const limitedChanges = filteredChanges.slice(0, input.max_targets);
3086
3436
  return stripUndefined({
3087
3437
  request_id: randomUUID(),
@@ -3106,10 +3456,55 @@ async function getProgramScopeDelta(client, config, input) {
3106
3456
  change_type: input.change_type,
3107
3457
  target_type: input.target_type,
3108
3458
  language_tags: input.language_tags.length > 0 ? input.language_tags : undefined,
3109
- target_list_mode: input.target_list_mode
3459
+ target_list_mode: input.target_list_mode,
3460
+ upstream_total_pages: readNumber(changesPayload.meta?.total_pages),
3461
+ pages_fetched: readNumber(changesPayload.meta?.pages_fetched),
3462
+ scan_may_be_incomplete: (readNumber(changesPayload.meta?.total_pages) ?? 1) > (readNumber(changesPayload.meta?.pages_fetched) ?? 1)
3110
3463
  })
3111
3464
  });
3112
3465
  }
3466
+ async function getProgramScopeDeltaFromIndexedProgram(client, config, input, indexedProgram) {
3467
+ const program = addProgramResourceLinks(indexedProgram);
3468
+ const changesPayload = await fetchProgramChanges(client, config, input.program_id, input.page_size, input.include_removed || input.change_type === "removed", input.include_ineligible, input.include_out_of_scope, input.max_targets);
3469
+ const filteredChanges = filterProgramScopeDeltaChanges(changesPayload.changes, input);
3470
+ const limitedChanges = filteredChanges.slice(0, input.max_targets);
3471
+ return stripUndefined({
3472
+ request_id: randomUUID(),
3473
+ source_requests: changesPayload.sourceRequests,
3474
+ program: formatProgram(program, "compact"),
3475
+ delta: buildScopeDelta(limitedChanges, input.target_list_mode),
3476
+ changes: limitedChanges.map((change) => formatChange(change, "compact")),
3477
+ meta: stripUndefined({
3478
+ recent_changes_scanned: changesPayload.changes.length,
3479
+ changes_after_filters: filteredChanges.length,
3480
+ returned: limitedChanges.length,
3481
+ has_more: filteredChanges.length > limitedChanges.length,
3482
+ since: input.since,
3483
+ change_type: input.change_type,
3484
+ target_type: input.target_type,
3485
+ language_tags: input.language_tags.length > 0 ? input.language_tags : undefined,
3486
+ target_list_mode: input.target_list_mode,
3487
+ program_detail_source: "program_index",
3488
+ upstream_total_pages: readNumber(changesPayload.meta?.total_pages),
3489
+ pages_fetched: readNumber(changesPayload.meta?.pages_fetched),
3490
+ scan_may_be_incomplete: (readNumber(changesPayload.meta?.total_pages) ?? 1) > (readNumber(changesPayload.meta?.pages_fetched) ?? 1)
3491
+ })
3492
+ });
3493
+ }
3494
+ function filterProgramScopeDeltaChanges(changes, input) {
3495
+ const sinceTimestamp = input.since ? Date.parse(input.since) : undefined;
3496
+ return changes
3497
+ .filter((change) => !input.change_type || stringField(change, "change_type") === input.change_type)
3498
+ .filter((change) => sinceTimestamp === undefined || timestampField(change, "changed_at") >= sinceTimestamp)
3499
+ .filter((change) => {
3500
+ const target = readObject(change.target);
3501
+ return !input.target_type || (target !== undefined && targetMatchesRequestedType(target, input.target_type));
3502
+ })
3503
+ .filter((change) => {
3504
+ const target = readObject(change.target);
3505
+ return input.language_tags.length === 0 || (target !== undefined && targetMatchesAnySignal(target, input.language_tags));
3506
+ });
3507
+ }
3113
3508
  async function getRecentTargetActivity(client, config, input) {
3114
3509
  const query = toQuery({
3115
3510
  change_type: input.change_type,
@@ -3517,32 +3912,45 @@ async function getProgramDelta(client, config, input) {
3517
3912
  meta: changes.meta
3518
3913
  };
3519
3914
  }
3520
- async function fetchProgramChanges(client, config, programId, pageSize, includeRemoved, includeIneligible, includeOutOfScope) {
3915
+ async function fetchProgramChanges(client, config, programId, pageSize, includeRemoved, includeIneligible, includeOutOfScope, maxChanges = pageSize) {
3521
3916
  const handle = programSearchText(programId);
3522
- const api = await client.getRecentChanges({
3523
- search: handle.length >= 2 ? handle : programId,
3524
- include_removed: includeRemoved,
3525
- include_ineligible: includeIneligible,
3526
- include_out_of_scope: includeOutOfScope,
3527
- page: 1,
3528
- page_size: pageSize
3529
- });
3530
- const data = readObject(api.data);
3531
- const changes = readArray(data?.results)
3532
- .map((change) => sanitizeChange(change, config.webBaseUrl))
3533
- .filter((change) => stringField(readObject(change.program), "id") === programId);
3534
- return {
3535
- changes,
3536
- meta: sanitizeJson(data?.meta),
3537
- sourceRequests: [
3538
- {
3539
- source: "recent_changes",
3540
- request_id: api.requestId,
3541
- upstream_request_id: api.upstreamRequestId,
3542
- ...apiSourceMetadata(api)
3543
- }
3544
- ]
3545
- };
3917
+ const changes = [];
3918
+ const sourceRequests = [];
3919
+ let meta;
3920
+ const maxPages = Math.max(1, Math.ceil(maxChanges / pageSize));
3921
+ for (let page = 1; page <= maxPages && changes.length < maxChanges; page += 1) {
3922
+ const api = await client.getRecentChanges({
3923
+ search: handle.length >= 2 ? handle : programId,
3924
+ include_removed: includeRemoved,
3925
+ include_ineligible: includeIneligible,
3926
+ include_out_of_scope: includeOutOfScope,
3927
+ page,
3928
+ page_size: pageSize
3929
+ });
3930
+ const data = readObject(api.data);
3931
+ const pageMeta = readObject(sanitizeJson(data?.meta));
3932
+ const rows = readArray(data?.results);
3933
+ meta = {
3934
+ ...(meta ?? {}),
3935
+ ...(pageMeta ?? {}),
3936
+ pages_fetched: page
3937
+ };
3938
+ sourceRequests.push({
3939
+ source: "recent_changes",
3940
+ request_id: api.requestId,
3941
+ upstream_request_id: api.upstreamRequestId,
3942
+ page,
3943
+ ...apiSourceMetadata(api)
3944
+ });
3945
+ changes.push(...rows
3946
+ .map((change) => sanitizeChange(change, config.webBaseUrl))
3947
+ .filter((change) => stringField(readObject(change.program), "id") === programId));
3948
+ const totalPages = readNumber(pageMeta?.total_pages);
3949
+ if (rows.length === 0 || (totalPages !== undefined && page >= totalPages)) {
3950
+ break;
3951
+ }
3952
+ }
3953
+ return { changes: changes.slice(0, maxChanges), meta, sourceRequests };
3546
3954
  }
3547
3955
  function addProgramResourceLinks(program) {
3548
3956
  const id = stringField(program, "id");
@@ -4136,6 +4544,23 @@ function programFetchError(programId, error) {
4136
4544
  suggested_fix: "Retry the program lookup or verify the BBRadar program id."
4137
4545
  };
4138
4546
  }
4547
+ function apiOperationError(operation, error) {
4548
+ if (error instanceof BBRadarApiError) {
4549
+ return stripUndefined({
4550
+ operation,
4551
+ request_id: error.requestId,
4552
+ upstream_request_id: error.upstreamRequestId,
4553
+ status: error.status,
4554
+ message: error.message,
4555
+ detail: sanitizeJson(error.detail),
4556
+ errors: sanitizeJson(error.errors)
4557
+ });
4558
+ }
4559
+ return {
4560
+ operation,
4561
+ message: error instanceof Error ? error.message : String(error)
4562
+ };
4563
+ }
4139
4564
  function programSearchText(programId) {
4140
4565
  const colonIndex = programId.indexOf(":");
4141
4566
  const handle = colonIndex >= 0 ? programId.slice(colonIndex + 1) : programId;
@@ -4999,6 +5424,21 @@ function toRateLimitPayload(decision) {
4999
5424
  function rateLimitForTool(toolName, config) {
5000
5425
  return toolName === "export_targets" ? config.exportRateLimitPerMinute : config.defaultRateLimitPerMinute;
5001
5426
  }
5427
+ function normalizeStringListInput(value) {
5428
+ if (typeof value === "string") {
5429
+ return splitFilterList(value);
5430
+ }
5431
+ if (Array.isArray(value)) {
5432
+ return value.flatMap((entry) => (typeof entry === "string" ? splitFilterList(entry) : [entry]));
5433
+ }
5434
+ return value;
5435
+ }
5436
+ function splitFilterList(value) {
5437
+ return value
5438
+ .split(",")
5439
+ .map((entry) => entry.trim())
5440
+ .filter((entry) => entry.length > 0);
5441
+ }
5002
5442
  function toQuery(values) {
5003
5443
  return compactRecord(values);
5004
5444
  }