@bbradar/mcp 0.1.20 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,11 +1,12 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { z } from "zod";
4
4
  import { BBRadarApiError } from "./bbradarClient.js";
5
+ import { NumericCursorStore } from "./cursorStore.js";
5
6
  import { stableStringify, stripUndefined } from "./json.js";
6
7
  import { FixedWindowRateLimiter } from "./rateLimit.js";
7
- import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeChange, sanitizeProgram, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
8
- const SERVER_VERSION = "0.1.19";
8
+ import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
9
+ const SERVER_VERSION = "0.2.1";
9
10
  const MAX_PROGRAMS_PER_SEARCH = 100;
10
11
  const MAX_TARGET_EXPORT = 500;
11
12
  const MAX_RECENT_CHANGES = 100;
@@ -29,10 +30,13 @@ const MAX_FILTER_DISCOVERY_PAGES = 5;
29
30
  const DEFAULT_FILTER_TARGET_SAMPLE_PROGRAMS = 10;
30
31
  const MAX_FILTER_TARGET_SAMPLE_PROGRAMS = 25;
31
32
  const MAX_LOCAL_EXPORT_RESOURCES = 25;
33
+ const MAX_LOCAL_EXPORT_BYTES = 64 * 1024 * 1024;
34
+ const LOCAL_EXPORT_TTL_MS = 10 * 60 * 1000;
32
35
  const EXPORT_PREVIEW_LIMIT = 25;
33
36
  const STALE_PROGRAM_ID_RESOLVE_PAGES = 1;
34
37
  const STALE_PROGRAM_ID_RESOLVE_BUDGET = 12;
35
38
  const PROGRAM_FALLBACK_CACHE_MAX_ENTRIES = 200;
39
+ const MAX_IN_MEMORY_FEED_CURSORS = 500;
36
40
  const MAX_RESOLVE_SEARCH_QUERIES = 4;
37
41
  const FAST_RESOLVE_SEARCH_QUERIES = 2;
38
42
  const MAX_DIRECT_HANDLE_RESOLUTION_REQUESTS = 96;
