@bbradar/mcp 0.2.3 → 0.2.6

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
@@ -5,8 +5,8 @@ import { BBRadarApiError } from "./bbradarClient.js";
5
5
  import { NumericCursorStore } from "./cursorStore.js";
6
6
  import { stableStringify, stripUndefined } from "./json.js";
7
7
  import { FixedWindowRateLimiter } from "./rateLimit.js";
8
- import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
9
- const SERVER_VERSION = "0.2.3";
8
+ import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeProPayload, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
9
+ const SERVER_VERSION = "0.2.6";
10
10
  const MAX_PROGRAMS_PER_SEARCH = 100;
11
11
  const MAX_TARGET_EXPORT = 500;
12
12
  const MAX_RECENT_CHANGES = 100;
@@ -27,6 +27,7 @@ const MAX_HUNT_TARGETS_PER_RESULT = 10;
27
27
  const MAX_BULK_PROGRAM_RESOURCE_LINKS = 5;
28
28
  const DEFAULT_RESOLVE_UPSTREAM_REQUEST_BUDGET = 12;
29
29
  const MAX_ACCEPTED_NUMERIC_LIMIT = 1_000;
30
+ const MAX_UPSTREAM_PAGE = 500;
30
31
  const PROGRAM_COLLECTION_CONCURRENCY = 4;
31
32
  const TARGET_SAMPLE_CONCURRENCY = 3;
32
33
  const DEFAULT_FILTER_DISCOVERY_PAGES = 2;
@@ -63,9 +64,17 @@ const CORE_TOOL_NAMES = new Set([
63
64
  "list_filters",
64
65
  "get_bbradar_guide",
65
66
  "get_mcp_status",
66
- "export_targets"
67
+ "export_targets",
68
+ "get_history_changes",
69
+ "get_history_change_detail",
70
+ "get_program_history",
71
+ "get_program_activity",
72
+ "get_analytics_platform_series",
73
+ "get_analytics_program_activity",
74
+ "get_analytics_program_detail"
67
75
  ]);