@@ -45,6 +49,8 @@ const CORE_TOOL_NAMES = new Set([
45
49
  "get_program_eligible_targets_by_name",
46
50
  "get_program_targets",
47
51
  "get_recent_changes",
52
+ "get_repository_changes",
53
+ "find_repository_hunt_targets",
48
54
  "get_latest_added_targets",
49
55
  "get_opportunities",
50
56
  "find_programs",
@@ -54,6 +60,12 @@ const CORE_TOOL_NAMES = new Set([
54
60
  "get_mcp_status",
55
61
  "export_targets"
56
62
  ]);
63
+ const TOOL_ENDPOINT_REQUIREMENTS = {
64
+ get_repository_changes: "repository_changes",
65
+ find_repository_hunt_targets: "repository_changes",
66
+ export_targets: "target_export",
67
+ get_opportunities: "opportunities"
68
+ };
57
69
  const PROGRAM_ID_PLATFORM_GUESSES = [
58
70
  "HackerOne",
59
71
  "BugCrowd",
@@ -159,6 +171,7 @@ const TARGET_QUERY_STOP_WORDS = new Set([
159
171
  ]);
160
172
  const targetListModeSchema = z.enum(["identifiers", "compact", "full"]);
161
173
  const changeListModeSchema = z.enum(["compact", "full"]);
174
+ const repositoryChangeListModeSchema = z.enum(["compact", "full"]);
162
175
  const programListModeSchema = z.enum(["compact", "full"]);
163
176
  const rewardThresholdModeSchema = z.enum(["max_at_least", "min_at_least"]);
164
177
  const vdpModeSchema = z.enum(["include", "exclude", "only_likely", "only_known_no_bounty"]);
@@ -227,6 +240,8 @@ const pageSchema = z.number().int().min(1).default(1);
227
240
  const programsPageSizeSchema = z.number().int().min(1).max(MAX_PROGRAMS_PER_SEARCH).default(20);
228
241
  const recentChangesPageSizeSchema = z.number().int().min(1).max(MAX_RECENT_CHANGES).default(50);
229
242
  const opportunityLevelSchema = z.enum(["elite", "hot", "strong", "potential"]);
243
+ const targetOpportunityLabelSchema = z.enum(["Elite Target", "Strong Target", "Potential Target", "Normal"]);
244
+ const competitionRiskSchema = z.enum(["Low", "Medium", "High", "Unknown"]);
230
245
  const programSortSchema = z
231
246
  .enum([
232
247
  "best_match",
@@ -278,6 +293,7 @@ function toolTimingOutputSchema() {
278
293
  function cacheOutputSchema() {
279
294
  return z.object({
280
295
  hit: z.boolean(),
296
+ stale_while_revalidate: z.boolean().optional(),
281
297
  coalesced_live_request: z.boolean().optional(),
282
298
  expires_at: z.string().optional()
283
299
  });
@@ -364,10 +380,12 @@ const programCandidatePresetInputSchema = {
364
380
  output_mode: programListModeSchema.default("compact"),
365
381
  upstream_request_budget: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(30)
366
382
  };
367
- function createProfiledToolRegistrar(server, profile) {
383
+ function createProfiledToolRegistrar(server, profile, client) {
368
384
  return (name, toolConfig, callback) => {
369
385
  const registeredTool = server.registerTool(name, toolConfig, callback);
370
- if (profile === "core" && !CORE_TOOL_NAMES.has(name)) {
386
+ const requiredEndpoint = TOOL_ENDPOINT_REQUIREMENTS[name];
387
+ if ((profile === "core" && !CORE_TOOL_NAMES.has(name)) ||
388
+ (requiredEndpoint !== undefined && !client.supportsEndpoint(requiredEndpoint))) {
371
389
  registeredTool.disable();
372
390
  }
373
391
  return registeredTool;
@@ -391,7 +409,9 @@ export function createBbradarServer(client, config) {
391
409
  "Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
392
410
  "Default to bounty-eligible in-scope data. Include out-of-scope, ineligible, VDP, disclosure-only, or no-bounty data only when the user explicitly asks.",
393
411
  "Recently updated programs across platforms: find_recently_updated_programs. Do not infer a platform such as Immunefi unless the user explicitly names it.",
412
+ "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.",
394
413
  "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.",
414
+ "Repository activity: get_repository_changes defaults to compact program-only events. Seed with after_id, then resume the in-memory per-filter cursor.",
395
415
  "Discovery: 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, find_low_noise_programs.",
396
416
  "Details: get_program_brief, get_program_scope_summary, get_program_target_breakdown, find_programs_by_target, compare_programs_compact.",
397
417
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
@@ -402,13 +422,26 @@ export function createBbradarServer(client, config) {
402
422
  }, {
403
423
  instructions: instructions.join("\n")
404
424
  });
405
- const registerTool = createProfiledToolRegistrar(server, config.toolProfile);
425
+ const registerTool = createProfiledToolRegistrar(server, config.toolProfile, client);
406
426
  const rateLimiter = new FixedWindowRateLimiter();
407
427
  const exportStore = new Map();
428
+ const continuationStore = new Map();
429
+ const feedCursorStore = new NumericCursorStore(config.cursorStatePath, MAX_IN_MEMORY_FEED_CURSORS);
430
+ const maxProgramsPageSize = client.endpointNumber("programs", "max_page_size", MAX_PROGRAMS_PER_SEARCH);
431
+ const maxProgramTargetsPageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
432
+ const maxTargetChangesPageSize = client.endpointNumber("target_changes", "max_page_size", MAX_RECENT_CHANGES);
433
+ const maxRepositoryChangesPageSize = client.endpointNumber("repository_changes", "max_page_size", MAX_RECENT_CHANGES);
434
+ const maxTargetExport = client.endpointNumber("target_export", "max_targets", MAX_TARGET_EXPORT);
435
+ const maxTargetExportProgramIds = client.endpointNumber("target_export", "max_program_ids", 50);
436
+ const programsPageSizeSchema = z.number().int().min(1).max(maxProgramsPageSize).default(Math.min(20, maxProgramsPageSize));
437
+ const recentChangesPageSizeSchema = z.number().int().min(1).max(maxTargetChangesPageSize).default(Math.min(50, maxTargetChangesPageSize));
438
+ const repositoryChangesPageSizeSchema = z.number().int().min(1).max(maxRepositoryChangesPageSize).default(Math.min(50, maxRepositoryChangesPageSize));
439
+ const acceptedTargetLimitSchema = z.number().int().min(1).max(maxProgramTargetsPageSize);
408
440
  registerTool("search_programs", {
409
441
  title: "Search BBRadar Programs",
410
- description: `List active programs. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
442
+ description: `Search or list active platform and Self-Hosted programs. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
411
443
  inputSchema: {
444
+ search: searchTextSchema.optional().describe("Server-side program name, handle, platform, policy-host, or target search."),
412
445
  platforms: platformListSchema(),
413
446
  tags: tagListSchema(),
414
447
  updated_since: isoDateTimeSchema.optional().describe("ISO datetime."),
@@ -505,8 +538,8 @@ export function createBbradarServer(client, config) {
505
538
  language_tags: languageTagListSchema(),
506
539
  include_out_of_scope: z.boolean().default(false),
507
540
  include_ineligible: z.boolean().default(false),
508
- page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
509
- max_targets: acceptedTargetLimitSchema.default(25),
541
+ page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
542
+ max_targets: acceptedTargetLimitSchema.default(Math.min(25, maxProgramTargetsPageSize)),
510
543
  group_limit: z.number().int().min(1).max(MAX_TARGETS_PER_PROGRAM).default(100),
511
544
  target_list_mode: targetListModeSchema.default("identifiers"),
512
545
  max_resolve_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
@@ -549,6 +582,7 @@ export function createBbradarServer(client, config) {
549
582
  resolved_program: jsonRecordSchema().optional(),
550
583
  program_id: z.string().optional(),
551
584
  targets: z.array(z.unknown()).optional(),
585
+ continuation: jsonRecordSchema().optional(),
552
586
  warnings: z.array(z.string()).optional(),
553
587
  upstream_budget: jsonRecordSchema().optional(),
554
588
  ranking_scope: jsonRecordSchema().optional(),
@@ -558,14 +592,19 @@ export function createBbradarServer(client, config) {
558
592
  }, (args) => runTool("get_program_eligible_targets_by_name", config, rateLimiter, async () => getProgramEligibleTargetsByName(client, config, args)));
559
593
  registerTool("get_program_targets", {
560
594
  title: "Get Program Targets",
561
- description: `Get active targets for one program_id, handle, or human program name. This is a final source for eligible target lists; do not call summary/breakdown unless grouped counts are needed. For a program name query, get_program_eligible_targets_by_name is the simplest path. limit max ${MAX_TARGETS_PER_PROGRAM}.`,
595
+ description: `Get active targets for one program_id, handle, or human program name. This is a final source for eligible target lists; do not call summary/breakdown unless grouped counts are needed. For a program name query, get_program_eligible_targets_by_name is the simplest path. limit max ${maxProgramTargetsPageSize}.`,
562
596
  inputSchema: {
563
597
  program_id: programIdentifierSchema,
598
+ search: searchTextSchema.optional(),
599
+ target_type: filterValueSchema().optional(),
600
+ min_target_score: z.number().int().min(0).max(100).optional(),
601
+ target_opportunity_label: targetOpportunityLabelSchema.optional(),
602
+ competition_risk: competitionRiskSchema.optional(),
564
603
  include_out_of_scope: z.boolean().default(false),
565
604
  include_ineligible: z.boolean().default(false),
566
605
  strict_scope_filter: z.boolean().default(true),
567
606
  offset: z.number().int().min(0).default(0),
568
- limit: z.number().int().min(1).max(MAX_TARGETS_PER_PROGRAM).default(100),
607
+ limit: z.number().int().min(1).max(maxProgramTargetsPageSize).default(Math.min(100, maxProgramTargetsPageSize)),
569
608
  output_mode: targetListModeSchema.default("compact")
570
609
  },
571
610
  outputSchema: {
@@ -576,13 +615,21 @@ export function createBbradarServer(client, config) {
576
615
  },
577
616
  annotations: readOnlyAnnotations
578
617
  }, (args) => runTool("get_program_targets", config, rateLimiter, async () => {
579
- return runProgramIdTool(client, config, args.program_id, async (programId) => getProgramTargetsPayload(client, args, programId), async (indexFallback) => getProgramTargetsExportFallbackPayload(client, args, indexFallback.resolvedProgramId));
618
+ const payload = await runProgramIdTool(client, config, args.program_id, async (programId) => getProgramTargetsPayload(client, args, programId), async (indexFallback) => getProgramTargetsExportFallbackPayload(client, args, indexFallback.resolvedProgramId));
619
+ return applyCollectionContinuation(payload, "targets", "program targets", continuationStore, config);
580
620
  }));
581
621
  registerTool("get_recent_changes", {
582
622
  title: "Get Recent Target Changes",
583
623
  description: `List recent target changes. page_size max ${MAX_RECENT_CHANGES}.`,
584
624
  inputSchema: {
585
625
  change_type: z.enum(["added", "updated", "removed"]).optional(),
626
+ after_id: z.number().int().min(0).optional().describe("Incremental cursor; returns changes after this id in ascending order."),
627
+ resume_cursor: z.boolean().default(false).describe("Resume the in-memory cursor for this exact normalized filter set. Seed it once with after_id."),
628
+ max_cursor_pages: z.number().int().min(1).max(10).default(2),
629
+ cursor_time_budget_ms: z.number().int().min(100).max(10_000).default(3_000),
630
+ min_target_score: z.number().int().min(0).max(100).optional(),
631
+ target_opportunity_label: targetOpportunityLabelSchema.optional(),
632
+ competition_risk: competitionRiskSchema.optional(),
586
633
  include_removed: z.boolean().default(false),
587
634
  include_ineligible: z.boolean().default(false),
588
635
  include_out_of_scope: z.boolean().default(false),
@@ -597,26 +644,148 @@ export function createBbradarServer(client, config) {
597
644
  ...apiEnvelopeOutputShape(),
598
645
  changes: z.array(jsonRecordSchema()).optional(),
599
646
  meta: jsonRecordSchema().optional(),
600
- facets: jsonRecordSchema().optional()
647
+ facets: jsonRecordSchema().optional(),
648
+ cursor: jsonRecordSchema().optional(),
649
+ continuation: jsonRecordSchema().optional()
601
650
  },
602
651
  annotations: readOnlyAnnotations
603
652
  }, (args) => runTool("get_recent_changes", config, rateLimiter, async () => {
604
- const { output_mode, ...filters } = args;
605
- const api = await client.getRecentChanges(toQuery(filters));
606
- const data = readObject(api.data);
653
+ const { output_mode, resume_cursor, max_cursor_pages, cursor_time_budget_ms, ...filters } = args;
654
+ const cursor = resolveFeedCursor(feedCursorStore, "target_changes", filters, resume_cursor);
655
+ const { min_target_score, target_opportunity_label, competition_risk, ...upstreamFilters } = filters;
656
+ const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, (afterId) => client.getRecentChanges(toQuery({
657
+ ...upstreamFilters,
658
+ minTargetScore: min_target_score,
659
+ targetOpportunityLabel: target_opportunity_label,
660
+ competitionRisk: competition_risk,
661
+ after_id: afterId,
662
+ page: afterId === undefined ? filters.page : 1
663
+ })));
664
+ const api = collected.lastApi;
665
+ const data = collected.lastData;
607
666
  const sanitizedMeta = readObject(sanitizeJson(data?.meta));
608
- const changes = readArray(data?.results)
667
+ const changes = collected.results
609
668
  .map((change) => sanitizeChange(change, config.webBaseUrl))
610
669
  .map((change) => formatChange(change, output_mode));
611
- return withApiMetadata(api, {
670
+ return applyCollectionContinuation(withApiMetadata(api, {
612
671
  changes,
613
672
  meta: stripUndefined({
614
673
  ...(sanitizedMeta ?? {}),
615
- output_mode
674
+ output_mode,
675
+ pages_fetched: collected.pagesFetched,
676
+ returned_across_pages: changes.length,
677
+ cursor_page_budget_exhausted: collected.pageBudgetExhausted,
678
+ cursor_time_budget_exhausted: collected.timeBudgetExhausted
616
679
  }),
617
- facets: sanitizeJson(data?.facets)
618
- });
680
+ facets: sanitizeJson(data?.facets),
681
+ cursor: collected.cursor,
682
+ source_requests: collected.sourceRequests.length > 1 ? collected.sourceRequests : undefined
683
+ }), "changes", "target changes", continuationStore, config);
684
+ }));
685
+ registerTool("get_repository_changes", {
686
+ title: "Get Repository Changes",
687
+ 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.",
688
+ inputSchema: {
689
+ after_id: z.number().int().min(0).optional().describe("Incremental cursor; returns events after this id in ascending order."),
690
+ resume_cursor: z.boolean().default(false).describe("Resume the in-memory cursor for this exact normalized filter set. Seed it once with after_id."),
691
+ max_cursor_pages: z.number().int().min(1).max(10).default(2),
692
+ cursor_time_budget_ms: z.number().int().min(100).max(10_000).default(3_000),
693
+ include_targets: z.boolean().default(false).describe("Include matched targets. False is faster and returns affected programs only."),
694
+ search: searchTextSchema.optional(),
695
+ platforms: platformListSchema(),
696
+ tags: tagListSchema(),
697
+ free_submission_only: z.boolean().default(false),
698
+ include_removed: z.boolean().default(false),
699
+ include_ineligible: z.boolean().default(false),
700
+ include_out_of_scope: z.boolean().default(false),
701
+ page: pageSchema,
702
+ page_size: repositoryChangesPageSizeSchema,
703
+ output_mode: repositoryChangeListModeSchema.default("compact")
704
+ },
705
+ outputSchema: {
706
+ ...apiEnvelopeOutputShape(),
707
+ repository_changes: z.array(jsonRecordSchema()).optional(),
708
+ meta: jsonRecordSchema().optional(),
709
+ facets: jsonRecordSchema().optional(),
710
+ available_facets: jsonRecordSchema().optional(),
711
+ cursor: jsonRecordSchema().optional(),
712
+ continuation: jsonRecordSchema().optional()
713
+ },
714
+ annotations: readOnlyAnnotations
715
+ }, (args) => runTool("get_repository_changes", config, rateLimiter, async () => {
716
+ if (!client.supportsEndpoint("repository_changes")) {
717
+ throw new Error("The negotiated BBRadar API contract does not expose repository changes.");
718
+ }
719
+ const { output_mode, resume_cursor, max_cursor_pages, cursor_time_budget_ms, ...filters } = args;
720
+ const cursor = resolveFeedCursor(feedCursorStore, "repository_changes", filters, resume_cursor);
721
+ const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, (afterId) => client.getRepositoryChanges(toQuery({
722
+ ...filters,
723
+ after_id: afterId,
724
+ page: afterId === undefined ? filters.page : 1
725
+ })));
726
+ const api = collected.lastApi;
727
+ const data = collected.lastData;
728
+ return applyCollectionContinuation(withApiMetadata(api, {
729
+ repository_changes: collected.results.map((change) => sanitizeRepositoryChange(change, config.webBaseUrl)).map((change) => formatRepositoryChange(change, output_mode)),
730
+ meta: stripUndefined({
731
+ ...(readObject(sanitizeJson(data?.meta)) ?? {}),
732
+ output_mode,
733
+ pages_fetched: collected.pagesFetched,
734
+ returned_across_pages: collected.results.length,
735
+ cursor_page_budget_exhausted: collected.pageBudgetExhausted,
736
+ cursor_time_budget_exhausted: collected.timeBudgetExhausted
737
+ }),
738
+ facets: sanitizeJson(data?.facets),
739
+ available_facets: sanitizeJson(data?.available_facets),
740
+ cursor: collected.cursor,
741
+ source_requests: collected.sourceRequests.length > 1 ? collected.sourceRequests : undefined
742
+ }), "repository_changes", "repository changes", continuationStore, config);
619
743
  }));
744
+ registerTool("find_repository_hunt_targets", {
745
+ title: "Find Repository-Aware Hunt Targets",
746
+ 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.",
747
+ inputSchema: {
748
+ platforms: platformListSchema(),
749
+ exclude_platforms: stringListSchema("Platforms to exclude from final matches."),
750
+ tags: tagListSchema(),
751
+ scope_tags: scopeTagListSchema(),
752
+ target_types: targetTypeListSchema(),
753
+ language_tags: languageTagListSchema(),
754
+ surface_mode: z.enum(["any", "web2", "web3"]).default("any"),
755
+ opportunity_levels: z.array(opportunityLevelSchema).max(4).default([]),
756
+ min_bounty_max: z.number().int().min(0).optional(),
757
+ require_bounty: z.boolean().default(true),
758
+ max_public_report_count: z.number().int().min(0).optional(),
759
+ include_unknown_report_count: z.boolean().default(true),
760
+ min_eligible_targets: z.number().int().min(0).default(1),
761
+ min_total_targets: z.number().int().min(0).optional(),
762
+ min_wildcards: z.number().int().min(0).max(1000).default(0),
763
+ min_added_7d: z.number().int().min(0).optional(),
764
+ changed_since: isoDateTimeSchema.optional(),
765
+ free_submission_only: z.boolean().default(false),
766
+ min_target_score: z.number().int().min(0).max(100).default(60),
767
+ target_opportunity_labels: z.array(targetOpportunityLabelSchema).max(4).default([]),
768
+ competition_risks: z.array(competitionRiskSchema).max(4).default([]),
769
+ max_program_pages: z.number().int().min(1).max(MAX_FIND_PROGRAM_PAGES).default(3),
770
+ max_program_candidates: z.number().int().min(1).max(MAX_FIND_PROGRAM_RESULTS).default(MAX_FIND_PROGRAM_RESULTS),
771
+ repository_page_size: z.number().int().min(1).max(maxRepositoryChangesPageSize).default(Math.min(100, maxRepositoryChangesPageSize)),
772
+ max_results: z.number().int().min(1).max(25).default(10),
773
+ max_targets_per_result: z.number().int().min(1).max(20).default(5),
774
+ ranking_mode: z.enum(["balanced", "highest_reward", "best_target", "newest"]).default("balanced"),
775
+ output_mode: repositoryChangeListModeSchema.default("compact"),
776
+ upstream_request_budget: z.number().int().min(2).max(MAX_FIND_UPSTREAM_REQUEST_BUDGET).default(10)
777
+ },
778
+ outputSchema: {
779
+ ...apiEnvelopeOutputShape(),
780
+ matches: z.array(jsonRecordSchema()).optional(),
781
+ programs: z.array(jsonRecordSchema()).optional(),
782
+ filters: jsonRecordSchema().optional(),
783
+ upstream_budget: jsonRecordSchema().optional(),
784
+ ranking_scope: jsonRecordSchema().optional(),
785
+ meta: jsonRecordSchema().optional()
786
+ },
787
+ annotations: readOnlyAnnotations
788
+ }, (args) => runTool("find_repository_hunt_targets", config, rateLimiter, async () => findRepositoryHuntTargets(client, config, args)));
620
789
  registerTool("get_latest_added_targets", {
621
790
  title: "Get Latest Added Targets",
622
791
  description: "Latest added targets by type with optional platform, reward, and report-count filters. For fast wildcard discovery use target_type=wildcard and include_full_target_list=false.",
@@ -630,7 +799,7 @@ export function createBbradarServer(client, config) {
630
799
  include_unknown_report_count: z.boolean().default(true),
631
800
  include_out_of_scope: z.boolean().default(false).describe("Include out-of-scope."),
632
801
  include_ineligible: z.boolean().default(false).describe("Include ineligible."),
633
- recent_changes_page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
802
+ recent_changes_page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
634
803
  max_results: z.number().int().min(1).max(MAX_RECENT_CHANGES).default(10),
635
804
  include_full_target_list: z.boolean().default(true).describe("Fetch the newest matching program's complete target list. Set false for the fastest cross-program discovery."),
636
805
  full_target_list_mode: targetListModeSchema.default("identifiers"),
@@ -701,8 +870,8 @@ export function createBbradarServer(client, config) {
701
870
  include_removed: z.boolean().default(true),
702
871
  include_out_of_scope: z.boolean().default(false),
703
872
  include_ineligible: z.boolean().default(false),
704
- page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
705
- max_targets: acceptedTargetLimitSchema.default(50),
873
+ page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
874
+ max_targets: acceptedTargetLimitSchema.default(Math.min(50, maxProgramTargetsPageSize)),
706
875
  target_list_mode: targetListModeSchema.default("identifiers")
707
876
  },
708
877
  outputSchema: {
@@ -728,7 +897,7 @@ export function createBbradarServer(client, config) {
728
897
  platforms: platformListSchema(),
729
898
  tags: tagListSchema(),
730
899
  language_tags: languageTagListSchema(),
731
- page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
900
+ page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
732
901
  max_programs: z.number().int().min(1).max(50).default(10),
733
902
  sample_size: z.number().int().min(1).max(20).default(5),
734
903
  target_list_mode: targetListModeSchema.default("identifiers")
@@ -749,8 +918,8 @@ export function createBbradarServer(client, config) {
749
918
  since: isoDateTimeSchema.optional().describe("ISO datetime."),
750
919
  target_type: filterValueSchema().optional(),
751
920
  include_ineligible: z.boolean().default(false).describe("Include ineligible."),
752
- page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
753
- max_targets: acceptedTargetLimitSchema.default(25),
921
+ page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
922
+ max_targets: acceptedTargetLimitSchema.default(Math.min(25, maxProgramTargetsPageSize)),
754
923
  target_list_mode: targetListModeSchema.default("identifiers")
755
924
  },
756
925
  outputSchema: {
@@ -774,7 +943,7 @@ export function createBbradarServer(client, config) {
774
943
  since: isoDateTimeSchema.optional(),
775
944
  target_type: filterValueSchema().optional(),
776
945
  include_ineligible: z.boolean().default(false),
777
- page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
946
+ page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
778
947
  max_targets_per_program: z.number().int().min(1).max(50).default(10),
779
948
  target_list_mode: targetListModeSchema.default("identifiers")
780
949
  },
@@ -820,14 +989,16 @@ export function createBbradarServer(client, config) {
820
989
  }));
821
990
  registerTool("find_programs", {
822
991
  title: "Find BBRadar Programs",
823
- description: "Ranked program discovery with filters and compact output.",
992
+ description: "Ranked program discovery with positive and negative platform/surface filters and compact output. Strict-filter retries reuse cached upstream pages, so relax one local filter at a time when a narrow query returns no candidates.",
824
993
  inputSchema: {
825
994
  platforms: platformListSchema(),
995
+ exclude_platforms: stringListSchema("Platforms to exclude after retrieval. Use list_filters first when the user asks for a geographic/platform exclusion without naming exact platform values."),
826
996
  tags: tagListSchema(),
827
997
  scope_tags: scopeTagListSchema(),
828
998
  target_types: targetTypeListSchema(),
829
999
  language_tags: languageTagListSchema(),
830
1000
  web3: z.boolean().default(false).describe("Require Web3 signals."),
1001
+ 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."),
831
1002
  opportunity_levels: z.array(opportunityLevelSchema).max(4).default([]),
832
1003
  updated_since: isoDateTimeSchema.optional(),
833
1004
  min_bounty_min: z.number().int().min(0).optional(),
@@ -1211,7 +1382,7 @@ export function createBbradarServer(client, config) {
1211
1382
  platforms: platformListSchema(),
1212
1383
  tags: tagListSchema(),
1213
1384
  language_tags: languageTagListSchema(),
1214
- page_size: recentChangesPageSizeSchema.default(MAX_RECENT_CHANGES),
1385
+ page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
1215
1386
  max_programs: z.number().int().min(1).max(50).default(10),
1216
1387
  sample_size: z.number().int().min(1).max(20).default(5),
1217
1388
  target_list_mode: targetListModeSchema.default("identifiers")
@@ -1387,7 +1558,7 @@ export function createBbradarServer(client, config) {
1387
1558
  annotations: readOnlyAnnotations
1388
1559
  }, (args) => runTool("get_bbradar_guide", config, rateLimiter, async () => ({
1389
1560
  request_id: randomUUID(),
1390
- guide: bbradarGuide(args.topic, config.toolProfile)
1561
+ guide: bbradarGuide(args.topic, config.toolProfile, client)
1391
1562
  })));
1392
1563
  registerTool("get_mcp_status", {
1393
1564
  title: "Get BBRadar MCP Status",
@@ -1417,6 +1588,13 @@ export function createBbradarServer(client, config) {
1417
1588
  compact_tool_text: config.compactToolText,
1418
1589
  tool_profile: config.toolProfile,
1419
1590
  request_timeout_ms: config.requestTimeoutMs,
1591
+ feed_cursors: feedCursorStore.stats(),
1592
+ continuations: {
1593
+ entries: continuationStore.size,
1594
+ max_entries: MAX_LOCAL_EXPORT_RESOURCES,
1595
+ inline_limit: config.continuationInlineLimit ?? 50,
1596
+ ttl_ms: config.continuationTtlMs ?? LOCAL_EXPORT_TTL_MS
1597
+ },
1420
1598
  validate_on_startup: config.validateOnStartup,
1421
1599
  transport: "stdio",
1422
1600
  remote_streamable_http: "not_enabled_for_local_setup",
@@ -1626,7 +1804,7 @@ export function createBbradarServer(client, config) {
1626
1804
  include_removed: z.boolean().default(true),
1627
1805
  include_ineligible: z.boolean().default(false),
1628
1806
  include_out_of_scope: z.boolean().default(false),
1629
- page_size: recentChangesPageSizeSchema.default(50)
1807
+ page_size: recentChangesPageSizeSchema.default(Math.min(50, maxTargetChangesPageSize))
1630
1808
  },
1631
1809
  outputSchema: {
1632
1810
  ...apiEnvelopeOutputShape(),
@@ -1638,16 +1816,16 @@ export function createBbradarServer(client, config) {
1638
1816
  }, (args) => runTool("get_program_delta", config, rateLimiter, async () => runProgramIdTool(client, config, args.program_id, (programId) => getProgramDelta(client, config, { ...args, program_id: programId }))));
1639
1817
  registerTool("export_targets", {
1640
1818
  title: "Export BBRadar Targets",
1641
- description: `Read-only target export. limit max ${MAX_TARGET_EXPORT}.`,
1819
+ description: `Read-only target export. limit max ${maxTargetExport}.`,
1642
1820
  inputSchema: {
1643
- program_ids: z.array(programIdentifierSchema).max(50).default([]),
1821
+ program_ids: z.array(programIdentifierSchema).max(maxTargetExportProgramIds).default([]),
1644
1822
  platforms: platformListSchema(),
1645
1823
  tags: tagListSchema(),
1646
1824
  opportunity_levels: z.array(z.enum(["elite", "hot", "strong", "potential"])).max(4).default([]),
1647
1825
  include_out_of_scope: z.boolean().default(false),
1648
1826
  include_ineligible: z.boolean().default(false),
1649
1827
  format: z.enum(["json", "csv"]).default("json"),
1650
- limit: z.number().int().min(1).max(MAX_TARGET_EXPORT).default(MAX_TARGET_EXPORT)
1828
+ limit: z.number().int().min(1).max(maxTargetExport).default(maxTargetExport)
1651
1829
  },
1652
1830
  outputSchema: {
1653
1831
  ...apiEnvelopeOutputShape(),
@@ -1694,12 +1872,12 @@ export function createBbradarServer(client, config) {
1694
1872
  }
1695
1873
  }));
1696
1874
  registerPrompts(server, config.toolProfile);
1697
- registerResources(server, client, config, exportStore);
1875
+ registerResources(server, client, config, exportStore, continuationStore);
1698
1876
  return server;
1699
1877
  }
1700
- function bbradarGuide(topic, profile) {
1878
+ function bbradarGuide(topic, profile, client) {
1701
1879
  if (profile === "core") {
1702
- return coreBbradarGuide(topic);
1880
+ return coreBbradarGuide(topic, client);
1703
1881
  }
1704
1882
  const primaryEntryPoints = [
1705
1883
  {
@@ -1708,6 +1886,12 @@ function bbradarGuide(topic, profile) {
1708
1886
  minimum_arguments: {},
1709
1887
  useful_arguments: ["platforms", "target_types", "language_tags", "min_bounty_max", "max_public_report_count", "fresh_launch_days", "has_api_scope"]
1710
1888
  },
1889
+ {
1890
+ tool: "find_repository_hunt_targets",
1891
+ use_when: "The request combines repository activity with reward, target-quality, platform, technology, or freshness filters.",
1892
+ minimum_arguments: {},
1893
+ useful_arguments: ["platforms", "exclude_platforms", "surface_mode", "min_bounty_max", "min_target_score", "changed_since", "ranking_mode"]
1894
+ },
1711
1895
  {
1712
1896
  tool: "run_program_name_action",
1713
1897
  use_when: "The user names one program and asks for a brief, targets, new targets, scope delta, or target breakdown.",
@@ -1732,6 +1916,11 @@ function bbradarGuide(topic, profile) {
1732
1916
  first_tool: "find_program_candidates",
1733
1917
  then: "Use get_program_brief on the best program_id if the user wants details."
1734
1918
  },
1919
+ {
1920
+ user_goal: "Web2 programs with broad scope, good rewards, recent target activity, and excluded platforms",
1921
+ first_tool: "list_filters",
1922
+ then: "Resolve the excluded platform names, then call find_programs with exclude_platforms, exclude_web3=true, exclude_non_web=true, reward/scope/freshness thresholds, and sort_by=most_new_targets. If strict local filters return zero, relax one threshold and retry; cached source pages make the retry cheap."
1923
+ },
1735
1924
  {
1736
1925
  user_goal: "named program summary",
1737
1926
  first_tool: "run_program_name_action",
@@ -1752,6 +1941,18 @@ function bbradarGuide(topic, profile) {
1752
1941
  first_tool: "find_recently_updated_programs",
1753
1942
  then: "Use get_latest_added_targets for global added targets, or check_program_new_targets when the user names one program."
1754
1943
  },
1944
+ {
1945
+ user_goal: "repository changes or repository polling",
1946
+ first_tool: "get_repository_changes",
1947
+ arguments: { include_targets: false, max_cursor_pages: 2 },
1948
+ then: "For incremental polling, seed once with after_id and later set resume_cursor=true with the same filters."
1949
+ },
1950
+ {
1951
+ user_goal: "hunt targets combining repository activity with rewards, target quality, platforms, or Web2/Web3 constraints",
1952
+ first_tool: "find_repository_hunt_targets",
1953
+ arguments: { platforms: ["requested platforms"], surface_mode: "web3", min_bounty_max: 5000, min_target_score: 60 },
1954
+ then: "Use this single composite call instead of manually chaining program discovery and repository tools. Omit or change any example filter the user did not request."
1955
+ },
1755
1956
  {
1756
1957
  user_goal: "latest added wildcard targets with platform, reward, or report-count filters",
1757
1958
  first_tool: "get_latest_added_targets",
@@ -1797,10 +1998,12 @@ function bbradarGuide(topic, profile) {
1797
1998
  };
1798
1999
  const filters = {
1799
2000
  platforms: "Only set when the user explicitly names a platform. Do not infer Immunefi, HackerOne, etc.",
2001
+ exclude_platforms: "Use exact observed values from list_filters for user-requested platform or geographic exclusions.",
1800
2002
  tags: "Broad combined tag filter across scope, language, and target-type signals.",
1801
2003
  scope_tags: "Scope category signals such as domain, api, mobile, smart-contract, wildcard.",
1802
2004
  target_types: "Target-kind signals such as domain, api, url, mobile, smart-contract.",
1803
2005
  language_tags: "Technology/language signals such as javascript, solidity, web3, php, python.",
2006
+ exclude_web3: "Set true for Web2-only requests.",
1804
2007
  vdp_mode: "Default exclude. Include VDP/no-bounty data only when asked."
1805
2008
  };
1806
2009
  const examples = [
@@ -1851,24 +2054,20 @@ function bbradarGuide(topic, profile) {
1851
2054
  require_bounty: true
1852
2055
  },
1853
2056
  primary_entry_points: topic === "start" ? primaryEntryPoints : primaryEntryPoints.filter((entry) => entry.tool !== "get_bbradar_guide"),
1854
- decision_tree: decisionTree.filter((entry) => topic === "start" || guideEntryMatchesTopic(topic, entry)),
2057
+ decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
2058
+ (topic === "start" || guideEntryMatchesTopic(topic, entry))),
1855
2059
  filters,
1856
2060
  advanced_tools: advancedTools,
1857
2061
  examples
1858
2062
  };
1859
2063
  }
1860
- function coreBbradarGuide(topic) {
2064
+ function coreBbradarGuide(topic, client) {
1861
2065
  const decisionTree = [
1862
2066
  {
1863
2067
  user_goal: "find programs to hunt",
1864
2068
  first_tool: "find_programs",
1865
2069
  arguments: { max_results: 10, output_mode: "compact" }
1866
2070
  },
1867
- {
1868
- user_goal: "named program summary or action",
1869
- first_tool: "run_program_name_action",
1870
- arguments: { query: "program name", action: "brief" }
1871
- },
1872
2071
  {
1873
2072
  user_goal: "named program targets",
1874
2073
  first_tool: "get_program_eligible_targets_by_name",
@@ -1884,6 +2083,17 @@ function coreBbradarGuide(topic) {
1884
2083
  first_tool: "get_recent_changes",
1885
2084
  then: "Use get_latest_added_targets when only newly added targets are needed."
1886
2085
  },
2086
+ {
2087
+ user_goal: "repository changes or repository polling",
2088
+ first_tool: "get_repository_changes",
2089
+ arguments: { include_targets: false, max_cursor_pages: 2 }
2090
+ },
2091
+ {
2092
+ user_goal: "hunt targets combining repository activity with rewards, target quality, platforms, or Web2/Web3 constraints",
2093
+ first_tool: "find_repository_hunt_targets",
2094
+ arguments: { platforms: ["requested platforms"], surface_mode: "web3", min_bounty_max: 5000, min_target_score: 60 },
2095
+ then: "Use this one call instead of manually chaining program and repository tools. Omit or change any example filter the user did not request."
2096
+ },
1887
2097
  {
1888
2098
  user_goal: "export targets",
1889
2099
  first_tool: "export_targets",
@@ -1909,15 +2119,21 @@ function coreBbradarGuide(topic) {
1909
2119
  },
1910
2120
  primary_entry_points: [
1911
2121
  "find_programs",
2122
+ "find_repository_hunt_targets",
1912
2123
  "run_program_name_action",
1913
2124
  "get_program_eligible_targets_by_name",
1914
2125
  "find_programs_by_target",
1915
2126
  "get_bbradar_guide"
1916
2127
  ],
1917
- decision_tree: decisionTree.filter((entry) => topic === "start" || guideEntryMatchesTopic(topic, entry)),
1918
- available_tools: [...CORE_TOOL_NAMES]
2128
+ decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
2129
+ (topic === "start" || guideEntryMatchesTopic(topic, entry))),
2130
+ available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name))
1919
2131
  };
1920
2132
  }
2133
+ function toolAvailableForContract(client, toolName) {
2134
+ const endpoint = TOOL_ENDPOINT_REQUIREMENTS[toolName];
2135
+ return endpoint === undefined || client.supportsEndpoint(endpoint);
2136
+ }
1921
2137
  function guideEntryMatchesTopic(topic, entry) {
1922
2138
  const goal = typeof entry.user_goal === "string" ? entry.user_goal : "";
1923
2139
  const firstTool = typeof entry.first_tool === "string" ? entry.first_tool : "";
@@ -1944,15 +2160,52 @@ function guideEntryMatchesTopic(topic, entry) {
1944
2160
  }
1945
2161
  return true;
1946
2162
  }
1947
- function registerResources(server, client, config, exportStore) {
2163
+ function registerResources(server, client, config, exportStore, continuationStore) {
2164
+ server.registerResource("continuation", new ResourceTemplate("bbradar://continuations{?continuation_id,offset,limit}", { list: undefined }), {
2165
+ title: "BBRadar Result Continuation",
2166
+ description: "Bounded, short-lived in-memory continuation page for a large tool result.",
2167
+ mimeType: "application/json"
2168
+ }, async (uri) => {
2169
+ const continuationId = uri.searchParams.get("continuation_id") ?? "";
2170
+ pruneExpiredContinuations(continuationStore);
2171
+ const stored = continuationStore.get(continuationId);
2172
+ if (!stored) {
2173
+ return jsonResource(uri.toString(), {
2174
+ request_id: randomUUID(),
2175
+ error: { message: "Continuation not found or expired from local memory." }
2176
+ });
2177
+ }
2178
+ const offset = boundedResourceInteger(uri.searchParams.get("offset"), 0, 0, stored.items.length);
2179
+ const limit = boundedResourceInteger(uri.searchParams.get("limit"), config.continuationInlineLimit ?? 50, 1, 100);
2180
+ const items = stored.items.slice(offset, offset + limit);
2181
+ const nextOffset = offset + items.length;
2182
+ const hasMore = nextOffset < stored.items.length;
2183
+ return jsonResource(uri.toString(), {
2184
+ request_id: randomUUID(),
2185
+ continuation_id: continuationId,
2186
+ label: stored.label,
2187
+ items,
2188
+ meta: {
2189
+ offset,
2190
+ limit,
2191
+ returned: items.length,
2192
+ total: stored.items.length,
2193
+ has_more: hasMore,
2194
+ next_resource_uri: hasMore
2195
+ ? continuationResourceUri(continuationId, nextOffset, limit)
2196
+ : undefined
2197
+ }
2198
+ });
2199
+ });
1948
2200
  server.registerResource("target-export", new ResourceTemplate("bbradar://exports{?export_id}", { list: undefined }), {
1949
2201
  title: "BBRadar Target Export",
1950
- description: "In-memory local target export payload by export_id query parameter.",
2202
+ description: "Bounded, short-lived in-memory target export payload by export_id query parameter.",
1951
2203
  mimeType: "application/json"
1952
2204
  }, async (uri) => {
1953
2205
  const exportId = uri.searchParams.get("export_id") ?? "";
1954
- const payload = exportStore.get(exportId);
1955
- if (!payload) {
2206
+ pruneExpiredExports(exportStore);
2207
+ const storedExport = exportStore.get(exportId);
2208
+ if (!storedExport) {
1956
2209
  return jsonResource(uri.toString(), {
1957
2210
  request_id: randomUUID(),
1958
2211
  error: {
@@ -1963,7 +2216,7 @@ function registerResources(server, client, config, exportStore) {
1963
2216
  return jsonResource(uri.toString(), {
1964
2217
  request_id: randomUUID(),
1965
2218
  export_id: exportId,
1966
- export: payload
2219
+ export: storedExport.payload
1967
2220
  });
1968
2221
  });
1969
2222
  server.registerResource("program", new ResourceTemplate("bbradar://program{?program_id}", { list: undefined }), {
@@ -3498,6 +3751,10 @@ function programsByTargetResult(input, warnings, sourceRequests, matches, budget
3498
3751
  });
3499
3752
  }
3500
3753
  async function searchProgramsByTargetApi(client, config, input, budget, warnings, sourceRequests) {
3754
+ if (!client.supportsEndpoint("target_search")) {
3755
+ warnings.push("The negotiated API contract does not expose direct target search; using sampled program target lists.");
3756
+ return undefined;
3757
+ }
3501
3758
  const searchQueries = targetApiSearchQueries(input.target_query);
3502
3759
  const grouped = new Map();
3503
3760
  let rawMatchesScanned = 0;
@@ -3519,7 +3776,7 @@ async function searchProgramsByTargetApi(client, config, input, budget, warnings
3519
3776
  include_out_of_scope: input.include_out_of_scope,
3520
3777
  include_ineligible: input.include_ineligible,
3521
3778
  page: 1,
3522
- page_size: Math.max(input.max_results * input.max_targets_per_program, input.max_results)
3779
+ page_size: Math.min(client.endpointNumber("target_search", "max_page_size", MAX_TARGETS_PER_PROGRAM), Math.max(input.max_results * input.max_targets_per_program, input.max_results))
3523
3780
  });
3524
3781
  refundBudgetIfNoUpstreamRequest(budget, api);
3525
3782
  }
@@ -4096,8 +4353,18 @@ function latestAddedProgramMatches(change, input, platformFilters) {
4096
4353
  return true;
4097
4354
  }
4098
4355
  async function getProgramTargetsPayload(client, input, programId) {
4099
- const api = await client.getProgramTargets(programId);
4356
+ const api = await client.getProgramTargets(programId, {
4357
+ search: input.search,
4358
+ target_type: input.target_type,
4359
+ eligibility: input.include_ineligible ? "all" : "eligible",
4360
+ minTargetScore: input.min_target_score,
4361
+ targetOpportunityLabel: input.target_opportunity_label,
4362
+ competitionRisk: input.competition_risk,
4363
+ page: 1,
4364
+ page_size: client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM)
4365
+ });
4100
4366
  const data = readObject(api.data);
4367
+ const upstreamMeta = readObject(data?.meta);
4101
4368
  const rawTargets = readArray(data?.targets);
4102
4369
  const sanitizedTargets = rawTargets.map(sanitizeTarget).filter((target) => targetHasAllowedScope(target, input));
4103
4370
  const targets = sanitizedTargets.slice(input.offset, input.offset + input.limit);
@@ -4108,10 +4375,13 @@ async function getProgramTargetsPayload(client, input, programId) {
4108
4375
  offset: input.offset,
4109
4376
  limit: input.limit,
4110
4377
  output_mode: input.output_mode,
4111
- total_active_targets: rawTargets.length,
4378
+ total_active_targets: readNumber(upstreamMeta?.total) ?? rawTargets.length,
4112
4379
  total_after_filters: sanitizedTargets.length,
4113
4380
  returned: targets.length,
4114
- has_more: input.offset + input.limit < sanitizedTargets.length,
4381
+ has_more: input.offset + input.limit < sanitizedTargets.length || readBoolean(upstreamMeta?.has_more) === true,
4382
+ upstream_has_more: readBoolean(upstreamMeta?.has_more),
4383
+ possibly_incomplete: readBoolean(upstreamMeta?.has_more) === true,
4384
+ data_capabilities: readObject(upstreamMeta?.data_capabilities),
4115
4385
  target_source: "program_targets"
4116
4386
  }
4117
4387
  });
@@ -5196,6 +5466,79 @@ function programTargetsResourceUri(programId) {
5196
5466
  function exportResourceUri(exportId) {
5197
5467
  return `bbradar://exports?export_id=${encodeURIComponent(exportId)}`;
5198
5468
  }
5469
+ function continuationResourceUri(continuationId, offset, limit) {
5470
+ return `bbradar://continuations?continuation_id=${encodeURIComponent(continuationId)}&offset=${offset}&limit=${limit}`;
5471
+ }
5472
+ function applyCollectionContinuation(payload, field, label, store, config) {
5473
+ const items = Array.isArray(payload[field]) ? payload[field] : undefined;
5474
+ const inlineLimit = Math.min(100, Math.max(1, config.continuationInlineLimit ?? 50));
5475
+ if (!items || items.length <= inlineLimit) {
5476
+ return payload;
5477
+ }
5478
+ const continuationId = randomUUID();
5479
+ const expiresAt = Date.now() + (config.continuationTtlMs ?? LOCAL_EXPORT_TTL_MS);
5480
+ const stored = rememberContinuation(store, continuationId, label, items, expiresAt);
5481
+ if (!stored) {
5482
+ return {
5483
+ ...payload,
5484
+ [field]: items.slice(0, inlineLimit),
5485
+ warnings: uniqueStrings([
5486
+ ...readArray(payload.warnings).filter((entry) => typeof entry === "string"),
5487
+ "The complete result exceeded the local continuation-store byte limit; only the inline prefix is available."
5488
+ ])
5489
+ };
5490
+ }
5491
+ return {
5492
+ ...payload,
5493
+ [field]: items.slice(0, inlineLimit),
5494
+ continuation: {
5495
+ continuation_id: continuationId,
5496
+ resource_uri: continuationResourceUri(continuationId, inlineLimit, inlineLimit),
5497
+ collection: field,
5498
+ total: items.length,
5499
+ inline_returned: inlineLimit,
5500
+ remaining: items.length - inlineLimit,
5501
+ expires_at: new Date(expiresAt).toISOString()
5502
+ }
5503
+ };
5504
+ }
5505
+ function rememberContinuation(store, id, label, items, expiresAt) {
5506
+ const byteLength = Buffer.byteLength(JSON.stringify(items), "utf8");
5507
+ pruneExpiredContinuations(store);
5508
+ if (byteLength > MAX_LOCAL_EXPORT_BYTES) {
5509
+ return false;
5510
+ }
5511
+ while (store.size >= MAX_LOCAL_EXPORT_RESOURCES ||
5512
+ retainedContinuationBytes(store) + byteLength > MAX_LOCAL_EXPORT_BYTES) {
5513
+ const oldest = store.keys().next().value;
5514
+ if (oldest === undefined) {
5515
+ break;
5516
+ }
5517
+ store.delete(oldest);
5518
+ }
5519
+ store.set(id, { items, label, byteLength, expiresAt });
5520
+ return true;
5521
+ }
5522
+ function pruneExpiredContinuations(store, now = Date.now()) {
5523
+ for (const [id, value] of store) {
5524
+ if (value.expiresAt <= now) {
5525
+ store.delete(id);
5526
+ }
5527
+ }
5528
+ }
5529
+ function retainedContinuationBytes(store) {
5530
+ let total = 0;
5531
+ for (const value of store.values()) {
5532
+ total += value.byteLength;
5533
+ }
5534
+ return total;
5535
+ }
5536
+ function boundedResourceInteger(raw, fallback, minimum, maximum) {
5537
+ const parsed = raw === null ? fallback : Number.parseInt(raw, 10);
5538
+ return Number.isFinite(parsed)
5539
+ ? Math.min(maximum, Math.max(minimum, parsed))
5540
+ : fallback;
5541
+ }
5199
5542
  function jsonResource(uri, payload) {
5200
5543
  return {
5201
5544
  contents: [
@@ -5208,41 +5551,86 @@ function jsonResource(uri, payload) {
5208
5551
  };
5209
5552
  }
5210
5553
  function rememberExport(exportStore, exportId, payload) {
5554
+ const now = Date.now();
5555
+ const byteLength = Buffer.byteLength(JSON.stringify(payload), "utf8");
5556
+ pruneExpiredExports(exportStore, now);
5211
5557
  if (exportStore.has(exportId)) {
5212
5558
  exportStore.delete(exportId);
5213
5559
  }
5214
- while (exportStore.size >= MAX_LOCAL_EXPORT_RESOURCES) {
5560
+ while (exportStore.size >= MAX_LOCAL_EXPORT_RESOURCES ||
5561
+ retainedExportBytes(exportStore) + byteLength > MAX_LOCAL_EXPORT_BYTES) {
5215
5562
  const oldestKey = exportStore.keys().next().value;
5216
5563
  if (oldestKey === undefined) {
5217
5564
  break;
5218
5565
  }
5219
5566
  exportStore.delete(oldestKey);
5220
5567
  }
5221
- exportStore.set(exportId, payload);
5568
+ exportStore.set(exportId, {
5569
+ payload,
5570
+ byteLength,
5571
+ expiresAt: now + LOCAL_EXPORT_TTL_MS
5572
+ });
5573
+ }
5574
+ function pruneExpiredExports(exportStore, now = Date.now()) {
5575
+ for (const [exportId, storedExport] of exportStore) {
5576
+ if (storedExport.expiresAt <= now) {
5577
+ exportStore.delete(exportId);
5578
+ }
5579
+ }
5580
+ }
5581
+ function retainedExportBytes(exportStore) {
5582
+ let total = 0;
5583
+ for (const storedExport of exportStore.values()) {
5584
+ total += storedExport.byteLength;
5585
+ }
5586
+ return total;
5222
5587
  }
5223
5588
  function previewExportPayload(payload) {
5589
+ if (typeof payload === "string") {
5590
+ return previewExportText(payload);
5591
+ }
5224
5592
  if (Array.isArray(payload)) {
5225
5593
  return payload.slice(0, EXPORT_PREVIEW_LIMIT);
5226
5594
  }
5227
- if (readObject(payload) && Array.isArray(payload.targets)) {
5595
+ const object = readObject(payload);
5596
+ if (object && typeof object.content === "string") {
5228
5597
  return {
5229
- ...payload,
5230
- targets: payload.targets.slice(0, EXPORT_PREVIEW_LIMIT)
5598
+ ...object,
5599
+ content: previewExportText(object.content)
5600
+ };
5601
+ }
5602
+ if (object && Array.isArray(object.targets)) {
5603
+ return {
5604
+ ...object,
5605
+ targets: object.targets.slice(0, EXPORT_PREVIEW_LIMIT)
5231
5606
  };
5232
5607
  }
5233
5608
  return payload;
5234
5609
  }
5235
5610
  function previewExportCount(payload) {
5611
+ if (typeof payload === "string") {
5612
+ return Math.min(Math.max(0, payload.split(/\r?\n/).length - 1), EXPORT_PREVIEW_LIMIT);
5613
+ }
5236
5614
  if (Array.isArray(payload)) {
5237
5615
  return Math.min(payload.length, EXPORT_PREVIEW_LIMIT);
5238
5616
  }
5239
5617
  const object = readObject(payload);
5618
+ if (typeof object?.content === "string") {
5619
+ return Math.min(Math.max(0, object.content.split(/\r?\n/).length - 1), EXPORT_PREVIEW_LIMIT);
5620
+ }
5240
5621
  const targets = object?.targets;
5241
5622
  if (Array.isArray(targets)) {
5242
5623
  return Math.min(targets.length, EXPORT_PREVIEW_LIMIT);
5243
5624
  }
5244
5625
  return payload === undefined || payload === null ? 0 : 1;
5245
5626
  }
5627
+ function previewExportText(payload) {
5628
+ const lines = payload.split(/\r?\n/);
5629
+ const preview = lines.slice(0, EXPORT_PREVIEW_LIMIT + 1).join("\n");
5630
+ return lines.length > EXPORT_PREVIEW_LIMIT + 1
5631
+ ? `${preview}\n[preview truncated; read the export resource for the complete payload]`
5632
+ : preview;
5633
+ }
5246
5634
  function collectResourceLinks(payload) {
5247
5635
  const links = new Map();
5248
5636
  for (const program of extractProgramsFromPayload(payload)) {
@@ -5284,6 +5672,19 @@ function collectResourceLinks(payload) {
5284
5672
  mimeType: "application/json"
5285
5673
  });
5286
5674
  }
5675
+ const continuation = readObject(payload.continuation);
5676
+ const continuationUri = stringField(continuation, "resource_uri");
5677
+ const continuationId = stringField(continuation, "continuation_id");
5678
+ if (continuationUri && continuationId) {
5679
+ links.set(continuationUri, {
5680
+ type: "resource_link",
5681
+ uri: continuationUri,
5682
+ name: `${continuationId} continuation`,
5683
+ title: "Result continuation",
5684
+ description: "Read the next bounded page of this local in-memory result.",
5685
+ mimeType: "application/json"
5686
+ });
5687
+ }
5287
5688
  return [...links.values()];
5288
5689
  }
5289
5690
  function extractProgramsFromPayload(payload) {
@@ -5333,9 +5734,18 @@ function compactProgram(program) {
5333
5734
  handle: stringField(program, "handle"),
5334
5735
  name: stringField(program, "name"),
5335
5736
  platform: stringField(program, "platform"),
5737
+ catalog: stringField(program, "catalog"),
5738
+ program_type: stringField(program, "program_type"),
5336
5739
  platform_url: stringField(program, "platform_url"),
5337
5740
  bbradar_url: stringField(program, "bbradar_url"),
5741
+ policy_url: stringField(program, "policy_url"),
5742
+ reporting_url: stringField(program, "reporting_url"),
5743
+ reward_status: stringField(program, "reward_status"),
5744
+ data_capabilities: readObject(program.data_capabilities),
5338
5745
  opportunity_tag: readObject(program.opportunity_tag),
5746
+ competition_risk: stringField(program, "competition_risk"),
5747
+ competition_risk_reason: stringField(program, "competition_risk_reason"),
5748
+ submission_cost: readObject(program.submission_cost),
5339
5749
  reward_range: readObject(program.reward_range),
5340
5750
  public_report_count: readNumber(program.public_report_count),
5341
5751
  public_report_count_label: stringField(program, "public_report_count_label"),
@@ -5385,6 +5795,30 @@ function formatChange(change, mode) {
5385
5795
  target: target ? compactTarget(target) : undefined
5386
5796
  });
5387
5797
  }
5798
+ function formatRepositoryChange(change, mode) {
5799
+ if (mode === "full") {
5800
+ return change;
5801
+ }
5802
+ const repository = readObject(change.repository);
5803
+ return stripUndefined({
5804
+ id: change.id,
5805
+ event_type: stringField(change, "event_type"),
5806
+ ref_name: stringField(change, "ref_name"),
5807
+ observed_at: stringField(change, "observed_at"),
5808
+ repository: repository
5809
+ ? stripUndefined({
5810
+ provider: stringField(repository, "provider"),
5811
+ full_name: stringField(repository, "full_name"),
5812
+ url: stringField(repository, "url")
5813
+ })
5814
+ : undefined,
5815
+ changes_url: stringField(change, "changes_url"),
5816
+ programs: readArray(change.programs).map((program) => formatProgram(readObject(program) ?? {}, "compact")),
5817
+ targets: Array.isArray(change.targets)
5818
+ ? change.targets.map((target) => compactTarget(readObject(target) ?? {}))
5819
+ : undefined
5820
+ });
5821
+ }
5388
5822
  function buildTargetScopeSummary(targets, mode, groupLimit, includeOutOfScope, includeIneligible) {
5389
5823
  const bountyTargets = targets.filter((target) => target.in_scope === true && target.eligible_for_bounty === true);
5390
5824
  const groups = {
@@ -5636,6 +6070,14 @@ function candidateLooksContestLike(candidate) {
5636
6070
  return tagIncludesAnyNormalized(candidate.normalizedSearchSignals, WEB3_CONTEST_SIGNALS);
5637
6071
  }
5638
6072
  function candidateHasKnownNoBounty(candidate) {
6073
+ const programType = normalizeTag(stringField(candidate.program, "program_type") ?? "");
6074
+ const rewardStatus = normalizeTag(stringField(candidate.program, "reward_status") ?? "");
6075
+ if (programType === "vdp" || rewardStatus === "no_reward" || rewardStatus === "no reward") {
6076
+ return true;
6077
+ }
6078
+ if (programType === "paid" || rewardStatus === "paid") {
6079
+ return false;
6080
+ }
5639
6081
  if (candidate.bountyMin === undefined && candidate.bountyMax === undefined) {
5640
6082
  return false;
5641
6083
  }
@@ -5824,6 +6266,16 @@ function normalizeFindProgramsInput(input) {
5824
6266
  const warnings = [];
5825
6267
  const normalized = { ...input };
5826
6268
  normalized.vdp_mode ??= "exclude";
6269
+ normalized.exclude_platforms = uniqueStrings(normalized.exclude_platforms ?? []);
6270
+ if (normalized.web3 && normalized.exclude_web3) {
6271
+ normalized.web3 = false;
6272
+ warnings.push("exclude_web3=true took precedence over the contradictory web3=true filter.");
6273
+ }
6274
+ const includedPlatforms = new Set(normalized.platforms.map(normalizeTag));
6275
+ const overlappingPlatforms = normalized.exclude_platforms.filter((platform) => includedPlatforms.has(normalizeTag(platform)));
6276
+ if (overlappingPlatforms.length > 0) {
6277
+ warnings.push(`Excluded platform filters take precedence over included values: ${overlappingPlatforms.join(", ")}.`);
6278
+ }
5827
6279
  normalized.max_pages = clampLimit("max_pages", normalized.max_pages, MAX_FIND_PROGRAM_PAGES, warnings);
5828
6280
  normalized.max_results = clampLimit("max_results", normalized.max_results, MAX_FIND_PROGRAM_RESULTS, warnings);
5829
6281
  normalized.target_sample_programs = clampLimit("target_sample_programs", normalized.target_sample_programs, MAX_FIND_TARGET_SAMPLE_PROGRAMS, warnings);
@@ -5842,6 +6294,285 @@ function clampLimit(name, value, max, warnings) {
5842
6294
  warnings.push(`${name} was clamped from ${value} to ${max}.`);
5843
6295
  return max;
5844
6296
  }
6297
+ async function findRepositoryHuntTargets(client, config, input) {
6298
+ const warnings = [];
6299
+ const repositoryRequestReserve = input.upstream_request_budget >= 3 ? 2 : 1;
6300
+ const programRequestBudget = Math.max(1, input.upstream_request_budget - repositoryRequestReserve);
6301
+ const programInput = {
6302
+ platforms: input.platforms,
6303
+ exclude_platforms: input.exclude_platforms,
6304
+ tags: input.tags,
6305
+ scope_tags: input.scope_tags,
6306
+ target_types: input.target_types,
6307
+ language_tags: input.language_tags,
6308
+ web3: input.surface_mode === "web3",
6309
+ exclude_web3: input.surface_mode === "web2",
6310
+ opportunity_levels: input.opportunity_levels,
6311
+ updated_since: undefined,
6312
+ min_bounty_min: undefined,
6313
+ min_bounty_max: input.min_bounty_max,
6314
+ max_bounty_max: undefined,
6315
+ require_bounty: input.require_bounty,
6316
+ max_public_report_count: input.max_public_report_count,
6317
+ require_known_report_count: false,
6318
+ include_unknown_report_count: input.include_unknown_report_count,
6319
+ exclude_ctf: true,
6320
+ exclude_mobile_only: false,
6321
+ exclude_non_web: input.surface_mode === "web2",
6322
+ has_api_scope: false,
6323
+ contest_like: false,
6324
+ vdp_mode: "exclude",
6325
+ fresh_launch_days: undefined,
6326
+ min_wildcards: input.min_wildcards,
6327
+ min_eligible_targets: input.min_eligible_targets,
6328
+ min_total_targets: input.min_total_targets,
6329
+ min_added_24h: undefined,
6330
+ min_added_7d: input.min_added_7d,
6331
+ max_pages: input.max_program_pages,
6332
+ max_results: input.max_program_candidates,
6333
+ sort_by: "highest_reward",
6334
+ include_target_samples: false,
6335
+ target_sample_programs: 0,
6336
+ target_sample_size: 1,
6337
+ only_in_scope_targets: true,
6338
+ only_bounty_eligible_targets: true,
6339
+ target_samples_only_wildcards: false,
6340
+ output_mode: "compact",
6341
+ upstream_request_budget: programRequestBudget
6342
+ };
6343
+ const [programPayload, repository] = await Promise.all([
6344
+ findPrograms(client, config, programInput),
6345
+ fetchRepositoryHuntPage(client, input, warnings, repositoryRequestReserve)
6346
+ ]);
6347
+ const candidatePrograms = readArray(programPayload.programs)
6348
+ .map(readObject)
6349
+ .filter((program) => program !== undefined);
6350
+ const candidatesById = new Map(candidatePrograms
6351
+ .map((program) => [stringField(program, "id"), program])
6352
+ .filter((entry) => entry[0] !== undefined));
6353
+ const changedSinceTime = input.changed_since ? Date.parse(input.changed_since) : undefined;
6354
+ const rankedMatches = [];
6355
+ let qualifyingTargetCount = 0;
6356
+ for (const rawChange of readArray(readObject(repository.api.data)?.results)) {
6357
+ const change = sanitizeRepositoryChange(rawChange, config.webBaseUrl);
6358
+ const observedAtText = stringField(change, "observed_at");
6359
+ const observedAt = observedAtText ? Date.parse(observedAtText) : 0;
6360
+ if (changedSinceTime !== undefined && (!Number.isFinite(observedAt) || observedAt < changedSinceTime)) {
6361
+ continue;
6362
+ }
6363
+ const programs = readArray(change.programs)
6364
+ .map((program) => stringField(readObject(program), "id"))
6365
+ .filter((id) => id !== undefined)
6366
+ .map((id) => candidatesById.get(id))
6367
+ .filter((program) => program !== undefined);
6368
+ if (programs.length === 0) {
6369
+ continue;
6370
+ }
6371
+ const targets = readArray(change.targets)
6372
+ .map(readObject)
6373
+ .filter((target) => target !== undefined)
6374
+ .filter((target) => repositoryHuntTargetMatches(target, input))
6375
+ .sort((left, right) => (readNumber(right.target_opportunity_score) ?? 0) - (readNumber(left.target_opportunity_score) ?? 0));
6376
+ if (targets.length === 0) {
6377
+ continue;
6378
+ }
6379
+ qualifyingTargetCount += targets.length;
6380
+ const maxReward = Math.max(0, ...programs.map(programMaximumReward));
6381
+ const maxTargetScore = Math.max(0, ...targets.map((target) => readNumber(target.target_opportunity_score) ?? 0));
6382
+ const balancedScore = repositoryHuntBalancedScore(maxReward, maxTargetScore, observedAt);
6383
+ const formattedChange = formatRepositoryChange(change, input.output_mode);
6384
+ const { programs: _eventPrograms, targets: _eventTargets, ...repositoryChange } = formattedChange;
6385
+ rankedMatches.push({
6386
+ balancedScore,
6387
+ maxReward,
6388
+ maxTargetScore,
6389
+ observedAt,
6390
+ payload: stripUndefined({
6391
+ repository_change: repositoryChange,
6392
+ programs,
6393
+ targets: targets.slice(0, input.max_targets_per_result).map((target) => input.output_mode === "full" ? target : compactTarget(target)),
6394
+ target_count: targets.length,
6395
+ max_target_score: maxTargetScore,
6396
+ max_program_reward: maxReward,
6397
+ match_score: balancedScore,
6398
+ why_ranked_here: repositoryHuntReasons(maxReward, maxTargetScore, observedAt)
6399
+ })
6400
+ });
6401
+ }
6402
+ rankedMatches.sort((left, right) => compareRepositoryHuntMatches(left, right, input.ranking_mode));
6403
+ const matches = rankedMatches.slice(0, input.max_results).map((match, index) => ({
6404
+ rank: index + 1,
6405
+ ...match.payload
6406
+ }));
6407
+ const matchedPrograms = uniqueProgramsFromRepositoryMatches(matches);
6408
+ const programWarnings = readArray(programPayload.warnings).filter((warning) => typeof warning === "string");
6409
+ if (candidatePrograms.length === 0) {
6410
+ warnings.push("No programs matched the reward and program-level filters. Relax one program filter and retry.");
6411
+ }
6412
+ else if (matches.length === 0) {
6413
+ warnings.push("Programs matched, but no recent repository event also contained a target that passed the target-quality filters. Relax one target or freshness filter and retry.");
6414
+ }
6415
+ const programBudget = readObject(programPayload.upstream_budget);
6416
+ const usedRequests = (readNumber(programBudget?.used) ?? 0) + repository.attempts;
6417
+ return stripUndefined({
6418
+ request_id: randomUUID(),
6419
+ fetched_at: new Date().toISOString(),
6420
+ source_requests: [
6421
+ ...readArray(programPayload.source_requests),
6422
+ {
6423
+ source: "repository_changes",
6424
+ request_id: repository.api.requestId,
6425
+ upstream_request_id: repository.api.upstreamRequestId,
6426
+ page: 1,
6427
+ page_size: repository.pageSize,
6428
+ ...apiSourceMetadata(repository.api)
6429
+ }
6430
+ ],
6431
+ warnings: uniqueStrings([...programWarnings, ...warnings]),
6432
+ filters: stripUndefined({
6433
+ platforms: input.platforms.length > 0 ? input.platforms : undefined,
6434
+ exclude_platforms: input.exclude_platforms.length > 0 ? input.exclude_platforms : undefined,
6435
+ tags: input.tags.length > 0 ? input.tags : undefined,
6436
+ scope_tags: input.scope_tags.length > 0 ? input.scope_tags : undefined,
6437
+ target_types: input.target_types.length > 0 ? input.target_types : undefined,
6438
+ language_tags: input.language_tags.length > 0 ? input.language_tags : undefined,
6439
+ surface_mode: input.surface_mode,
6440
+ opportunity_levels: input.opportunity_levels.length > 0 ? input.opportunity_levels : undefined,
6441
+ min_bounty_max: input.min_bounty_max,
6442
+ require_bounty: input.require_bounty,
6443
+ max_public_report_count: input.max_public_report_count,
6444
+ min_eligible_targets: input.min_eligible_targets,
6445
+ min_total_targets: input.min_total_targets,
6446
+ min_wildcards: input.min_wildcards,
6447
+ min_added_7d: input.min_added_7d,
6448
+ changed_since: input.changed_since,
6449
+ free_submission_only: input.free_submission_only,
6450
+ min_target_score: input.min_target_score,
6451
+ target_opportunity_labels: input.target_opportunity_labels.length > 0 ? input.target_opportunity_labels : undefined,
6452
+ competition_risks: input.competition_risks.length > 0 ? input.competition_risks : undefined,
6453
+ ranking_mode: input.ranking_mode,
6454
+ output_mode: input.output_mode
6455
+ }),
6456
+ upstream_budget: {
6457
+ initial: input.upstream_request_budget,
6458
+ used: usedRequests,
6459
+ remaining: Math.max(0, input.upstream_request_budget - usedRequests)
6460
+ },
6461
+ ranking_scope: readObject(programPayload.ranking_scope),
6462
+ programs: matchedPrograms,
6463
+ matches,
6464
+ meta: {
6465
+ returned: matches.length,
6466
+ matched_programs: matchedPrograms.length,
6467
+ program_candidates: candidatePrograms.length,
6468
+ repository_events_scanned: readArray(readObject(repository.api.data)?.results).length,
6469
+ qualifying_targets_before_result_limit: qualifyingTargetCount,
6470
+ repository_page_size_used: repository.pageSize,
6471
+ repository_request_attempts: repository.attempts,
6472
+ parallel_source_fetch: true
6473
+ }
6474
+ });
6475
+ }
6476
+ async function fetchRepositoryHuntPage(client, input, warnings, maxAttempts) {
6477
+ let pageSize = input.repository_page_size;
6478
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
6479
+ try {
6480
+ const api = await client.getRepositoryChanges(toQuery({
6481
+ include_targets: true,
6482
+ platforms: input.platforms.length > 0 ? input.platforms : undefined,
6483
+ tags: input.tags.length > 0 ? input.tags : undefined,
6484
+ free_submission_only: input.free_submission_only,
6485
+ include_removed: false,
6486
+ include_ineligible: false,
6487
+ include_out_of_scope: false,
6488
+ page: 1,
6489
+ page_size: pageSize
6490
+ }));
6491
+ return { api, pageSize, attempts: attempt };
6492
+ }
6493
+ catch (error) {
6494
+ if (!(error instanceof BBRadarApiError) || error.status !== 413 || attempt >= maxAttempts || pageSize <= 10) {
6495
+ throw error;
6496
+ }
6497
+ const reducedPageSize = Math.max(10, Math.floor(pageSize / 2));
6498
+ warnings.push(`Repository page_size was reduced from ${pageSize} to ${reducedPageSize} after a 413 association-limit response.`);
6499
+ pageSize = reducedPageSize;
6500
+ }
6501
+ }
6502
+ throw new Error("No repository-change response was collected.");
6503
+ }
6504
+ function repositoryHuntTargetMatches(target, input) {
6505
+ if (readBoolean(target.in_scope) !== true || readBoolean(target.eligible_for_bounty) !== true) {
6506
+ return false;
6507
+ }
6508
+ const score = readNumber(target.target_opportunity_score);
6509
+ if (score === undefined || score < input.min_target_score) {
6510
+ return false;
6511
+ }
6512
+ if (input.target_opportunity_labels.length > 0 &&
6513
+ !input.target_opportunity_labels.includes(stringField(target, "target_opportunity_label"))) {
6514
+ return false;
6515
+ }
6516
+ if (input.competition_risks.length > 0 &&
6517
+ !input.competition_risks.includes(stringField(target, "competition_risk"))) {
6518
+ return false;
6519
+ }
6520
+ if (input.target_types.length > 0 && !input.target_types.some((targetType) => targetMatchesRequestedType(target, targetType))) {
6521
+ return false;
6522
+ }
6523
+ if (input.scope_tags.length > 0 && !targetMatchesAnyTags(target, input.scope_tags, "scope_tags")) {
6524
+ return false;
6525
+ }
6526
+ if (input.language_tags.length > 0 && !targetMatchesAnyTags(target, input.language_tags, "language_tags")) {
6527
+ return false;
6528
+ }
6529
+ return true;
6530
+ }
6531
+ function targetMatchesAnyTags(target, requested, field) {
6532
+ const actual = new Set(readStringArrayField(target, field).map(normalizeTag));
6533
+ return requested.map(normalizeTag).some((value) => actual.has(value));
6534
+ }
6535
+ function programMaximumReward(program) {
6536
+ return readNumber(readObject(program.reward_range)?.max) ?? 0;
6537
+ }
6538
+ function repositoryHuntBalancedScore(maxReward, maxTargetScore, observedAt) {
6539
+ const rewardScore = Math.min(100, Math.log10(Math.max(1, maxReward)) / 7 * 100);
6540
+ const ageDays = observedAt > 0 ? Math.max(0, Date.now() - observedAt) / (24 * 60 * 60 * 1000) : 30;
6541
+ const freshnessScore = Math.max(0, 100 - Math.min(30, ageDays) / 30 * 100);
6542
+ return Math.round((maxTargetScore * 0.45 + rewardScore * 0.35 + freshnessScore * 0.2) * 100) / 100;
6543
+ }
6544
+ function repositoryHuntReasons(maxReward, maxTargetScore, observedAt) {
6545
+ const reasons = [`target score up to ${maxTargetScore}`, `maximum reward ${maxReward}`];
6546
+ if (observedAt > 0) {
6547
+ reasons.push(`repository activity observed ${new Date(observedAt).toISOString()}`);
6548
+ }
6549
+ return reasons;
6550
+ }
6551
+ function compareRepositoryHuntMatches(left, right, mode) {
6552
+ if (mode === "highest_reward") {
6553
+ return right.maxReward - left.maxReward || right.maxTargetScore - left.maxTargetScore || right.observedAt - left.observedAt;
6554
+ }
6555
+ if (mode === "best_target") {
6556
+ return right.maxTargetScore - left.maxTargetScore || right.maxReward - left.maxReward || right.observedAt - left.observedAt;
6557
+ }
6558
+ if (mode === "newest") {
6559
+ return right.observedAt - left.observedAt || right.maxTargetScore - left.maxTargetScore || right.maxReward - left.maxReward;
6560
+ }
6561
+ return right.balancedScore - left.balancedScore || right.observedAt - left.observedAt;
6562
+ }
6563
+ function uniqueProgramsFromRepositoryMatches(matches) {
6564
+ const programs = new Map();
6565
+ for (const match of matches) {
6566
+ for (const value of readArray(match.programs)) {
6567
+ const program = readObject(value);
6568
+ const id = stringField(program, "id");
6569
+ if (program && id && !programs.has(id)) {
6570
+ programs.set(id, program);
6571
+ }
6572
+ }
6573
+ }
6574
+ return [...programs.values()];
6575
+ }
5845
6576
  async function findPrograms(client, config, input) {
5846
6577
  const normalized = normalizeFindProgramsInput(input);
5847
6578
  const warnings = normalized.warnings;
@@ -5890,8 +6621,10 @@ async function findPrograms(client, config, input) {
5890
6621
  warnings: warnings.length > 0 ? warnings : undefined,
5891
6622
  filters: stripUndefined({
5892
6623
  platforms: input.platforms.length > 0 ? input.platforms : undefined,
6624
+ exclude_platforms: input.exclude_platforms && input.exclude_platforms.length > 0 ? input.exclude_platforms : undefined,
5893
6625
  tags: upstreamTags.length > 0 ? upstreamTags : undefined,
5894
6626
  web3: input.web3 ? true : undefined,
6627
+ exclude_web3: input.exclude_web3 ? true : undefined,
5895
6628
  opportunity_levels: input.opportunity_levels.length > 0 ? input.opportunity_levels : undefined,
5896
6629
  updated_since: input.updated_since,
5897
6630
  min_bounty_min: input.min_bounty_min,
@@ -6423,12 +7156,19 @@ function programCandidateMatches(candidate, filters) {
6423
7156
  if (filters.platforms.length > 0 && !filters.platforms.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
6424
7157
  return false;
6425
7158
  }
7159
+ if ((filters.exclude_platforms?.length ?? 0) > 0 &&
7160
+ filters.exclude_platforms?.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
7161
+ return false;
7162
+ }
6426
7163
  if (filters.tags.length > 0 && !candidateMatchesAnyTag(candidate, filters.tags)) {
6427
7164
  return false;
6428
7165
  }
6429
7166
  if (filters.web3 && !candidateHasWeb3Surface(candidate)) {
6430
7167
  return false;
6431
7168
  }
7169
+ if (filters.exclude_web3 && candidateHasWeb3Surface(candidate)) {
7170
+ return false;
7171
+ }
6432
7172
  if (filters.scope_tags.length > 0 && !candidateMatchesAnyTag(candidate, filters.scope_tags)) {
6433
7173
  return false;
6434
7174
  }
@@ -6441,7 +7181,9 @@ function programCandidateMatches(candidate, filters) {
6441
7181
  if (candidate.wildcardCount < filters.min_wildcards) {
6442
7182
  return false;
6443
7183
  }
6444
- if (filters.require_bounty && (candidate.bountyMax ?? candidate.bountyMin ?? 0) <= 0) {
7184
+ const explicitlyPaid = normalizeTag(stringField(candidate.program, "program_type") ?? "") === "paid" ||
7185
+ normalizeTag(stringField(candidate.program, "reward_status") ?? "") === "paid";
7186
+ if (filters.require_bounty && !explicitlyPaid && (candidate.bountyMax ?? candidate.bountyMin ?? 0) <= 0) {
6445
7187
  return false;
6446
7188
  }
6447
7189
  if (filters.min_bounty_min !== undefined && (candidate.bountyMin ?? 0) < filters.min_bounty_min) {
@@ -6812,6 +7554,111 @@ The plan must:
6812
7554
  - Prioritize targets by bounty eligibility, freshness, wildcard status, target type, and severity labels.
6813
7555
  - Include a short checklist of BBRadar follow-up tool calls and decision points.`));
6814
7556
  }
7557
+ function resolveFeedCursor(store, endpoint, filters, resume) {
7558
+ const normalizedFilters = stableStringify(normalizeFeedCursorFilters(filters));
7559
+ const key = `${endpoint}:${createHash("sha256").update(normalizedFilters).digest("hex")}`;
7560
+ const explicitAfterId = readNumber(filters.after_id);
7561
+ if (explicitAfterId !== undefined) {
7562
+ return { endpoint, key, afterId: explicitAfterId, resumed: false };
7563
+ }
7564
+ if (!resume) {
7565
+ return { endpoint, key, afterId: undefined, resumed: false };
7566
+ }
7567
+ const stored = store.get(key);
7568
+ if (stored === undefined) {
7569
+ throw new Error(`No in-memory ${endpoint} cursor exists for this filter set. Provide after_id once to seed it.`);
7570
+ }
7571
+ return { endpoint, key, afterId: stored, resumed: true };
7572
+ }
7573
+ function advanceFeedCursor(store, cursor, rawMeta) {
7574
+ if (cursor.afterId === undefined) {
7575
+ return undefined;
7576
+ }
7577
+ const meta = readObject(rawMeta);
7578
+ const nextAfterId = readNumber(meta?.next_after_id) ?? cursor.afterId;
7579
+ store.set(cursor.key, nextAfterId);
7580
+ return {
7581
+ endpoint: cursor.endpoint,
7582
+ mode: cursor.resumed ? "resumed" : "seeded",
7583
+ used_after_id: cursor.afterId,
7584
+ next_after_id: nextAfterId,
7585
+ has_more: readBoolean(meta?.has_more) ?? false,
7586
+ stored_in_memory: true
7587
+ };
7588
+ }
7589
+ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudgetMs, fetchPage) {
7590
+ const startedAt = Date.now();
7591
+ const results = [];
7592
+ const sourceRequests = [];
7593
+ let currentCursor = initialCursor;
7594
+ let lastApi;
7595
+ let lastData;
7596
+ let cursorPayload;
7597
+ let pagesFetched = 0;
7598
+ let hasMore = false;
7599
+ let pageBudgetExhausted = false;
7600
+ let timeBudgetExhausted = false;
7601
+ do {
7602
+ const api = await fetchPage(currentCursor.afterId);
7603
+ const data = readObject(api.data);
7604
+ const pageResults = readArray(data?.results);
7605
+ lastApi = api;
7606
+ lastData = data;
7607
+ results.push(...pageResults);
7608
+ pagesFetched += 1;
7609
+ sourceRequests.push({
7610
+ source: currentCursor.endpoint,
7611
+ page: pagesFetched,
7612
+ request_id: api.requestId,
7613
+ upstream_request_id: api.upstreamRequestId,
7614
+ ...apiSourceMetadata(api)
7615
+ });
7616
+ cursorPayload = advanceFeedCursor(store, currentCursor, data?.meta);
7617
+ hasMore = readBoolean(readObject(data?.meta)?.has_more) === true;
7618
+ if (currentCursor.afterId === undefined || !hasMore) {
7619
+ break;
7620
+ }
7621
+ if (pagesFetched >= maxPages) {
7622
+ pageBudgetExhausted = true;
7623
+ break;
7624
+ }
7625
+ if (Date.now() - startedAt >= timeBudgetMs) {
7626
+ timeBudgetExhausted = true;
7627
+ break;
7628
+ }
7629
+ currentCursor = {
7630
+ ...currentCursor,
7631
+ afterId: readNumber(cursorPayload?.next_after_id) ?? currentCursor.afterId,
7632
+ resumed: true
7633
+ };
7634
+ } while (true);
7635
+ if (!lastApi) {
7636
+ throw new Error(`No ${initialCursor.endpoint} response was collected.`);
7637
+ }
7638
+ return {
7639
+ lastApi,
7640
+ lastData,
7641
+ results,
7642
+ cursor: cursorPayload,
7643
+ pagesFetched,
7644
+ pageBudgetExhausted,
7645
+ timeBudgetExhausted,
7646
+ sourceRequests
7647
+ };
7648
+ }
7649
+ function normalizeFeedCursorFilters(filters) {
7650
+ return Object.fromEntries(Object.entries(filters)
7651
+ .filter(([key, value]) => key !== "after_id" && key !== "page" && value !== undefined)
7652
+ .map(([key, value]) => [key, normalizeFeedCursorFilterValue(value)]));
7653
+ }
7654
+ function normalizeFeedCursorFilterValue(value) {
7655
+ if (Array.isArray(value)) {
7656
+ return value
7657
+ .map((entry) => normalizeFeedCursorFilterValue(entry))
7658
+ .sort((left, right) => String(left).localeCompare(String(right)));
7659
+ }
7660
+ return typeof value === "string" ? value.trim().toLowerCase() : value;
7661
+ }
6815
7662
  async function runTool(toolName, config, rateLimiter, callback) {
6816
7663
  const startedAt = Date.now();
6817
7664
  const decision = rateLimiter.consume(toolName, rateLimitForTool(toolName, config));
@@ -6870,7 +7717,8 @@ function compactToolText(payload, isError) {
6870
7717
  [
6871
7718
  readArray(payload.programs).length,
6872
7719
  readArray(payload.targets).length,
6873
- readArray(payload.changes).length
7720
+ readArray(payload.changes).length,
7721
+ readArray(payload.repository_changes).length
6874
7722
  ].find((count) => count > 0);
6875
7723
  return JSON.stringify(stripUndefined({
6876
7724
  request_id: payload.request_id,
@@ -6901,6 +7749,7 @@ function withApiMetadata(api, payload) {
6901
7749
  upstream_request_id: api.upstreamRequestId,
6902
7750
  cache: stripUndefined({
6903
7751
  hit: api.cached,
7752
+ stale_while_revalidate: api.stale,
6904
7753
  coalesced_live_request: api.coalesced,
6905
7754
  expires_at: api.cacheExpiresAt
6906
7755
  }),
@@ -7053,7 +7902,11 @@ function compactTarget(target) {
7053
7902
  target_type: stringField(target, "target_type"),
7054
7903
  in_scope: readBoolean(target.in_scope),
7055
7904
  eligible_for_bounty: readBoolean(target.eligible_for_bounty),
7056
- wildcard: readBoolean(target.wildcard)
7905
+ wildcard: readBoolean(target.wildcard),
7906
+ target_opportunity_score: readNumber(target.target_opportunity_score),
7907
+ target_opportunity_label: stringField(target, "target_opportunity_label"),
7908
+ competition_risk: stringField(target, "competition_risk"),
7909
+ why_this_target: stringField(target, "why_this_target")
7057
7910
  });
7058
7911
  }
7059
7912
  function targetIdentifier(target) {