68
76
  const TOOL_ENDPOINT_REQUIREMENTS = {
77
+ search_targets: "target_search",
69
78
  get_repository_changes: "repository_changes",
70
79
  find_repository_hunt_targets: "repository_changes",
71
80
  export_targets: "target_export",
@@ -95,7 +104,6 @@ const PROGRAM_ID_PLATFORM_GUESSES = [
95
104
  ];
96
105
  const PRIMARY_PROGRAM_ID_PLATFORM_GUESSES = PROGRAM_ID_PLATFORM_GUESSES.slice(0, 8);
97
106
  const WEB3_TAGS = ["web3", "crypto", "blockchain", "smart-contract", "smart contract", "defi", "nft", "ethereum", "solana", "solidity"];
98
- const WEB3_CONTEST_SIGNALS = ["contest", "audit", "competitive", "code4rena", "sherlock", "cantina", "codehawks", "hats", "spearbit", "warden"];
99
107
  const VDP_SIGNALS = ["vdp", "disclosure", "responsible disclosure", "vulnerability disclosure", "no bounty", "non-bounty", "no reward"];
100
108
  const PROGRAM_RESOLUTION_STOP_WORDS = new Set([
101
109
  "program",
@@ -183,6 +191,8 @@ const vdpModeSchema = z.enum(["include", "exclude", "only_likely", "only_known_n
183
191
  const targetMatchModeSchema = z
184
192
  .enum(["smart", "contains", "exact", "suffix", "wildcard"])
185
193
  .describe("How to match target_query. smart is forgiving for URLs, wildcards, target names, and partial words.");
194
+ const apiTargetMatchModeSchema = z.enum(["contains", "exact", "suffix", "wildcard"]);
195
+ const targetChangeTypeSchema = z.enum(["added", "readded", "updated", "removed"]);
186
196
  const programNameActionSchema = z.enum(["new_targets", "targets", "scope_delta", "brief", "target_breakdown"]);
187
197
  const guideTopicSchema = z.enum(["start", "program", "discovery", "freshness", "targets", "compare", "export", "filters"]).default("start");
188
198
  const readOnlyAnnotations = {
@@ -241,6 +251,18 @@ const targetQuerySchema = z
241
251
  })
242
252
  .describe("Target identifier, domain, URL, wildcard, app/package/repo name, or partial target text. URLs and wildcards are normalized automatically.");
243
253
  const isoDateTimeSchema = z.string().datetime({ offset: true });
254
+ const stableSnapshotSchema = z
255
+ .string()
256
+ .trim()
257
+ .min(1)
258
+ .max(1_024)
259
+ .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
260
+ message: "Snapshot tokens cannot contain control characters."
261
+ })
262
+ .describe("Optional stable-page token. Send 'start' on page 1, then reuse meta.snapshot with identical filters. Snapshot creation is consistency-first and can be slower; omit it for the fastest live result.");
263
+ const opaqueCursorSchema = z.string().min(1).max(4096).describe("Opaque five-minute cursor. Reuse with identical filters and page size; restart the query on expiry.");
264
+ const analyticsDateSchema = () => z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use an inclusive UTC YYYY-MM-DD date.");
265
+ const proLimitSchema = z.number().int().min(1).max(100);
244
266
  const pageSchema = z.number().int().min(1).default(1);
245
267
  const programsPageSizeSchema = z.number().int().min(1).max(MAX_PROGRAMS_PER_SEARCH).default(20);
246
268
  const recentChangesPageSizeSchema = z.number().int().min(1).max(MAX_RECENT_CHANGES).default(50);
@@ -265,7 +287,7 @@ const programIdSchema = z
265
287
  .string()
266
288
  .trim()
267
289
  .min(3)
268
- .max(256)
290
+ .max(620)
269
291
  .regex(/^[^:/?#\u0000-\u001F\u007F]+:[^?#\u0000-\u001F\u007F]+$/, "Use platform:handle, for example HackerOne:example-handle.")
270
292
  .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
271
293
  message: "program_id cannot contain control characters."
@@ -300,6 +322,7 @@ function cacheOutputSchema() {
300
322
  hit: z.boolean(),
301
323
  stale_while_revalidate: z.boolean().optional(),
302
324
  coalesced_live_request: z.boolean().optional(),
325
+ no_store: z.boolean().optional(),
303
326
  expires_at: z.string().optional()
304
327
  });
305
328
  }
@@ -325,6 +348,7 @@ function apiEnvelopeOutputShape() {
325
348
  mcp_timing: toolTimingOutputSchema().optional(),
326
349
  warnings: z.array(z.string()).optional(),
327
350
  program_id_resolution: jsonRecordSchema().optional(),
351
+ recovery: jsonRecordSchema().optional(),
328
352
  error: errorOutputSchema().optional()
329
353
  };
330
354
  }
@@ -387,7 +411,11 @@ const programCandidatePresetInputSchema = {
387
411
  };
388
412
  function createProfiledToolRegistrar(server, profile, client) {
389
413
  return (name, toolConfig, callback) => {
390
- const registeredTool = server.registerTool(name, toolConfig, callback);
414
+ const scopedCallback = ((...args) => {
415
+ const extra = args[args.length - 1];
416
+ return client.withRequestContext(extra.signal, () => callback(...args), readObject(args[0])?.snapshot === "start");
417
+ });
418
+ const registeredTool = server.registerTool(name, toolConfig, scopedCallback);
391
419
  const requiredEndpoint = TOOL_ENDPOINT_REQUIREMENTS[name];
392
420
  if ((profile === "core" && !CORE_TOOL_NAMES.has(name)) ||
393
421
  (requiredEndpoint !== undefined && !client.supportsEndpoint(requiredEndpoint))) {
@@ -404,6 +432,7 @@ export function createBbradarServer(client, config) {
404
432
  "Use resolve_program or run_program_name_action for a named program.",
405
433
  "Use find_hunt_targets once for requests combining good rewards, broad scope, target quality, and recent target additions. Use find_repository_hunt_targets instead when Git or repository activity is explicitly required.",
406
434
  "Use find_programs for lower-level program-only discovery and find_programs_by_target for reverse target lookup.",
435
+ "For semantic changes use get_history_changes or get_program_history, then get_history_change_detail only when before/after items are needed. For exact activity use get_program_activity; for ranked activity use get_analytics_program_activity. Use daily analytics tools directly for date series.",
407
436
  "Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
408
437
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
409
438
  ]
@@ -419,6 +448,7 @@ export function createBbradarServer(client, config) {
419
448
  "Self-Hosted programs use Self-Hosted:slug ids and are available through program search, exact detail, and target tools. Respect data_capabilities; do not infer missing opportunity, report-count, target-change, submission-cost, or repository signals.",
420
449
  "Freshness: find_recently_updated_programs, get_latest_added_targets, check_program_new_targets, check_watchlist_new_targets, find_recent_by_type, get_program_scope_delta.",
421
450
  "Repository activity: get_repository_changes defaults to compact program-only events. Seed with after_id, then resume the in-memory per-filter cursor.",
451
+ "Semantic History: get_history_changes globally or get_program_history for one exact ID, especially Self-Hosted. Fetch detail lazily with get_history_change_detail. Exact recorded signals: get_program_activity. Server-side rankings: get_analytics_program_activity. Daily series: get_analytics_platform_series or get_analytics_program_detail. Reuse opaque cursors only with unchanged filters and page size.",
422
452
  "Discovery: find_hunt_targets is the one-call default; find_program_candidates, find_wildcard_programs, find_recently_added_wildcards, find_low_competition_high_reward, find_stack_matches, find_language_programs, find_target_type_programs, find_reward_programs, find_paid_programs, find_vdp_programs, find_web3_contests, and find_low_noise_programs are lower-level alternatives.",
423
453
  "Details: get_program_brief, get_program_scope_summary, get_program_target_breakdown, find_programs_by_target, compare_programs_compact.",
424
454
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
@@ -436,6 +466,7 @@ export function createBbradarServer(client, config) {
436
466
  const feedCursorStore = new NumericCursorStore(config.cursorStatePath, MAX_IN_MEMORY_FEED_CURSORS);
437
467
  const maxProgramsPageSize = client.endpointNumber("programs", "max_page_size", MAX_PROGRAMS_PER_SEARCH);
438
468
  const maxProgramTargetsPageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
469
+ const maxTargetSearchPageSize = client.endpointNumber("target_search", "max_page_size", MAX_TARGETS_PER_PROGRAM);
439
470
  const maxTargetChangesPageSize = client.endpointNumber("target_changes", "max_page_size", MAX_RECENT_CHANGES);
440
471
  const maxRepositoryChangesPageSize = client.endpointNumber("repository_changes", "max_page_size", MAX_RECENT_CHANGES);
441
472
  const maxTargetExport = client.endpointNumber("target_export", "max_targets", MAX_TARGET_EXPORT);
@@ -444,14 +475,93 @@ export function createBbradarServer(client, config) {
444
475
  const recentChangesPageSizeSchema = z.number().int().min(1).max(maxTargetChangesPageSize).default(Math.min(50, maxTargetChangesPageSize));
445
476
  const repositoryChangesPageSizeSchema = z.number().int().min(1).max(maxRepositoryChangesPageSize).default(Math.min(50, maxRepositoryChangesPageSize));
446
477
  const acceptedTargetLimitSchema = z.number().int().min(1).max(maxProgramTargetsPageSize);
478
+ const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
479
+ const historyOutput = { ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() };
480
+ const analyticsOutput = { ...apiEnvelopeOutputShape(), platform: z.string().optional(), program: jsonRecordSchema().optional(), programs: z.array(jsonRecordSchema()).optional(), series: z.array(jsonRecordSchema()).optional(), observations: jsonRecordSchema().optional(), category: z.string().optional(), population: z.string().optional(), ranking_definition: z.string().optional(), inventory_definition: z.string().optional(), meta: jsonRecordSchema().optional() };
481
+ registerTool("get_history_changes", {
482
+ title: "Get Program History Changes",
483
+ description: "One page of semantic program History. Use get_program_history for an exact program, especially Self-Hosted. History may be partial or unavailable; inspect coverage.",
484
+ inputSchema: {
485
+ cursor: opaqueCursorSchema.optional(), days: z.number().int().min(0).max(3650).default(90),
486
+ platform: z.string().min(1).max(100).optional(), program_id: programIdSchema.optional(),
487
+ category: z.enum(["scope", "rewards", "rules", "program", "repositories"]).optional(),
488
+ search: z.string().min(1).max(120).optional(), limit: proLimitSchema.default(50)
489
+ }, outputSchema: historyOutput, annotations: readOnlyAnnotations
490
+ }, (args) => runTool("get_history_changes", config, rateLimiter, async () => {
491
+ assertProFeature(client, "history_changes", "history_capture_enabled");
492
+ if (args.days === 0 && !args.program_id)
493
+ throw new Error("days=0 requires program_id; use get_program_history for Self-Hosted catalog IDs.");
494
+ const api = await client.getHistoryChanges(toQuery(args));
495
+ return proPage(api, "changes");
496
+ }));
497
+ registerTool("get_history_change_detail", {
498
+ title: "Get History Change Detail",
499
+ description: "Fetch one semantic History change's paginated before/after items only when needed.",
500
+ inputSchema: { change_id: z.string().min(1).max(128), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) },
501
+ outputSchema: { ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
502
+ }, (args) => runTool("get_history_change_detail", config, rateLimiter, async () => {
503
+ assertProFeature(client, "history_change_detail");
504
+ assertHistoryCapture(client);
505
+ return proPage(await client.getHistoryChangeDetail(args.change_id, toQuery({ cursor: args.cursor, limit: args.limit })), "items");
506
+ }));
507
+ registerTool("get_program_history", {
508
+ title: "Get Program History",
509
+ description: "One page of exact semantic History for a platform:handle or Self-Hosted:slug catalog ID. Preserve archive id and catalog_id separately.",
510
+ inputSchema: { program_id: programIdSchema, cursor: opaqueCursorSchema.optional(), days: z.number().int().min(0).max(3650).default(90), limit: proLimitSchema.default(50) },
511
+ outputSchema: historyOutput, annotations: readOnlyAnnotations
512
+ }, (args) => runTool("get_program_history", config, rateLimiter, async () => {
513
+ assertProFeature(client, "program_history");
514
+ assertHistoryCapture(client);
515
+ return proPage(await client.getProgramHistory(args.program_id, toQuery({ cursor: args.cursor, days: args.days, limit: args.limit })), "changes");
516
+ }));
517
+ registerTool("get_program_activity", {
518
+ title: "Get Exact Program Activity",
519
+ description: "Read the authoritative activity_summary for one exact program ID.",
520
+ inputSchema: { program_id: programIdSchema },
521
+ outputSchema: { ...apiEnvelopeOutputShape(), program_id: z.string().optional(), activity_summary: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
522
+ }, (args) => runTool("get_program_activity", config, rateLimiter, async () => {
523
+ assertProFeature(client, "program_activity");
524
+ return proPage(await client.getProgramActivity(args.program_id), "activity_summary");
525
+ }));
526
+ registerTool("get_analytics_platform_series", {
527
+ title: "Get Platform Daily Analytics",
528
+ description: "One page of inclusive UTC daily platform analytics; inspect generation, projection_pending, and tracking_since.",
529
+ inputSchema: { platform: z.string().min(1).max(100), start: analyticsDateSchema().optional(), end: analyticsDateSchema().optional(), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(100) },
530
+ outputSchema: analyticsOutput, annotations: readOnlyAnnotations
531
+ }, (args) => runTool("get_analytics_platform_series", config, rateLimiter, async () => {
532
+ assertProFeature(client, "analytics_platform_series", "enabled");
533
+ const { platform, ...query } = args;
534
+ return proPage(await client.getAnalyticsPlatformSeries(platform, toQuery(query)), "series");
535
+ }));
536
+ registerTool("get_analytics_program_activity", {
537
+ title: "Rank Programs by Recorded Activity",
538
+ description: "Server-side daily activity ranking. One page; semantic activity excludes baselines and corrections.",
539
+ inputSchema: { start: analyticsDateSchema().optional(), end: analyticsDateSchema().optional(), platform: z.string().min(1).max(100).optional(), category: z.enum(["all", "scope", "rewards", "rules", "schedule", "costs", "lifecycle"]).default("all"), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) },
540
+ outputSchema: analyticsOutput, annotations: readOnlyAnnotations
541
+ }, (args) => runTool("get_analytics_program_activity", config, rateLimiter, async () => {
542
+ assertProFeature(client, "analytics_program_activity", "enabled");
543
+ return proPage(await client.getAnalyticsProgramActivity(toQuery(args)), "programs");
544
+ }));
545
+ registerTool("get_analytics_program_detail", {
546
+ title: "Get Program Daily Analytics",
547
+ description: "One page of program daily metrics, carried inventory state, report observations, and source coverage.",
548
+ inputSchema: { program_id: programIdSchema, start: analyticsDateSchema().optional(), end: analyticsDateSchema().optional(), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(100) },
549
+ outputSchema: analyticsOutput, annotations: readOnlyAnnotations
550
+ }, (args) => runTool("get_analytics_program_detail", config, rateLimiter, async () => {
551
+ assertProFeature(client, "analytics_program_detail", "enabled");
552
+ const { program_id, ...query } = args;
553
+ return proPage(await client.getAnalyticsProgramDetail(program_id, toQuery(query)), "series");
554
+ }));
447
555
  registerTool("search_programs", {
448
556
  title: "Search BBRadar Programs",
449
- description: `Search or list active platform and Self-Hosted programs. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
557
+ description: `Search or list active and confirmed paused platform programs plus active Self-Hosted programs. page_size max ${maxProgramsPageSize}. Stable snapshots are opt-in because creating one can be slower than live indexed pagination.`,
450
558
  inputSchema: {
451
559
  search: searchTextSchema.optional().describe("Server-side program name, handle, platform, policy-host, or target search."),
560
+ program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
452
561
  platforms: platformListSchema(),
453
562
  tags: tagListSchema(),
454
563
  updated_since: isoDateTimeSchema.optional().describe("ISO datetime."),
564
+ snapshot: stableSnapshotSchema.optional(),
455
565
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
456
566
  page: pageSchema,
457
567
  page_size: programsPageSizeSchema
@@ -460,6 +570,7 @@ export function createBbradarServer(client, config) {
460
570
  annotations: readOnlyAnnotations
461
571
  }, (args) => runTool("search_programs", config, rateLimiter, async () => {
462
572
  const { vdp_mode, ...filters } = args;
573
+ assertStableSnapshotSupported(client, "programs", filters.snapshot);
463
574
  const api = await client.listPrograms(toQuery(filters));
464
575
  const data = readObject(api.data);
465
576
  const programs = readArray(data?.programs)
@@ -471,6 +582,47 @@ export function createBbradarServer(client, config) {
471
582
  meta: sanitizeJson(data?.meta)
472
583
  });
473
584
  }));
585
+ registerTool("search_targets", {
586
+ title: "Search BBRadar Targets",
587
+ description: `Low-level reverse target search and current target-inventory paging. q may be omitted for inventory traversal. page_size max ${maxTargetSearchPageSize}. Stable snapshots are opt-in and intentionally not used by find_hunt_targets.`,
588
+ inputSchema: {
589
+ q: targetQuerySchema.optional(),
590
+ match_mode: apiTargetMatchModeSchema.default("contains"),
591
+ snapshot: stableSnapshotSchema.optional(),
592
+ platforms: platformListSchema(),
593
+ tags: tagListSchema(),
594
+ include_out_of_scope: z.boolean().default(false),
595
+ include_ineligible: z.boolean().default(false),
596
+ page: pageSchema,
597
+ page_size: targetSearchPageSizeSchema,
598
+ output_mode: targetListModeSchema.default("compact")
599
+ },
600
+ outputSchema: {
601
+ ...apiEnvelopeOutputShape(),
602
+ results: z.array(jsonRecordSchema()).optional(),
603
+ meta: jsonRecordSchema().optional(),
604
+ continuation: jsonRecordSchema().optional()
605
+ },
606
+ annotations: readOnlyAnnotations
607
+ }, (args) => runTool("search_targets", config, rateLimiter, async () => {
608
+ const { output_mode, ...filters } = args;
609
+ assertStableSnapshotSupported(client, "target_search", filters.snapshot);
610
+ const api = await client.searchTargets(toQuery(filters));
611
+ const data = readObject(api.data);
612
+ const results = readArray(data?.results).map((value) => {
613
+ const row = readObject(value) ?? {};
614
+ const program = sanitizeProgram(row.program, config.webBaseUrl);
615
+ const target = sanitizeTarget(row.target);
616
+ return stripUndefined({
617
+ program: formatProgram(program, output_mode === "full" ? "full" : "compact"),
618
+ target: output_mode === "full" ? target : compactTarget(target)
619
+ });
620
+ });
621
+ return applyCollectionContinuation(withApiMetadata(api, {
622
+ results,
623
+ meta: sanitizeJson(data?.meta)
624
+ }), "results", "target search results", continuationStore, config);
625
+ }));
474
626
  registerTool("get_program", {
475
627
  title: "Get BBRadar Program",
476
628
  description: "Get one program by platform:handle program_id, handle, or human program name. Names are resolved automatically when unambiguous.",
@@ -629,7 +781,8 @@ export function createBbradarServer(client, config) {
629
781
  title: "Get Recent Target Changes",
630
782
  description: `List recent target changes. page_size max ${MAX_RECENT_CHANGES}.`,
631
783
  inputSchema: {
632
- change_type: z.enum(["added", "updated", "removed"]).optional(),
784
+ change_type: targetChangeTypeSchema.optional(),
785
+ snapshot: stableSnapshotSchema.optional(),
633
786
  after_id: z.number().int().min(0).optional().describe("Incremental cursor; returns changes after this id in ascending order."),
634
787
  resume_cursor: z.boolean().default(false).describe("Resume the in-memory cursor for this exact normalized filter set. Seed it once with after_id."),
635
788
  max_cursor_pages: z.number().int().min(1).max(10).default(2),
@@ -658,16 +811,24 @@ export function createBbradarServer(client, config) {
658
811
  annotations: readOnlyAnnotations
659
812
  }, (args) => runTool("get_recent_changes", config, rateLimiter, async () => {
660
813
  const { output_mode, resume_cursor, max_cursor_pages, cursor_time_budget_ms, ...filters } = args;
661
- const cursor = resolveFeedCursor(feedCursorStore, "target_changes", filters, resume_cursor);
662
- const { min_target_score, target_opportunity_label, competition_risk, ...upstreamFilters } = filters;
663
- const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, (afterId) => client.getRecentChanges(toQuery({
814
+ assertStableSnapshotSupported(client, "target_changes", filters.snapshot);
815
+ const cursor = resolveFeedCursor(feedCursorStore, "target_changes", filters, resume_cursor, config);
816
+ const { min_target_score, target_opportunity_label, competition_risk, snapshot, ...upstreamFilters } = filters;
817
+ const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, snapshot, filters.page, (afterId, recoveryProbe, activeSnapshot, pageNumber) => client.getRecentChanges(toQuery({
664
818
  ...upstreamFilters,
665
819
  minTargetScore: min_target_score,
666
820
  targetOpportunityLabel: target_opportunity_label,
667
821
  competitionRisk: competition_risk,
822
+ snapshot: activeSnapshot,
668
823
  after_id: afterId,
669
- page: afterId === undefined ? filters.page : 1
670
- })));
824
+ page: activeSnapshot ? pageNumber : afterId === undefined ? filters.page : 1
825
+ }), recoveryProbe ? { cache: false, coalesce: false } : {}), (startPage, recoveryProbe) => refreshInventoryBaseline((page, pageProbe) => client.searchTargets(toQuery({
826
+ q: upstreamFilters.search,
827
+ platforms: upstreamFilters.platforms,
828
+ tags: upstreamFilters.tags,
829
+ page,
830
+ page_size: client.endpointNumber("target_search", "max_page_size", 100)
831
+ }), pageProbe ? { cache: false, coalesce: false } : {}), "results", max_cursor_pages, cursor_time_budget_ms, startPage, recoveryProbe));
671
832
  const api = collected.lastApi;
672
833
  const data = collected.lastData;
673
834
  const sanitizedMeta = readObject(sanitizeJson(data?.meta));
@@ -682,7 +843,9 @@ export function createBbradarServer(client, config) {
682
843
  pages_fetched: collected.pagesFetched,
683
844
  returned_across_pages: changes.length,
684
845
  cursor_page_budget_exhausted: collected.pageBudgetExhausted,
685
- cursor_time_budget_exhausted: collected.timeBudgetExhausted
846
+ cursor_time_budget_exhausted: collected.timeBudgetExhausted,
847
+ history_gap: collected.gapRecovery !== undefined,
848
+ inventory_baseline: collected.gapRecovery
686
849
  }),
687
850
  facets: sanitizeJson(data?.facets),
688
851
  cursor: collected.cursor,
@@ -694,6 +857,7 @@ export function createBbradarServer(client, config) {
694
857
  description: "Read-only repository push/change feed for direct inspection or polling. Defaults to lightweight program summaries; use after_id for efficient incremental polling. Use find_repository_hunt_targets instead when reward or target-quality ranking is also required.",
695
858
  inputSchema: {
696
859
  after_id: z.number().int().min(0).optional().describe("Incremental cursor; returns events after this id in ascending order."),
860
+ snapshot: stableSnapshotSchema.optional(),
697
861
  resume_cursor: z.boolean().default(false).describe("Resume the in-memory cursor for this exact normalized filter set. Seed it once with after_id."),
698
862
  max_cursor_pages: z.number().int().min(1).max(10).default(2),
699
863
  cursor_time_budget_ms: z.number().int().min(100).max(10_000).default(3_000),
@@ -724,12 +888,20 @@ export function createBbradarServer(client, config) {
724
888
  throw new Error("The negotiated BBRadar API contract does not expose repository changes.");
725
889
  }
726
890
  const { output_mode, resume_cursor, max_cursor_pages, cursor_time_budget_ms, ...filters } = args;
727
- const cursor = resolveFeedCursor(feedCursorStore, "repository_changes", filters, resume_cursor);
728
- const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, (afterId) => client.getRepositoryChanges(toQuery({
729
- ...filters,
891
+ assertStableSnapshotSupported(client, "repository_changes", filters.snapshot);
892
+ const cursor = resolveFeedCursor(feedCursorStore, "repository_changes", filters, resume_cursor, config);
893
+ const { snapshot, ...upstreamFilters } = filters;
894
+ const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, snapshot, filters.page, (afterId, recoveryProbe, activeSnapshot, pageNumber) => client.getRepositoryChanges(toQuery({
895
+ ...upstreamFilters,
896
+ snapshot: activeSnapshot,
730
897
  after_id: afterId,
731
- page: afterId === undefined ? filters.page : 1
732
- })));
898
+ page: activeSnapshot ? pageNumber : afterId === undefined ? filters.page : 1
899
+ }), recoveryProbe ? { cache: false, coalesce: false } : {}), (startPage, recoveryProbe) => refreshInventoryBaseline((page, pageProbe) => client.getRepositoryChanges(toQuery({
900
+ ...upstreamFilters,
901
+ after_id: undefined,
902
+ page,
903
+ page_size: client.endpointNumber("repository_changes", "max_page_size", 100)
904
+ }), pageProbe ? { cache: false, coalesce: false } : {}), "results", max_cursor_pages, cursor_time_budget_ms, startPage, recoveryProbe));
733
905
  const api = collected.lastApi;
734
906
  const data = collected.lastData;
735
907
  return applyCollectionContinuation(withApiMetadata(api, {
@@ -740,7 +912,9 @@ export function createBbradarServer(client, config) {
740
912
  pages_fetched: collected.pagesFetched,
741
913
  returned_across_pages: collected.results.length,
742
914
  cursor_page_budget_exhausted: collected.pageBudgetExhausted,
743
- cursor_time_budget_exhausted: collected.timeBudgetExhausted
915
+ cursor_time_budget_exhausted: collected.timeBudgetExhausted,
916
+ history_gap: collected.gapRecovery !== undefined,
917
+ inventory_baseline: collected.gapRecovery
744
918
  }),
745
919
  facets: sanitizeJson(data?.facets),
746
920
  available_facets: sanitizeJson(data?.available_facets),
@@ -750,7 +924,7 @@ export function createBbradarServer(client, config) {
750
924
  }));
751
925
  registerTool("find_hunt_targets", {
752
926
  title: "Find Best Hunt Targets",
753
- description: "Preferred one-call path for requests combining good rewards, broad Web2/Web3 scope, target quality, competition, and recently added targets. Returns a final ranked shortlist with exact bounded target samples and recent additions; do not fan out into repeated find_programs/get_program_targets calls. Use find_repository_hunt_targets instead only when Git or repository activity is explicitly requested.",
927
+ description: "Preferred one-call path for requests combining good rewards, broad Web2/Web3 scope, target quality, competition, and recently added targets. Returns a final ranked shortlist with exact bounded target samples and recent additions; do not fan out into repeated find_programs/get_program_targets calls. Self-Hosted is supported through current inventory and automatically skips its unavailable change feed. Use find_repository_hunt_targets instead only when Git or repository activity is explicitly requested.",
754
928
  inputSchema: {
755
929
  platforms: platformListSchema(),
756
930
  exclude_platforms: stringListSchema("Platforms to exclude from the final shortlist."),
@@ -795,7 +969,7 @@ export function createBbradarServer(client, config) {
795
969
  }, (args) => runTool("find_hunt_targets", config, rateLimiter, async () => findHuntTargets(client, config, args)));
796
970
  registerTool("find_repository_hunt_targets", {
797
971
  title: "Find Repository-Aware Hunt Targets",
798
- description: "Fast one-call path for requests that combine recent repository activity with program rewards and target quality. Fetches program rankings and repository changes in parallel, joins them locally, and works with arbitrary platforms, Web2/Web3 modes, reward thresholds, target types, languages, and freshness criteria.",
972
+ description: "Fast one-call path for requests that combine recent repository activity with program rewards and target quality. Fetches program rankings and repository changes in parallel, joins them locally, and works with arbitrary supported platform catalogs, Web2/Web3 modes, reward thresholds, target types, languages, and freshness criteria. Self-Hosted has no repository stream and is rejected without upstream work.",
799
973
  inputSchema: {
800
974
  platforms: platformListSchema(),
801
975
  exclude_platforms: stringListSchema("Platforms to exclude from final matches."),
@@ -916,7 +1090,7 @@ export function createBbradarServer(client, config) {
916
1090
  inputSchema: {
917
1091
  program_id: programIdentifierSchema,
918
1092
  since: isoDateTimeSchema.optional(),
919
- change_type: z.enum(["added", "updated", "removed"]).optional(),
1093
+ change_type: targetChangeTypeSchema.optional(),
920
1094
  target_type: filterValueSchema().optional(),
921
1095
  language_tags: languageTagListSchema(),
922
1096
  include_removed: z.boolean().default(true),
@@ -940,7 +1114,7 @@ export function createBbradarServer(client, config) {
940
1114
  title: "Get Recent Target Activity",
941
1115
  description: "Recent target changes grouped by program.",
942
1116
  inputSchema: {
943
- change_type: z.enum(["added", "updated", "removed"]).optional(),
1117
+ change_type: targetChangeTypeSchema.optional(),
944
1118
  target_type: filterValueSchema().optional(),
945
1119
  include_removed: z.boolean().default(true),
946
1120
  include_out_of_scope: z.boolean().default(false),
@@ -1050,6 +1224,7 @@ export function createBbradarServer(client, config) {
1050
1224
  target_types: targetTypeListSchema(),
1051
1225
  language_tags: languageTagListSchema(),
1052
1226
  web3: z.boolean().default(false).describe("Require Web3 signals."),
1227
+ program_kind: z.enum(["bounty", "contest", "challenge"]).optional().describe("Source-classified program kind; sent to the Pro program list."),
1053
1228
  exclude_web3: z.boolean().default(false).describe("Exclude programs with Web3, blockchain, smart-contract, crypto, DeFi, NFT, Solidity, Ethereum, or Solana signals. Use this for Web2-only discovery."),
1054
1229
  opportunity_levels: z.array(opportunityLevelSchema).max(4).default([]),
1055
1230
  updated_since: isoDateTimeSchema.optional(),
@@ -1064,7 +1239,7 @@ export function createBbradarServer(client, config) {
1064
1239
  exclude_mobile_only: z.boolean().default(false),
1065
1240
  exclude_non_web: z.boolean().default(false),
1066
1241
  has_api_scope: z.boolean().default(false),
1067
- contest_like: z.boolean().default(false).describe("Require contest/audit signals."),
1242
+ contest_like: z.boolean().default(false).describe("Require source-classified contests."),
1068
1243
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
1069
1244
  fresh_launch_days: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).optional(),
1070
1245
  min_wildcards: z.number().int().min(0).max(1000).default(0),
@@ -1426,7 +1601,7 @@ export function createBbradarServer(client, config) {
1426
1601
  description: "Recent changes by target type grouped by program.",
1427
1602
  inputSchema: {
1428
1603
  target_type: filterValueSchema(),
1429
- change_type: z.enum(["added", "updated", "removed"]).default("added"),
1604
+ change_type: targetChangeTypeSchema.default("added"),
1430
1605
  include_removed: z.boolean().default(false),
1431
1606
  include_out_of_scope: z.boolean().default(false),
1432
1607
  include_ineligible: z.boolean().default(false),
@@ -1692,7 +1867,8 @@ export function createBbradarServer(client, config) {
1692
1867
  ...apiEnvelopeOutputShape(),
1693
1868
  program: programOutputSchema().optional(),
1694
1869
  activity: jsonRecordSchema().optional(),
1695
- changes: z.array(jsonRecordSchema()).optional()
1870
+ changes: z.array(jsonRecordSchema()).optional(),
1871
+ meta: jsonRecordSchema().optional()
1696
1872
  },
1697
1873
  annotations: readOnlyAnnotations
1698
1874
  }, (args) => runTool("summarize_program_activity", config, rateLimiter, async () => runProgramIdTool(client, config, args.program_id, (programId) => summarizeProgramActivity(client, config, programId, args.recent_changes_limit))));
@@ -1896,13 +2072,15 @@ export function createBbradarServer(client, config) {
1896
2072
  const exportId = randomUUID();
1897
2073
  const sanitizedExport = sanitizeTargetsForExport(api.data, args.limit);
1898
2074
  const resourceUri = exportResourceUri(exportId);
1899
- rememberExport(exportStore, exportId, sanitizedExport);
2075
+ if (!api.noStore)
2076
+ rememberExport(exportStore, exportId, sanitizedExport);
1900
2077
  return withApiMetadata(api, {
1901
2078
  source_requests: resolvedProgramIds.sourceRequests.length > 0 ? resolvedProgramIds.sourceRequests : undefined,
1902
2079
  warnings: resolvedProgramIds.warnings.length > 0 ? resolvedProgramIds.warnings : undefined,
1903
2080
  export: {
1904
- export_id: exportId,
1905
- resource_uri: resourceUri,
2081
+ export_id: api.noStore ? undefined : exportId,
2082
+ resource_uri: api.noStore ? undefined : resourceUri,
2083
+ data: api.noStore ? sanitizedExport : undefined,
1906
2084
  preview: previewExportPayload(sanitizedExport),
1907
2085
  returned_preview_count: previewExportCount(sanitizedExport),
1908
2086
  limit: args.limit
@@ -1999,6 +2177,9 @@ function bbradarGuide(topic, profile, client) {
1999
2177
  arguments: { include_targets: false, max_cursor_pages: 2 },
2000
2178
  then: "For incremental polling, seed once with after_id and later set resume_cursor=true with the same filters."
2001
2179
  },
2180
+ { user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch get_history_change_detail only for a selected change." },
2181
+ { user_goal: "most active programs in a date range", first_tool: "get_analytics_program_activity", arguments: {}, then: "Use this server-side ranking rather than sampling the program catalog." },
2182
+ { user_goal: "exact recorded activity for one program", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
2002
2183
  {
2003
2184
  user_goal: "hunt targets combining repository activity with rewards, target quality, platforms, or Web2/Web3 constraints",
2004
2185
  first_tool: "find_repository_hunt_targets",
@@ -2141,6 +2322,9 @@ function coreBbradarGuide(topic, client) {
2141
2322
  first_tool: "get_repository_changes",
2142
2323
  arguments: { include_targets: false, max_cursor_pages: 2 }
2143
2324
  },
2325
+ { user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch change detail only when needed." },
2326
+ { user_goal: "most active programs", first_tool: "get_analytics_program_activity", arguments: {} },
2327
+ { user_goal: "exact recorded activity", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
2144
2328
  {
2145
2329
  user_goal: "hunt targets combining repository activity with rewards, target quality, platforms, or Web2/Web3 constraints",
2146
2330
  first_tool: "find_repository_hunt_targets",
@@ -2518,7 +2702,7 @@ function getCachedProgramFallback(config, requestedProgramId) {
2518
2702
  return cloneProgramFallbackForRequest(cached.fallback, requestedProgramId, true);
2519
2703
  }
2520
2704
  function rememberProgramFallback(config, fallback) {
2521
- if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0) {
2705
+ if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0 || hasNoStoreSource(fallback.sourceRequests)) {
2522
2706
  return;
2523
2707
  }
2524
2708
  pruneProgramFallbackCache();
@@ -2543,7 +2727,13 @@ function pruneProgramFallbackCache() {
2543
2727
  }
2544
2728
  }
2545
2729
  function programFallbackCacheKey(config, requestedProgramId) {
2546
- return `${config.apiBaseUrl}\n${normalizeTag(requestedProgramId)}`;
2730
+ return `${config.apiBaseUrl}\n${credentialCacheScope(config)}\n${isStrictProgramId(requestedProgramId) ? requestedProgramId : normalizeTag(requestedProgramId)}`;
2731
+ }
2732
+ function credentialCacheScope(config) {
2733
+ return createHash("sha256").update(config.apiKey).digest("hex");
2734
+ }
2735
+ function hasNoStoreSource(sourceRequests) {
2736
+ return sourceRequests.some((request) => readBoolean(request.no_store) === true);
2547
2737
  }
2548
2738
  function cloneProgramFallbackForRequest(fallback, requestedProgramId, fromCache) {
2549
2739
  const warnings = fromCache
@@ -2997,7 +3187,8 @@ function getCachedResolveProgram(config, input) {
2997
3187
  });
2998
3188
  }
2999
3189
  function rememberResolveProgram(config, input, payload) {
3000
- if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0) {
3190
+ const sourceRequests = readArray(payload.source_requests).filter((request) => readObject(request) !== undefined);
3191
+ if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0 || hasNoStoreSource(sourceRequests)) {
3001
3192
  return;
3002
3193
  }
3003
3194
  pruneResolveProgramCache();
@@ -3023,7 +3214,8 @@ function pruneResolveProgramCache() {
3023
3214
  function resolveProgramCacheKey(config, input) {
3024
3215
  return stableStringify({
3025
3216
  apiBaseUrl: config.apiBaseUrl,
3026
- query: normalizeTag(input.query),
3217
+ credentialScope: credentialCacheScope(config),
3218
+ query: isStrictProgramId(input.query) ? input.query : normalizeTag(input.query),
3027
3219
  platforms: input.platforms.map(normalizeTag).sort(),
3028
3220
  tags: input.tags.map(normalizeTag).sort(),
3029
3221
  opportunity_levels: [...input.opportunity_levels].sort(),
@@ -4231,8 +4423,9 @@ async function comparePrograms(client, config, input) {
4231
4423
  });
4232
4424
  }
4233
4425
  async function summarizeProgramActivity(client, config, programId, recentChangesLimit) {
4234
- const [programApi, changes] = await Promise.all([
4426
+ const [programApi, activityApi, changes] = await Promise.all([
4235
4427
  client.getProgram(programId),
4428
+ client.supportsEndpoint("program_activity") ? client.getProgramActivity(programId) : Promise.resolve(undefined),
4236
4429
  recentChangesLimit > 0
4237
4430
  ? fetchProgramChanges(client, config, programId, recentChangesLimit, true, true, true)
4238
4431
  : Promise.resolve(undefined)
@@ -4249,15 +4442,18 @@ async function summarizeProgramActivity(client, config, programId, recentChanges
4249
4442
  upstream_request_id: programApi.upstreamRequestId,
4250
4443
  ...apiSourceMetadata(programApi)
4251
4444
  },
4445
+ ...(activityApi ? [{ source: "program_activity", request_id: activityApi.requestId, upstream_request_id: activityApi.upstreamRequestId, ...apiSourceMetadata(activityApi) }] : []),
4252
4446
  ...(changes?.sourceRequests ?? [])
4253
4447
  ],
4254
4448
  program,
4255
4449
  activity: stripUndefined({
4450
+ activity_summary: activityApi ? sanitizeProPayload(readObject(activityApi.data)?.activity_summary) : undefined,
4256
4451
  target_counts: readObject(scopeSummary?.target_counts),
4257
4452
  target_activity: targetActivity,
4258
4453
  recent_change_count: changes?.changes.length
4259
4454
  }),
4260
- changes: changes?.changes ?? []
4455
+ changes: changes?.changes ?? [],
4456
+ meta: { target_change_samples_are_semantic_history: false, activity_source: activityApi ? "pro_activity_endpoint" : "legacy_program_projection" }
4261
4457
  };
4262
4458
  }
4263
4459
  async function getLatestAddedTargets(client, config, input) {
@@ -4407,21 +4603,50 @@ function latestAddedProgramMatches(change, input, platformFilters) {
4407
4603
  return true;
4408
4604
  }
4409
4605
  async function getProgramTargetsPayload(client, input, programId) {
4410
- const api = await client.getProgramTargets(programId, {
4411
- search: input.search,
4412
- target_type: input.target_type,
4413
- eligibility: input.include_ineligible ? "all" : "eligible",
4414
- minTargetScore: input.min_target_score,
4415
- targetOpportunityLabel: input.target_opportunity_label,
4416
- competitionRisk: input.competition_risk,
4417
- page: 1,
4418
- page_size: client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM)
4419
- });
4420
- const data = readObject(api.data);
4421
- const upstreamMeta = readObject(data?.meta);
4422
- const rawTargets = readArray(data?.targets);
4423
- const sanitizedTargets = rawTargets.map(sanitizeTarget).filter((target) => targetHasAllowedScope(target, input));
4424
- const targets = sanitizedTargets.slice(input.offset, input.offset + input.limit);
4606
+ const pageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
4607
+ // `eligibility=eligible` is an API-level in-scope bounty-eligibility filter,
4608
+ // so the common eligible-only path needs no second local scope pass.
4609
+ const directOffsetPaging = !input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope);
4610
+ const collected = [];
4611
+ let matchedSeen = directOffsetPaging ? Math.floor(input.offset / pageSize) * pageSize : 0;
4612
+ let page = directOffsetPaging ? Math.floor(input.offset / pageSize) + 1 : 1;
4613
+ const firstPage = page;
4614
+ let api;
4615
+ let upstreamMeta;
4616
+ let upstreamHasMore = false;
4617
+ do {
4618
+ api = await client.getProgramTargets(programId, {
4619
+ search: input.search,
4620
+ target_type: input.target_type,
4621
+ eligibility: input.include_ineligible ? "all" : "eligible",
4622
+ minTargetScore: input.min_target_score,
4623
+ targetOpportunityLabel: input.target_opportunity_label,
4624
+ competitionRisk: input.competition_risk,
4625
+ page,
4626
+ page_size: pageSize
4627
+ });
4628
+ const data = readObject(api.data);
4629
+ upstreamMeta = readObject(data?.meta);
4630
+ for (const target of readArray(data?.targets).map(sanitizeTarget)) {
4631
+ if (!targetHasAllowedScope(target, input))
4632
+ continue;
4633
+ if (matchedSeen >= input.offset && collected.length < input.limit)
4634
+ collected.push(target);
4635
+ matchedSeen += 1;
4636
+ }
4637
+ upstreamHasMore = readBoolean(upstreamMeta?.has_more) === true;
4638
+ const totalPages = readNumber(upstreamMeta?.total_pages);
4639
+ page += 1;
4640
+ if (totalPages !== undefined && page > totalPages) {
4641
+ upstreamHasMore = false;
4642
+ }
4643
+ } while (upstreamHasMore &&
4644
+ page <= MAX_UPSTREAM_PAGE &&
4645
+ collected.length < input.limit);
4646
+ if (!api) {
4647
+ throw new Error("No program target response was collected.");
4648
+ }
4649
+ const targets = collected;
4425
4650
  return withApiMetadata(api, {
4426
4651
  program_id: programId,
4427
4652
  targets: formatTargetList(targets, input.output_mode),
@@ -4429,12 +4654,16 @@ async function getProgramTargetsPayload(client, input, programId) {
4429
4654
  offset: input.offset,
4430
4655
  limit: input.limit,
4431
4656
  output_mode: input.output_mode,
4432
- total_active_targets: readNumber(upstreamMeta?.total) ?? rawTargets.length,
4433
- total_after_filters: sanitizedTargets.length,
4657
+ total_active_targets: readNumber(upstreamMeta?.total) ?? matchedSeen,
4658
+ total_after_filters: upstreamHasMore ? undefined : matchedSeen,
4434
4659
  returned: targets.length,
4435
- has_more: input.offset + input.limit < sanitizedTargets.length || readBoolean(upstreamMeta?.has_more) === true,
4436
- upstream_has_more: readBoolean(upstreamMeta?.has_more),
4437
- possibly_incomplete: readBoolean(upstreamMeta?.has_more) === true,
4660
+ has_more: upstreamHasMore || matchedSeen > input.offset + targets.length,
4661
+ upstream_has_more: upstreamHasMore,
4662
+ possibly_incomplete: upstreamHasMore,
4663
+ upstream_pages_fetched: page - firstPage,
4664
+ upstream_start_page: firstPage,
4665
+ direct_offset_paging: directOffsetPaging,
4666
+ upstream_page_limit_reached: upstreamHasMore && page > MAX_UPSTREAM_PAGE,
4438
4667
  data_capabilities: readObject(upstreamMeta?.data_capabilities),
4439
4668
  target_source: "program_targets"
4440
4669
  }
@@ -4480,10 +4709,12 @@ async function fetchProgramTargetsWithEndpointFallback(client, programId) {
4480
4709
  requestId: api.requestId,
4481
4710
  upstreamRequestId: api.upstreamRequestId,
4482
4711
  fetchedAt: api.fetchedAt,
4712
+ noStore: api.noStore,
4483
4713
  cache: readObject(stripUndefined({
4484
4714
  hit: api.cached,
4485
4715
  coalesced_live_request: api.coalesced,
4486
- expires_at: api.cacheExpiresAt
4716
+ expires_at: api.cacheExpiresAt,
4717
+ no_store: api.noStore
4487
4718
  })),
4488
4719
  source: "program_targets",
4489
4720
  rawTargets,
@@ -4541,7 +4772,7 @@ function getCachedProgramTargetsFallback(client, programId) {
4541
4772
  }
4542
4773
  function rememberProgramTargetsFallback(client, programId, lookup) {
4543
4774
  const diagnostics = client.diagnostics();
4544
- if (!diagnostics.response_cache.enabled || lookup.unavailable) {
4775
+ if (!diagnostics.response_cache.enabled || lookup.unavailable || lookup.noStore) {
4545
4776
  return;
4546
4777
  }
4547
4778
  const cache = programTargetsFallbackCacheForClient(client);
@@ -4579,7 +4810,7 @@ function pruneProgramTargetsFallbackCache(cache) {
4579
4810
  }
4580
4811
  }
4581
4812
  function programTargetsFallbackCacheKey(programId) {
4582
- return normalizeTag(programId);
4813
+ return programId;
4583
4814
  }
4584
4815
  function cacheTtlMsFromDiagnostics(cache) {
4585
4816
  return cache.enabled ? cache.ttl_ms : 0;
@@ -4598,10 +4829,12 @@ async function fetchProgramTargetsFromExportFallback(client, programId) {
4598
4829
  requestId: api.requestId,
4599
4830
  upstreamRequestId: api.upstreamRequestId,
4600
4831
  fetchedAt: api.fetchedAt,
4832
+ noStore: api.noStore,
4601
4833
  cache: readObject(stripUndefined({
4602
4834
  hit: api.cached,
4603
4835
  coalesced_live_request: api.coalesced,
4604
- expires_at: api.cacheExpiresAt
4836
+ expires_at: api.cacheExpiresAt,
4837
+ no_store: api.noStore
4605
4838
  })),
4606
4839
  source: "target_export",
4607
4840
  rawTargets,
@@ -4648,7 +4881,8 @@ async function getProgramFromTargetsOnlyFallback(client, programId, programError
4648
4881
  cache: readObject(stripUndefined({
4649
4882
  hit: targetsApi.cached,
4650
4883
  coalesced_live_request: targetsApi.coalesced,
4651
- expires_at: targetsApi.cacheExpiresAt
4884
+ expires_at: targetsApi.cacheExpiresAt,
4885
+ no_store: targetsApi.noStore
4652
4886
  })),
4653
4887
  source_requests: [
4654
4888
  stripUndefined({
@@ -4793,8 +5027,6 @@ async function exportTargetsFromProgramTargetsFallback(client, config, exportSto
4793
5027
  generated_at: new Date().toISOString()
4794
5028
  });
4795
5029
  const exportId = randomUUID();
4796
- const resourceUri = exportResourceUri(exportId);
4797
- rememberExport(exportStore, exportId, exportPayload);
4798
5030
  return stripUndefined({
4799
5031
  request_id: randomUUID(),
4800
5032
  source_requests: sourceRequests,
@@ -4802,7 +5034,7 @@ async function exportTargetsFromProgramTargetsFallback(client, config, exportSto
4802
5034
  errors: errors.length > 0 ? errors : undefined,
4803
5035
  export: {
4804
5036
  export_id: exportId,
4805
- resource_uri: resourceUri,
5037
+ data: exportPayload,
4806
5038
  preview: previewExportPayload(exportPayload),
4807
5039
  returned_preview_count: previewExportCount(exportPayload),
4808
5040
  limit: input.limit
@@ -5529,6 +5761,10 @@ function applyCollectionContinuation(payload, field, label, store, config) {
5529
5761
  if (!items || items.length <= inlineLimit) {
5530
5762
  return payload;
5531
5763
  }
5764
+ // A no-store page stays only in this tool result, never in a reusable resource.
5765
+ if (readBoolean(readObject(payload.cache)?.no_store) === true) {
5766
+ return payload;
5767
+ }
5532
5768
  const continuationId = randomUUID();
5533
5769
  const expiresAt = Date.now() + (config.continuationTtlMs ?? LOCAL_EXPORT_TTL_MS);
5534
5770
  const stored = rememberContinuation(store, continuationId, label, items, expiresAt);
@@ -5785,28 +6021,41 @@ function formatProgram(program, mode) {
5785
6021
  function compactProgram(program) {
5786
6022
  return stripUndefined({
5787
6023
  id: stringField(program, "id"),
6024
+ catalog_id: stringField(program, "catalog_id"),
5788
6025
  handle: stringField(program, "handle"),
5789
6026
  name: stringField(program, "name"),
5790
6027
  platform: stringField(program, "platform"),
5791
6028
  catalog: stringField(program, "catalog"),
6029
+ program_kind: stringField(program, "program_kind"),
6030
+ program_status: stringField(program, "program_status"),
6031
+ is_active: readBoolean(program.is_active),
5792
6032
  program_type: stringField(program, "program_type"),
5793
6033
  platform_url: stringField(program, "platform_url"),
5794
6034
  bbradar_url: stringField(program, "bbradar_url"),
5795
6035
  policy_url: stringField(program, "policy_url"),
5796
6036
  reporting_url: stringField(program, "reporting_url"),
5797
6037
  reward_status: stringField(program, "reward_status"),
5798
- data_capabilities: readObject(program.data_capabilities),
5799
- opportunity_tag: readObject(program.opportunity_tag),
6038
+ data_capabilities: program.data_capabilities,
6039
+ opportunity_tag: program.opportunity_tag,
5800
6040
  competition_risk: stringField(program, "competition_risk"),
5801
6041
  competition_risk_reason: stringField(program, "competition_risk_reason"),
5802
- submission_cost: readObject(program.submission_cost),
5803
- reward_range: readObject(program.reward_range),
5804
- public_report_count: readNumber(program.public_report_count),
6042
+ submission_cost: program.submission_cost,
6043
+ reward_range: program.reward_range,
6044
+ reward_range_label: stringField(program, "reward_range_label"),
6045
+ public_report_count: program.public_report_count,
5805
6046
  public_report_count_label: stringField(program, "public_report_count_label"),
5806
6047
  first_seen_date: stringField(program, "first_seen_date"),
6048
+ launch_at: program.launch_at,
6049
+ launch_at_precision: stringField(program, "launch_at_precision"),
6050
+ ends_at: program.ends_at,
6051
+ ends_on: program.ends_on,
6052
+ verification: program.verification,
5807
6053
  last_updated: stringField(program, "last_updated"),
5808
6054
  targets_updated_at: stringField(program, "targets_updated_at"),
5809
6055
  scope_summary: readObject(program.scope_summary),
6056
+ match_qualities: Array.isArray(program.match_qualities)
6057
+ ? readStringArrayField(program, "match_qualities")
6058
+ : undefined,
5810
6059
  resource_links: readObject(program.resource_links)
5811
6060
  });
5812
6061
  }
@@ -5867,6 +6116,7 @@ function formatRepositoryChange(change, mode) {
5867
6116
  })
5868
6117
  : undefined,
5869
6118
  changes_url: stringField(change, "changes_url"),
6119
+ comparison: sanitizeJson(change.comparison),
5870
6120
  programs: readArray(change.programs).map((program) => formatProgram(readObject(program) ?? {}, "compact")),
5871
6121
  targets: Array.isArray(change.targets)
5872
6122
  ? change.targets.map((target) => compactTarget(readObject(target) ?? {}))
@@ -6121,7 +6371,7 @@ function candidateLooksLikeCtf(candidate) {
6121
6371
  return candidate.looksLikeCtf;
6122
6372
  }
6123
6373
  function candidateLooksContestLike(candidate) {
6124
- return tagIncludesAnyNormalized(candidate.normalizedSearchSignals, WEB3_CONTEST_SIGNALS);
6374
+ return stringField(candidate.program, "program_kind") === "contest";
6125
6375
  }
6126
6376
  function candidateHasKnownNoBounty(candidate) {
6127
6377
  const programType = normalizeTag(stringField(candidate.program, "program_type") ?? "");
@@ -6351,7 +6601,11 @@ function clampLimit(name, value, max, warnings) {
6351
6601
  async function findHuntTargets(client, config, input) {
6352
6602
  const warnings = [];
6353
6603
  const errors = [];
6354
- const recentChangesEnabled = input.include_recent_changes && client.supportsEndpoint("target_changes");
6604
+ const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
6605
+ const targetChangesEndpointSupported = client.supportsEndpoint("target_changes");
6606
+ const recentChangesEnabled = input.include_recent_changes &&
6607
+ !selfHostedOnly &&
6608
+ targetChangesEndpointSupported;
6355
6609
  const desiredRecentChangeQueries = recentChangesEnabled ? huntRecentChangeQueries(input) : [];
6356
6610
  const recentChangeQueries = desiredRecentChangeQueries.slice(0, Math.max(0, input.upstream_request_budget - 2));
6357
6611
  const recentRequestReserve = recentChangeQueries.length;
@@ -6399,7 +6653,10 @@ async function findHuntTargets(client, config, input) {
6399
6653
  output_mode: "compact",
6400
6654
  upstream_request_budget: programRequestBudget
6401
6655
  };
6402
- if (input.include_recent_changes && !recentChangesEnabled) {
6656
+ if (input.include_recent_changes && selfHostedOnly) {
6657
+ warnings.push("Self-Hosted does not expose target-change history; recent target evidence was derived only from current target-inventory timestamps.");
6658
+ }
6659
+ else if (input.include_recent_changes && !recentChangesEnabled) {
6403
6660
  warnings.push("The negotiated API contract does not expose target changes; ranked current programs without recent-change enrichment.");
6404
6661
  }
6405
6662
  else if (recentChangeQueries.length < desiredRecentChangeQueries.length) {
@@ -6502,6 +6759,7 @@ async function findHuntTargets(client, config, input) {
6502
6759
  exact_target_programs_sampled: targetResult.targetsByProgram.size,
6503
6760
  recent_changes_scanned: recentResult.changes.length,
6504
6761
  recent_programs_matched: recentByProgram.size,
6762
+ recent_change_catalog_supported: !selfHostedOnly && targetChangesEndpointSupported,
6505
6763
  model_round_trips_expected: 1,
6506
6764
  parallel_recent_change_fetch: recentChangeQueries.length > 0,
6507
6765
  bulk_resource_links_suppressed: true,
@@ -6747,7 +7005,9 @@ function buildHuntTargetMatch(entry, targetsByProgram, recentByProgram, input) {
6747
7005
  (readNumber(rankFactors.target_surface_score) ?? 0) * 0.25);
6748
7006
  const freshnessScore = huntFreshnessScore(latestRecentAt, recentTargets.length, readNumber(rankFactors.added_7d));
6749
7007
  const publicReports = readNumber(rankFactors.public_report_count);
6750
- const competitionScore = publicReports === undefined ? 50 : Math.max(0, 100 - Math.min(100, publicReports / 10));
7008
+ // Missing report evidence is unknown, not low competition. Keep the
7009
+ // internal ranking conservative and do not expose a made-up numeric signal.
7010
+ const competitionScore = publicReports === undefined ? 20 : Math.max(0, 100 - Math.min(100, publicReports / 10));
6751
7011
  const bestTargetScore = Math.max(0, ...targetSamples.map((target) => readNumber(target.target_opportunity_score) ?? 0));
6752
7012
  const baseHuntScore = readNumber(rankFactors.hunt_value_score) ?? 0;
6753
7013
  const compositeScore = huntCompositeScore(input.ranking_mode, baseHuntScore, rewardScore, scopeScore, freshnessScore, competitionScore, bestTargetScore);
@@ -6775,7 +7035,8 @@ function buildHuntTargetMatch(entry, targetsByProgram, recentByProgram, input) {
6775
7035
  reward_score: roundScore(rewardScore),
6776
7036
  scope_score: roundScore(scopeScore),
6777
7037
  freshness_score: roundScore(freshnessScore),
6778
- competition_score: roundScore(competitionScore),
7038
+ competition_score: publicReports === undefined ? undefined : roundScore(competitionScore),
7039
+ competition_signal: publicReports === undefined ? "unknown" : "public_report_count",
6779
7040
  best_sample_target_score: bestTargetScore > 0 ? bestTargetScore : undefined,
6780
7041
  recent_added_targets_in_window: recentTargets.length,
6781
7042
  latest_added_target_at: latestRecentAt,
@@ -6880,11 +7141,52 @@ function compareHuntTargetMatches(left, right, mode) {
6880
7141
  }
6881
7142
  async function findRepositoryHuntTargets(client, config, input) {
6882
7143
  const warnings = [];
7144
+ const repositoryPlatforms = input.platforms.filter((platform) => !isSelfHostedPlatform(platform));
7145
+ const selfHostedRequested = repositoryPlatforms.length !== input.platforms.length;
7146
+ if (input.platforms.length > 0 && repositoryPlatforms.length === 0) {
7147
+ return {
7148
+ request_id: randomUUID(),
7149
+ fetched_at: new Date().toISOString(),
7150
+ source_requests: [],
7151
+ warnings: [
7152
+ "Self-Hosted does not expose repository-change events or target intelligence, so repository-aware hunting is unavailable for this catalog. Use find_hunt_targets for current Self-Hosted program and target inventory."
7153
+ ],
7154
+ filters: {
7155
+ platforms: input.platforms,
7156
+ surface_mode: input.surface_mode,
7157
+ ranking_mode: input.ranking_mode,
7158
+ output_mode: input.output_mode
7159
+ },
7160
+ upstream_budget: {
7161
+ initial: input.upstream_request_budget,
7162
+ used: 0,
7163
+ remaining: input.upstream_request_budget
7164
+ },
7165
+ ranking_scope: {
7166
+ mode: "unsupported_catalog",
7167
+ possibly_incomplete: false
7168
+ },
7169
+ programs: [],
7170
+ matches: [],
7171
+ meta: {
7172
+ returned: 0,
7173
+ matched_programs: 0,
7174
+ program_candidates: 0,
7175
+ repository_events_scanned: 0,
7176
+ qualifying_targets_before_result_limit: 0,
7177
+ repository_request_attempts: 0,
7178
+ parallel_source_fetch: false
7179
+ }
7180
+ };
7181
+ }
7182
+ if (selfHostedRequested) {
7183
+ warnings.push("Self-Hosted was excluded from repository-aware matching because that catalog has no repository-change stream; other requested platforms were processed normally.");
7184
+ }
6883
7185
  const repositoryRequestReserve = input.upstream_request_budget >= 3 ? 2 : 1;
6884
7186
  const programRequestBudget = Math.max(1, input.upstream_request_budget - repositoryRequestReserve);
6885
7187
  const programInput = {
6886
- platforms: input.platforms,
6887
- exclude_platforms: input.exclude_platforms,
7188
+ platforms: repositoryPlatforms,
7189
+ exclude_platforms: uniqueStrings([...input.exclude_platforms, "Self-Hosted"]),
6888
7190
  tags: input.tags,
6889
7191
  scope_tags: input.scope_tags,
6890
7192
  target_types: input.target_types,
@@ -6926,7 +7228,7 @@ async function findRepositoryHuntTargets(client, config, input) {
6926
7228
  };
6927
7229
  const [programPayload, repository] = await Promise.all([
6928
7230
  findPrograms(client, config, programInput),
6929
- fetchRepositoryHuntPage(client, input, warnings, repositoryRequestReserve)
7231
+ fetchRepositoryHuntPage(client, { ...input, platforms: repositoryPlatforms }, warnings, repositoryRequestReserve)
6930
7232
  ]);
6931
7233
  const candidatePrograms = readArray(programPayload.programs)
6932
7234
  .map(readObject)
@@ -7175,6 +7477,7 @@ async function findPrograms(client, config, input) {
7175
7477
  const collected = await collectPrograms(client, {
7176
7478
  platforms: input.platforms,
7177
7479
  tags: upstreamTags,
7480
+ program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined),
7178
7481
  updated_since: input.updated_since,
7179
7482
  opportunity_levels: input.opportunity_levels,
7180
7483
  max_pages: input.max_pages,
@@ -7223,6 +7526,7 @@ async function findPrograms(client, config, input) {
7223
7526
  exclude_non_web: input.exclude_non_web,
7224
7527
  has_api_scope: input.has_api_scope,
7225
7528
  contest_like: input.contest_like ? true : undefined,
7529
+ program_kind: input.program_kind,
7226
7530
  vdp_mode: input.vdp_mode && input.vdp_mode !== "include" ? input.vdp_mode : undefined,
7227
7531
  fresh_launch_days: input.fresh_launch_days,
7228
7532
  min_wildcards: input.min_wildcards,
@@ -7652,6 +7956,7 @@ async function fetchProgramPage(client, source, sourceIndex, page, options) {
7652
7956
  platforms: options.platforms,
7653
7957
  tags: options.tags,
7654
7958
  updated_since: options.updated_since,
7959
+ program_kind: source.kind === "programs" ? options.program_kind : undefined,
7655
7960
  page,
7656
7961
  page_size: MAX_PROGRAMS_PER_SEARCH
7657
7962
  });
@@ -7737,6 +8042,13 @@ function toProgramCandidate(rawProgram, webBaseUrl) {
7737
8042
  return candidate;
7738
8043
  }
7739
8044
  function programCandidateMatches(candidate, filters) {
8045
+ if (filters.program_kind && stringField(candidate.program, "program_kind") !== filters.program_kind) {
8046
+ return false;
8047
+ }
8048
+ const programStatus = normalizeTag(stringField(candidate.program, "program_status") ?? "");
8049
+ if (readBoolean(candidate.program.is_active) === false || programStatus === "paused") {
8050
+ return false;
8051
+ }
7740
8052
  if (filters.platforms.length > 0 && !filters.platforms.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
7741
8053
  return false;
7742
8054
  }
@@ -8062,6 +8374,12 @@ function uniqueStrings(values) {
8062
8374
  function normalizeTag(value) {
8063
8375
  return value.trim().toLowerCase();
8064
8376
  }
8377
+ function isSelfHostedPlatform(value) {
8378
+ return normalizeTag(value).replace(/[\s_]+/g, "-") === "self-hosted";
8379
+ }
8380
+ function isSelfHostedOnlyPlatformSelection(platforms) {
8381
+ return platforms.length > 0 && platforms.every(isSelfHostedPlatform);
8382
+ }
8065
8383
  function tokenSet(value) {
8066
8384
  return new Set(value.split(/[^a-z0-9]+/).filter((token) => token.length >= 2));
8067
8385
  }
@@ -8089,7 +8407,7 @@ function registerPrompts(server, profile) {
8089
8407
  tags: z.string().optional().describe("Optional comma-separated scope/language/target-type tags."),
8090
8408
  max_programs: z.string().optional().describe("Optional maximum shortlist size.")
8091
8409
  }
8092
- }, (args) => promptResult(`Use the BBRadar MCP tools only. Do not invoke local bug bounty skills, methodology files, worklogs, or non-BBRadar tools unless the user explicitly asks for methodology. Do not perform active scanning or contact third-party targets. Treat results as fresh API reads; do not describe them as cached.
8410
+ }, (args) => promptResult(`Use the BBRadar MCP tools only. Do not invoke local bug bounty skills, methodology files, worklogs, or non-BBRadar tools unless the user explicitly asks for methodology. Do not perform active scanning or contact third-party targets. Check each result's cache metadata before describing its freshness.
8093
8411
 
8094
8412
  Find the best BBRadar program candidates.
8095
8413
  ${discoveryToolInstructions}
@@ -8140,8 +8458,12 @@ The plan must:
8140
8458
  - Prioritize targets by bounty eligibility, freshness, wildcard status, target type, and severity labels.
8141
8459
  - Include a short checklist of BBRadar follow-up tool calls and decision points.`));
8142
8460
  }
8143
- function resolveFeedCursor(store, endpoint, filters, resume) {
8144
- const normalizedFilters = stableStringify(normalizeFeedCursorFilters(filters));
8461
+ function resolveFeedCursor(store, endpoint, filters, resume, config) {
8462
+ const normalizedFilters = stableStringify({
8463
+ api_base_url: config.apiBaseUrl,
8464
+ credential_scope: credentialCacheScope(config),
8465
+ filters: normalizeFeedCursorFilters(filters)
8466
+ });
8145
8467
  const key = `${endpoint}:${createHash("sha256").update(normalizedFilters).digest("hex")}`;
8146
8468
  const explicitAfterId = readNumber(filters.after_id);
8147
8469
  if (explicitAfterId !== undefined) {
@@ -8172,7 +8494,7 @@ function advanceFeedCursor(store, cursor, rawMeta) {
8172
8494
  stored_in_memory: true
8173
8495
  };
8174
8496
  }
8175
- async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudgetMs, fetchPage) {
8497
+ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudgetMs, initialSnapshot, initialPage, fetchPage, refreshInventory) {
8176
8498
  const startedAt = Date.now();
8177
8499
  const results = [];
8178
8500
  const sourceRequests = [];
@@ -8184,9 +8506,114 @@ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudget
8184
8506
  let hasMore = false;
8185
8507
  let pageBudgetExhausted = false;
8186
8508
  let timeBudgetExhausted = false;
8509
+ let gapRecovery;
8510
+ let activeSnapshot = initialSnapshot;
8187
8511
  do {
8188
- const api = await fetchPage(currentCursor.afterId);
8512
+ let api;
8513
+ try {
8514
+ api = await fetchPage(currentCursor.afterId, undefined, activeSnapshot, activeSnapshot ? initialPage + pagesFetched : 1);
8515
+ feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8516
+ }
8517
+ catch (error) {
8518
+ if (!(error instanceof BBRadarApiError) ||
8519
+ error.status !== 410 ||
8520
+ currentCursor.afterId === undefined ||
8521
+ activeSnapshot !== undefined ||
8522
+ !refreshInventory) {
8523
+ throw error;
8524
+ }
8525
+ const recoveryNonce = `${Date.now()}-${currentCursor.afterId}`;
8526
+ const markerApi = await fetchPage(undefined, `${recoveryNonce}-marker`);
8527
+ const markerData = readObject(markerApi.data);
8528
+ const markerAfterId = feedBaselineAfterId(markerData);
8529
+ let progress = feedRecoveryProgress.get(store)?.get(currentCursor.key);
8530
+ let restartedForDrift = false;
8531
+ if (progress && (progress.previousAfterId !== currentCursor.afterId ||
8532
+ progress.baselineAfterId !== markerAfterId)) {
8533
+ feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8534
+ progress = undefined;
8535
+ restartedForDrift = true;
8536
+ }
8537
+ const beforeAfterId = progress?.baselineAfterId ?? markerAfterId;
8538
+ const startPage = progress?.nextPage ?? 1;
8539
+ const inventoryPart = await refreshInventory(startPage, `${recoveryNonce}-inventory`);
8540
+ const inventory = {
8541
+ ...inventoryPart,
8542
+ rows_observed: (progress?.rowsObserved ?? 0) + (readNumber(inventoryPart.rows_observed) ?? 0),
8543
+ pages_fetched: (progress?.pagesFetched ?? 0) + (readNumber(inventoryPart.pages_fetched) ?? 0),
8544
+ resumed_from_page: startPage,
8545
+ restarted_for_feed_drift: restartedForDrift
8546
+ };
8547
+ const inventoryComplete = readBoolean(inventory.complete) === true;
8548
+ const endpointPageLimitReached = readBoolean(inventory.endpoint_page_limit_reached) === true;
8549
+ const afterApi = inventoryComplete ? await fetchPage(undefined, `${recoveryNonce}-after`) : markerApi;
8550
+ const afterData = readObject(afterApi.data);
8551
+ const afterAfterId = feedBaselineAfterId(afterData);
8552
+ const baselineStable = inventoryComplete && beforeAfterId !== undefined && beforeAfterId === afterAfterId;
8553
+ const nextAfterId = baselineStable ? beforeAfterId : currentCursor.afterId;
8554
+ if (baselineStable) {
8555
+ store.set(currentCursor.key, nextAfterId);
8556
+ feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8557
+ }
8558
+ else if (!inventoryComplete && !endpointPageLimitReached && beforeAfterId !== undefined) {
8559
+ // Persist only the unchanged expired bookmark so resume_cursor can
8560
+ // continue recovery without treating partial inventory as a baseline.
8561
+ store.set(currentCursor.key, currentCursor.afterId);
8562
+ setRecoveryCheckpoint(store, currentCursor.key, {
8563
+ previousAfterId: currentCursor.afterId,
8564
+ baselineAfterId: beforeAfterId,
8565
+ nextPage: readNumber(inventoryPart.next_page) ?? startPage,
8566
+ rowsObserved: readNumber(inventory.rows_observed) ?? 0,
8567
+ pagesFetched: readNumber(inventory.pages_fetched) ?? 0
8568
+ });
8569
+ }
8570
+ else {
8571
+ // A failed first explicit recovery must remain resumable even when no
8572
+ // safe inventory checkpoint can be retained.
8573
+ store.set(currentCursor.key, currentCursor.afterId);
8574
+ feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8575
+ }
8576
+ gapRecovery = {
8577
+ historical_gap: true,
8578
+ current_inventory_refreshed: inventoryComplete,
8579
+ recovery_complete: baselineStable,
8580
+ retained_previous_cursor: !baselineStable,
8581
+ incomplete_reason: !inventoryComplete
8582
+ ? endpointPageLimitReached ? "inventory_endpoint_page_limit_reached" : "inventory_page_or_time_budget_exhausted"
8583
+ : beforeAfterId === undefined
8584
+ ? "feed_baseline_unavailable"
8585
+ : beforeAfterId !== afterAfterId
8586
+ ? "feed_changed_during_inventory_refresh"
8587
+ : undefined,
8588
+ inventory,
8589
+ next_inventory_page: baselineStable ? undefined : readNumber(inventoryPart.next_page),
8590
+ previous_after_id: currentCursor.afterId,
8591
+ baseline_after_id: baselineStable ? nextAfterId : undefined,
8592
+ baseline_events_emitted: 0,
8593
+ limitation: "Changes retired before recovery cannot be reconstructed."
8594
+ };
8595
+ lastApi = afterApi;
8596
+ lastData = afterData;
8597
+ cursorPayload = {
8598
+ endpoint: currentCursor.endpoint,
8599
+ mode: baselineStable ? "recovered_baseline" : "recovery_incomplete",
8600
+ used_after_id: currentCursor.afterId,
8601
+ next_after_id: nextAfterId,
8602
+ has_more: false,
8603
+ stored_in_memory: true
8604
+ };
8605
+ pagesFetched += 1;
8606
+ break;
8607
+ }
8189
8608
  const data = readObject(api.data);
8609
+ const returnedSnapshot = readString(readObject(data?.meta)?.snapshot);
8610
+ if (activeSnapshot && !returnedSnapshot)
8611
+ throw new Error("Stable snapshot response omitted its token; discard this page and start a new snapshot.");
8612
+ if (activeSnapshot && activeSnapshot !== "start" && returnedSnapshot !== activeSnapshot)
8613
+ throw new Error("Stable snapshot changed between pages; discard these pages and start a new snapshot.");
8614
+ if (activeSnapshot === "start" && returnedSnapshot) {
8615
+ activeSnapshot = returnedSnapshot;
8616
+ }
8190
8617
  const pageResults = readArray(data?.results);
8191
8618
  lastApi = api;
8192
8619
  lastData = data;
@@ -8229,12 +8656,61 @@ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudget
8229
8656
  pagesFetched,
8230
8657
  pageBudgetExhausted,
8231
8658
  timeBudgetExhausted,
8232
- sourceRequests
8659
+ sourceRequests,
8660
+ gapRecovery
8661
+ };
8662
+ }
8663
+ const feedRecoveryProgress = new WeakMap();
8664
+ function recoveryProgressFor(store) {
8665
+ let progress = feedRecoveryProgress.get(store);
8666
+ if (!progress) {
8667
+ progress = new Map();
8668
+ feedRecoveryProgress.set(store, progress);
8669
+ }
8670
+ return progress;
8671
+ }
8672
+ function setRecoveryCheckpoint(store, key, checkpoint) {
8673
+ const progress = recoveryProgressFor(store);
8674
+ if (!progress.has(key) && progress.size >= 500) {
8675
+ const oldest = progress.keys().next().value;
8676
+ if (oldest !== undefined)
8677
+ progress.delete(oldest);
8678
+ }
8679
+ progress.delete(key);
8680
+ progress.set(key, checkpoint);
8681
+ }
8682
+ function feedBaselineAfterId(data) {
8683
+ const metaAfterId = readNumber(readObject(data?.meta)?.next_after_id);
8684
+ const ids = readArray(data?.results)
8685
+ .map((entry) => readNumber(readObject(entry)?.id))
8686
+ .filter((value) => value !== undefined);
8687
+ return ids.length > 0 ? Math.max(metaAfterId ?? 0, ...ids) : metaAfterId;
8688
+ }
8689
+ async function refreshInventoryBaseline(fetchPage, collection, maxPages, timeBudgetMs, startPage = 1, recoveryProbe = "inventory") {
8690
+ const startedAt = Date.now();
8691
+ let page = startPage;
8692
+ let rows = 0;
8693
+ let hasMore = false;
8694
+ do {
8695
+ const api = await fetchPage(page, `${recoveryProbe}-${page}`);
8696
+ const data = readObject(api.data);
8697
+ rows += readArray(data?.[collection]).length;
8698
+ hasMore = readBoolean(readObject(data?.meta)?.has_more) === true;
8699
+ page += 1;
8700
+ } while (hasMore && page < startPage + maxPages && Date.now() - startedAt < timeBudgetMs);
8701
+ return {
8702
+ collection,
8703
+ rows_observed: rows,
8704
+ pages_fetched: page - startPage,
8705
+ next_page: hasMore && page <= MAX_UPSTREAM_PAGE ? page : undefined,
8706
+ endpoint_page_limit_reached: hasMore && page > MAX_UPSTREAM_PAGE,
8707
+ complete: !hasMore,
8708
+ bounded: true
8233
8709
  };
8234
8710
  }
8235
8711
  function normalizeFeedCursorFilters(filters) {
8236
8712
  return Object.fromEntries(Object.entries(filters)
8237
- .filter(([key, value]) => key !== "after_id" && key !== "page" && value !== undefined)
8713
+ .filter(([key, value]) => key !== "after_id" && key !== "page" && key !== "snapshot" && value !== undefined)
8238
8714
  .map(([key, value]) => [key, normalizeFeedCursorFilterValue(value)]));
8239
8715
  }
8240
8716
  function normalizeFeedCursorFilterValue(value) {
@@ -8337,21 +8813,49 @@ function withApiMetadata(api, payload) {
8337
8813
  hit: api.cached,
8338
8814
  stale_while_revalidate: api.stale,
8339
8815
  coalesced_live_request: api.coalesced,
8340
- expires_at: api.cacheExpiresAt
8816
+ expires_at: api.cacheExpiresAt,
8817
+ no_store: api.noStore
8341
8818
  }),
8342
8819
  fetched_at: api.fetchedAt,
8343
8820
  ...payload
8344
8821
  });
8345
8822
  }
8823
+ function proPage(api, field) {
8824
+ const raw = readObject(api.data);
8825
+ if (!raw || !(field === "activity_summary" ? readObject(raw[field]) : Array.isArray(raw[field]))) {
8826
+ throw new Error(`BBRadar Pro API returned a malformed page: expected ${field}.`);
8827
+ }
8828
+ const data = sanitizeProPayload(api.data);
8829
+ return { ...data, ...withApiMetadata(api, {}) };
8830
+ }
8831
+ function assertProFeature(client, endpoint, flag) {
8832
+ const state = client.capabilitySnapshot();
8833
+ if (state.status !== "negotiated")
8834
+ return;
8835
+ const capability = client.endpointCapability(endpoint);
8836
+ if (!capability)
8837
+ throw new Error(`The negotiated BBRadar API does not advertise ${endpoint}. Upgrade the API deployment to use this tool.`);
8838
+ if (flag && capability[flag] === false) {
8839
+ throw new Error(`${endpoint} is advertised but ${flag}=false on this API deployment.`);
8840
+ }
8841
+ }
8842
+ function assertHistoryCapture(client) {
8843
+ if (client.capabilitySnapshot().status === "negotiated" &&
8844
+ client.endpointCapability("history_changes")?.history_capture_enabled === false) {
8845
+ throw new Error("History endpoints are advertised but history_capture_enabled=false on this API deployment.");
8846
+ }
8847
+ }
8346
8848
  function apiSourceMetadata(api) {
8347
8849
  return stripUndefined({
8348
8850
  cache_hit: api.cached,
8349
8851
  cache_expires_at: api.cacheExpiresAt,
8350
- coalesced_live_request: api.coalesced
8852
+ coalesced_live_request: api.coalesced,
8853
+ no_store: api.noStore
8351
8854
  });
8352
8855
  }
8353
8856
  function errorPayload(error, decision, toolName, durationMs) {
8354
8857
  if (error instanceof BBRadarApiError) {
8858
+ const recovery = error.status === 410 ? recoveryForGone(error, toolName) : undefined;
8355
8859
  return stripUndefined({
8356
8860
  request_id: error.requestId,
8357
8861
  upstream_request_id: error.upstreamRequestId,
@@ -8361,6 +8865,7 @@ function errorPayload(error, decision, toolName, durationMs) {
8361
8865
  detail: sanitizeJson(error.detail),
8362
8866
  errors: sanitizeJson(error.errors)
8363
8867
  }),
8868
+ recovery,
8364
8869
  mcp_rate_limit: toRateLimitPayload(decision),
8365
8870
  mcp_timing: toolTimingPayload(toolName, durationMs)
8366
8871
  });
@@ -8374,6 +8879,19 @@ function errorPayload(error, decision, toolName, durationMs) {
8374
8879
  mcp_timing: toolTimingPayload(toolName, durationMs)
8375
8880
  };
8376
8881
  }
8882
+ function recoveryForGone(error, toolName) {
8883
+ const detail = typeof error.detail === "string" ? error.detail.toLowerCase() : error.message.toLowerCase();
8884
+ if (detail.includes("retained analytics") || detail.includes("precede")) {
8885
+ return { action: "choose_supported_interval", message: "Choose dates inside the retained analytics window." };
8886
+ }
8887
+ if (detail.includes("snapshot")) {
8888
+ return { action: "start_new_snapshot", message: "Restart on page 1 with snapshot=start; do not combine pages from the old snapshot." };
8889
+ }
8890
+ if (toolName.startsWith("get_history") || toolName === "get_program_history" || toolName.startsWith("get_analytics")) {
8891
+ return { action: "restart_query", message: "Restart this History or analytics query without its expired opaque cursor, keeping the intended filters." };
8892
+ }
8893
+ return { action: "reconcile_inventory", message: "The retained numeric feed has a gap; reconcile current inventory before seeding a new after_id." };
8894
+ }
8377
8895
  function recordToolMetric(toolName, durationMs, status) {
8378
8896
  const existing = TOOL_METRICS.get(toolName) ??
8379
8897
  {
@@ -8441,6 +8959,11 @@ function splitFilterList(value) {
8441
8959
  function toQuery(values) {
8442
8960
  return compactRecord(values);
8443
8961
  }
8962
+ function assertStableSnapshotSupported(client, endpoint, snapshot) {
8963
+ if (snapshot !== undefined && !client.supportsStableSnapshot(endpoint)) {
8964
+ throw new Error(`The negotiated BBRadar API contract does not advertise stable snapshots for ${endpoint}. Omit snapshot to use live pagination.`);
8965
+ }
8966
+ }
8444
8967
  function compactRecord(values) {
8445
8968
  return Object.fromEntries(Object.entries(values).filter(([, value]) => {
8446
8969
  if (value === undefined || value === null) {
@@ -8487,11 +9010,19 @@ function compactTarget(target) {
8487
9010
  identifier: targetIdentifier(target),
8488
9011
  target_type: stringField(target, "target_type"),
8489
9012
  in_scope: readBoolean(target.in_scope),
8490
- eligible_for_bounty: readBoolean(target.eligible_for_bounty),
9013
+ eligible_for_bounty: target.eligible_for_bounty,
8491
9014
  wildcard: readBoolean(target.wildcard),
8492
- target_opportunity_score: readNumber(target.target_opportunity_score),
9015
+ path_kind: stringField(target, "path_kind"),
9016
+ path_prefix: stringField(target, "path_prefix"),
9017
+ match_quality: stringField(target, "match_quality"),
9018
+ match_reason: stringField(target, "match_reason"),
9019
+ target_opportunity_score: target.target_opportunity_score,
8493
9020
  target_opportunity_label: stringField(target, "target_opportunity_label"),
9021
+ score_version: target.score_version,
9022
+ evidence_coverage: target.evidence_coverage,
9023
+ target_score_breakdown: target.target_score_breakdown,
8494
9024
  competition_risk: stringField(target, "competition_risk"),
9025
+ competition_risk_reason: stringField(target, "competition_risk_reason"),
8495
9026
  why_this_target: stringField(target, "why_this_target")
8496
9027
  });
8497
9028
  }