@bbradar/mcp 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { requiresActivityQuery } from "./activityContract.js";
2
3
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { z } from "zod";
4
5
  import { BBRadarApiError } from "./bbradarClient.js";
5
6
  import { NumericCursorStore } from "./cursorStore.js";
6
7
  import { stableStringify, stripUndefined } from "./json.js";
7
8
  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.5";
9
+ import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeProPayload, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
10
+ const SERVER_VERSION = "0.3.0";
10
11
  const MAX_PROGRAMS_PER_SEARCH = 100;
11
12
  const MAX_TARGET_EXPORT = 500;
12
13
  const MAX_RECENT_CHANGES = 100;
@@ -64,7 +65,15 @@ const CORE_TOOL_NAMES = new Set([
64
65
  "list_filters",
65
66
  "get_bbradar_guide",
66
67
  "get_mcp_status",
67
- "export_targets"
68
+ "export_targets",
69
+ "get_history_changes",
70
+ "get_history_change_detail",
71
+ "get_program_history",
72
+ "get_program_activity",
73
+ "get_analytics_platform_series",
74
+ "get_analytics_program_activity",
75
+ "get_analytics_program_detail",
76
+ "get_activity_capabilities", "get_activity_snapshot", "get_activity_rankings", "get_program_payouts", "get_activity_change_detail"
68
77
  ]);
69
78
  const TOOL_ENDPOINT_REQUIREMENTS = {
70
79
  search_targets: "target_search",
@@ -97,7 +106,6 @@ const PROGRAM_ID_PLATFORM_GUESSES = [
97
106
  ];
98
107
  const PRIMARY_PROGRAM_ID_PLATFORM_GUESSES = PROGRAM_ID_PLATFORM_GUESSES.slice(0, 8);
99
108
  const WEB3_TAGS = ["web3", "crypto", "blockchain", "smart-contract", "smart contract", "defi", "nft", "ethereum", "solana", "solidity"];
100
- const WEB3_CONTEST_SIGNALS = ["contest", "audit", "competitive", "code4rena", "sherlock", "cantina", "codehawks", "hats", "spearbit", "warden"];
101
109
  const VDP_SIGNALS = ["vdp", "disclosure", "responsible disclosure", "vulnerability disclosure", "no bounty", "non-bounty", "no reward"];
102
110
  const PROGRAM_RESOLUTION_STOP_WORDS = new Set([
103
111
  "program",
@@ -181,7 +189,7 @@ const changeListModeSchema = z.enum(["compact", "full"]);
181
189
  const repositoryChangeListModeSchema = z.enum(["compact", "full"]);
182
190
  const programListModeSchema = z.enum(["compact", "full"]);
183
191
  const rewardThresholdModeSchema = z.enum(["max_at_least", "min_at_least"]);
184
- const vdpModeSchema = z.enum(["include", "exclude", "only_likely", "only_known_no_bounty"]);
192
+ const vdpModeSchema = z.enum(["include", "exclude", "only", "only_likely", "only_known_no_bounty"]);
185
193
  const targetMatchModeSchema = z
186
194
  .enum(["smart", "contains", "exact", "suffix", "wildcard"])
187
195
  .describe("How to match target_query. smart is forgiving for URLs, wildcards, target names, and partial words.");
@@ -254,6 +262,9 @@ const stableSnapshotSchema = z
254
262
  message: "Snapshot tokens cannot contain control characters."
255
263
  })
256
264
  .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.");
265
+ 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.");
266
+ const analyticsDateSchema = () => z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use an inclusive UTC YYYY-MM-DD date.");
267
+ const proLimitSchema = z.number().int().min(1).max(100);
257
268
  const pageSchema = z.number().int().min(1).default(1);
258
269
  const programsPageSizeSchema = z.number().int().min(1).max(MAX_PROGRAMS_PER_SEARCH).default(20);
259
270
  const recentChangesPageSizeSchema = z.number().int().min(1).max(MAX_RECENT_CHANGES).default(50);
@@ -274,11 +285,64 @@ const programSortSchema = z
274
285
  "best_hunt_value"
275
286
  ])
276
287
  .default("best_match");
288
+ const ACTIVITY_TABS = ["payouts", "value", "scope", "rewards", "updates", "reports", "response", "status", "all_changes"];
289
+ const ACTIVITY_EVENT_PERIODS = ["7d", "30d", "90d"];
290
+ const ACTIVITY_COUNT_TABS = new Set(["payouts", "reports", "response"]);
291
+ const ACTIVITY_SHARED_PARAMS = ["tab", "sort", "direction", "platforms", "tags", "program_kind", "program_type", "search",
292
+ "current_status", "labels", "opportunity_tiers", "cursor", "limit", "snapshot"];
293
+ // Inputs the API accepts only for these views. Anything else is rejected before
294
+ // a request so it cannot be silently ignored or reinterpreted.
295
+ const ACTIVITY_TAB_PARAMS = {
296
+ payouts: ["period", "basis", "precision"],
297
+ value: ["include_map"],
298
+ scope: ["period"],
299
+ rewards: ["period"],
300
+ updates: ["period", "category"],
301
+ reports: ["period", "launched_within", "report_change_min", "report_change_max"],
302
+ response: ["period", "launched_within"],
303
+ status: ["period", "change_type"],
304
+ all_changes: ["period", "category"]
305
+ };
306
+ const activityCatalogSortSchema = z.enum(["updated", "payout_amount", "payout_lower_bound", "payout_upper_bound", "scope_added", "scope_net", "reward_change", "active_days", "reports", "reports_change", "resolved", "last_resolved", "first_response", "time_to_bounty", "last_change"]);
307
+ const activityCountPeriodSchema = () => z.enum(["24h", "7d", "30d", "90d", "all_time"]);
308
+ const activityEventPeriodSchema = () => z.enum(["7d", "30d", "90d"]);
309
+ const activityCatalogNumberSchema = () => z.number().finite().min(0).optional();
310
+ const activityCatalogInput = {
311
+ program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
312
+ payout_metric: z.enum(["payout_total", "payout_increase", "payout_native_90d"]).optional(),
313
+ payout_period: activityCountPeriodSchema().optional(),
314
+ payout_precision: z.enum(["exact", "proven_bounds"]).optional(),
315
+ payout_min: activityCatalogNumberSchema(), payout_max: activityCatalogNumberSchema(),
316
+ payout_positive: z.boolean().optional().describe("true requires a proved positive payout; false requires an exact observed zero. Omit to include either."),
317
+ payout_activity: z.enum(["increase", "no_increase", "lower"]).optional(),
318
+ scope_period: activityEventPeriodSchema().optional(),
319
+ scope_added_min: activityCatalogNumberSchema(), scope_removed_min: activityCatalogNumberSchema(),
320
+ scope_net_min: z.number().finite().optional(), scope_net_max: z.number().finite().optional(),
321
+ reward_period: activityEventPeriodSchema().optional(),
322
+ reward_change: z.enum(["increased", "decreased"]).optional(), reward_change_min_pct: activityCatalogNumberSchema(),
323
+ updates_period: activityEventPeriodSchema().optional(),
324
+ active_days_min: activityCatalogNumberSchema(), changes_min: activityCatalogNumberSchema(),
325
+ change_category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
326
+ reports_period: activityCountPeriodSchema().optional(),
327
+ reports_min: activityCatalogNumberSchema(), reports_max: activityCatalogNumberSchema(),
328
+ reports_change: z.enum(["rising", "falling"]).optional(), reports_change_min_pct: activityCatalogNumberSchema(),
329
+ launched_within: z.enum(["7d", "30d", "90d"]).optional(),
330
+ resolved_period: activityCountPeriodSchema().optional(), resolved_min: activityCatalogNumberSchema(),
331
+ last_resolved_within: z.enum(["7d", "30d", "90d"]).optional(),
332
+ first_response_max_hours: activityCatalogNumberSchema(), time_to_bounty_max_hours: activityCatalogNumberSchema(),
333
+ status_period: activityEventPeriodSchema().optional(),
334
+ status_change: z.enum(["first_seen", "paused", "resumed", "left_listing", "returned"]).optional(),
335
+ current_status: z.enum(["active", "paused", "suspended", "disabled", "closed", "unavailable", "unknown"]).optional(),
336
+ activity_labels: z.array(z.string().min(1).max(64)).max(10).optional(),
337
+ sort_by: activityCatalogSortSchema.optional(), sort_direction: z.enum(["asc", "desc"]).optional()
338
+ };
339
+ const activityCatalogGuidance = "Give each Activity signal its period (e.g. payout_period=30d with payout_positive=true; scope_period with scope_added_min) and use sort_by/sort_direction combinations enabled in get_activity_capabilities; default is updated descending. Tracked no_increase/lower require 30d. Nothing falls back to another period or precision.";
340
+ const freeSubmissionOnlySchema = z.boolean().default(false).describe("Only programs with no detected submission fee or deposit.");
277
341
  const programIdSchema = z
278
342
  .string()
279
343
  .trim()
280
344
  .min(3)
281
- .max(256)
345
+ .max(620)
282
346
  .regex(/^[^:/?#\u0000-\u001F\u007F]+:[^?#\u0000-\u001F\u007F]+$/, "Use platform:handle, for example HackerOne:example-handle.")
283
347
  .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
284
348
  message: "program_id cannot contain control characters."
@@ -296,49 +360,24 @@ const programIdentifierSchema = z
296
360
  function jsonRecordSchema() {
297
361
  return z.record(z.unknown());
298
362
  }
299
- function rateLimitOutputSchema() {
300
- return z.object({
301
- remaining: z.number().int().min(0),
302
- reset_at: z.string()
303
- });
304
- }
305
- function toolTimingOutputSchema() {
306
- return z.object({
307
- tool_name: z.string(),
308
- duration_ms: z.number().int().min(0)
309
- });
310
- }
311
- function cacheOutputSchema() {
312
- return z.object({
313
- hit: z.boolean(),
314
- stale_while_revalidate: z.boolean().optional(),
315
- coalesced_live_request: z.boolean().optional(),
316
- expires_at: z.string().optional()
317
- });
318
- }
319
- function errorOutputSchema() {
320
- return z.object({
321
- message: z.string(),
322
- status: z.number().int().optional(),
323
- detail: z.unknown().optional(),
324
- errors: z.unknown().optional()
325
- });
326
- }
327
363
  function programOutputSchema() {
328
364
  return jsonRecordSchema();
329
365
  }
366
+ // Every tool repeats this envelope in discovery. Diagnostic objects (cache,
367
+ // rate limit, timing, error) stay open records so each tool's schema stays small.
330
368
  function apiEnvelopeOutputShape() {
331
369
  return {
332
370
  request_id: z.string(),
333
371
  upstream_request_id: z.string().optional(),
334
372
  fetched_at: z.string().optional(),
335
- cache: cacheOutputSchema().optional(),
373
+ cache: jsonRecordSchema().optional(),
336
374
  source_requests: z.array(jsonRecordSchema()).optional(),
337
- mcp_rate_limit: rateLimitOutputSchema().optional(),
338
- mcp_timing: toolTimingOutputSchema().optional(),
375
+ mcp_rate_limit: jsonRecordSchema().optional(),
376
+ mcp_timing: jsonRecordSchema().optional(),
339
377
  warnings: z.array(z.string()).optional(),
340
378
  program_id_resolution: jsonRecordSchema().optional(),
341
- error: errorOutputSchema().optional()
379
+ recovery: jsonRecordSchema().optional(),
380
+ error: jsonRecordSchema().optional()
342
381
  };
343
382
  }
344
383
  function programListOutputShape() {
@@ -356,7 +395,8 @@ function findProgramsOutputShape() {
356
395
  warnings: z.array(z.string()).optional(),
357
396
  errors: z.array(jsonRecordSchema()).optional(),
358
397
  upstream_budget: jsonRecordSchema().optional(),
359
- ranking_scope: jsonRecordSchema().optional()
398
+ ranking_scope: jsonRecordSchema().optional(),
399
+ scan: jsonRecordSchema().optional()
360
400
  };
361
401
  }
362
402
  function decisionOutputShape() {
@@ -400,7 +440,11 @@ const programCandidatePresetInputSchema = {
400
440
  };
401
441
  function createProfiledToolRegistrar(server, profile, client) {
402
442
  return (name, toolConfig, callback) => {
403
- const registeredTool = server.registerTool(name, toolConfig, callback);
443
+ const scopedCallback = ((...args) => {
444
+ const extra = args[args.length - 1];
445
+ return client.withRequestContext(extra.signal, () => callback(...args), readObject(args[0])?.snapshot === "start");
446
+ });
447
+ const registeredTool = server.registerTool(name, toolConfig, scopedCallback);
404
448
  const requiredEndpoint = TOOL_ENDPOINT_REQUIREMENTS[name];
405
449
  if ((profile === "core" && !CORE_TOOL_NAMES.has(name)) ||
406
450
  (requiredEndpoint !== undefined && !client.supportsEndpoint(requiredEndpoint))) {
@@ -417,6 +461,9 @@ export function createBbradarServer(client, config) {
417
461
  "Use resolve_program or run_program_name_action for a named program.",
418
462
  "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.",
419
463
  "Use find_programs for lower-level program-only discovery and find_programs_by_target for reverse target lookup.",
464
+ "For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities, then get_activity_rankings with the matching tab for one ranking. For several conditions at once (e.g. recent payouts plus new scope, Web2-only, low competition, no submission fee) use one find_hunt_targets or find_programs call with Activity inputs and local filters together. Keep periods explicit; never intersect separately paginated rankings.",
465
+ "Activity data is published in versions. get_activity_snapshot reports the current version; pass its snapshot_id as snapshot to bind capabilities and first pages to one version, and never mix rows from different versions.",
466
+ "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.",
420
467
  "Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
421
468
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
422
469
  ]
@@ -426,12 +473,15 @@ export function createBbradarServer(client, config) {
426
473
  "Simple default path: find_hunt_targets once for ranked target/program discovery, run_program_name_action for one named program, get_program_eligible_targets_by_name for exhaustive target lists by program name.",
427
474
  "Do not repeat program discovery with alternate sorts or fan out get_program_targets across a shortlist; find_hunt_targets already includes exact bounded samples and recent target additions.",
428
475
  "Use advanced tools only after the user's goal is clear or the guide points to them.",
476
+ "For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities. Use get_activity_rankings (or its per-view alias) for one ranking, or one find_hunt_targets/find_programs call combining Activity inputs with Web2, scope, competition and fee filters. Keep server order and explicit periods; never re-rank a sampled catalog or intersect separate pages.",
477
+ "Activity data is published in versions. get_activity_snapshot reports the current version; pass its snapshot_id as snapshot to bind capabilities and first pages to one version, and never mix rows from different versions.",
429
478
  "Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
430
479
  "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.",
431
480
  "Recently updated programs across platforms: find_recently_updated_programs. Do not infer a platform such as Immunefi unless the user explicitly names it.",
432
481
  "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.",
433
482
  "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.",
434
483
  "Repository activity: get_repository_changes defaults to compact program-only events. Seed with after_id, then resume the in-memory per-filter cursor.",
484
+ "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.",
435
485
  "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.",
436
486
  "Details: get_program_brief, get_program_scope_summary, get_program_target_breakdown, find_programs_by_target, compare_programs_compact.",
437
487
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
@@ -459,11 +509,217 @@ export function createBbradarServer(client, config) {
459
509
  const repositoryChangesPageSizeSchema = z.number().int().min(1).max(maxRepositoryChangesPageSize).default(Math.min(50, maxRepositoryChangesPageSize));
460
510
  const acceptedTargetLimitSchema = z.number().int().min(1).max(maxProgramTargetsPageSize);
461
511
  const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
512
+ const historyOutput = { ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() };
513
+ 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() };
514
+ // Published Activity versions are 64 lowercase hex characters. "start" and
515
+ // catalog snapshot tokens are different contracts and are rejected here.
516
+ const activitySnapshotSchema = z.string().regex(/^[0-9a-f]{64}$/, "Use a snapshot_id returned by Activity.")
517
+ .describe("Published Activity version (get_activity_snapshot or meta.snapshot_id). Binds reads to one version; expired or withdrawn versions return 410. Never mix with a cursor from another version.");
518
+ const includeMapSchema = z.boolean().default(false)
519
+ .describe("Return Value map points (up to thousands). Off by default: map_points_omitted reports the count.");
520
+ const activityParams = {
521
+ tab: z.enum(ACTIVITY_TABS),
522
+ period: z.enum(["24h", "7d", "30d", "90d", "all_time"]).optional(),
523
+ basis: z.enum(["reported", "tracked"]).optional(),
524
+ precision: z.enum(["exact", "proven_bounds"]).optional(),
525
+ sort: z.string().min(1).max(64).optional(),
526
+ direction: z.enum(["asc", "desc"]).optional(),
527
+ platforms: platformListSchema(), tags: tagListSchema(),
528
+ program_kind: z.enum(["bounty", "contest", "audit", "challenge"]).optional(),
529
+ program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
530
+ search: z.string().min(1).max(200).optional(),
531
+ current_status: z.enum(["active", "paused", "suspended", "disabled", "closed", "unavailable", "unknown"]).optional(),
532
+ labels: z.array(z.string().min(1).max(64)).max(10).optional().describe("Only labels listed by get_activity_capabilities; others return 422."),
533
+ opportunity_tiers: z.array(opportunityLevelSchema).min(1).max(4).optional().describe("Any of these catalog opportunity tiers, as frozen into the Activity version. get_activity_capabilities lists the tiers present in facets.opportunity_tiers; an API without the filter is rejected before any request."),
534
+ launched_within: z.enum(["7d", "30d", "90d"]).optional(),
535
+ category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
536
+ change_type: z.enum(["first_seen", "paused", "resumed", "left_listing", "returned"]).optional(),
537
+ report_change_min: z.number().finite().optional(), report_change_max: z.number().finite().optional(),
538
+ include_map: includeMapSchema,
539
+ cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(25),
540
+ snapshot: activitySnapshotSchema.optional()
541
+ };
542
+ const activityOutput = {
543
+ ...apiEnvelopeOutputShape(), rows: z.array(jsonRecordSchema()).optional(), points: z.array(jsonRecordSchema()).optional(),
544
+ tab: z.string().optional(), period: z.string().nullable().optional(),
545
+ basis: z.string().nullable().optional(), precision: z.string().nullable().optional(),
546
+ sort: z.string().nullable().optional(), direction: z.string().optional(), meta: jsonRecordSchema().optional(),
547
+ cohort_median: jsonRecordSchema().nullable().optional(), available_views: z.array(jsonRecordSchema()).optional(),
548
+ map_points_omitted: z.number().int().optional()
549
+ };
550
+ registerTool("get_activity_capabilities", {
551
+ title: "Get Available Activity Views",
552
+ description: "Read the exact enabled period, sort, direction, basis and precision combinations before choosing an Activity ranking. Availability is evaluated before user filters; do not infer combinations from independent enums. Honor each view's sort_directions and default_direction and the enabled label list. The snapshot block identifies the published version these capabilities belong to.",
553
+ inputSchema: z.object({ snapshot: activitySnapshotSchema.optional() }).strict(),
554
+ outputSchema: { ...apiEnvelopeOutputShape(), tabs: z.array(jsonRecordSchema()).optional(), version: z.union([z.string(), z.number()]).optional(), facets: jsonRecordSchema().optional(), labels: z.array(z.string()).optional(), listing: jsonRecordSchema().optional(), listing_payouts_enabled: z.boolean().optional(), derived_signals_enabled: z.boolean().optional(), snapshot: jsonRecordSchema().optional() },
555
+ annotations: readOnlyAnnotations
556
+ }, (args) => runTool("get_activity_capabilities", config, rateLimiter, async () => {
557
+ assertProFeature(client, "activity_capabilities", "enabled");
558
+ assertActivitySnapshotSelector(client, "activity_capabilities", args.snapshot);
559
+ return proPage(await client.getActivityCapabilities(toQuery({ snapshot: args.snapshot })), "tabs");
560
+ }));
561
+ registerTool("get_activity_snapshot", {
562
+ title: "Get Current Activity Version",
563
+ description: "Read only the identity of the currently published Activity version: snapshot_id, frozen as_of cutoff, expires_at, policy_version and suggested poll_seconds. Use it to detect newer Activity data and pass snapshot_id as snapshot to bind capabilities, first pages and payouts to one version. as_of is when BBRadar froze its calculation, not a provider payment or update time.",
564
+ inputSchema: z.object({}).strict(),
565
+ outputSchema: { ...apiEnvelopeOutputShape(), snapshot_id: z.string().optional(), as_of: z.string().optional(), expires_at: z.string().optional(), policy_version: z.string().optional(), poll_seconds: z.number().optional() },
566
+ annotations: readOnlyAnnotations
567
+ }, () => runTool("get_activity_snapshot", config, rateLimiter, async () => {
568
+ assertProFeature(client, "activity_snapshot", "enabled");
569
+ const api = await client.getActivitySnapshot();
570
+ const data = readObject(api.data);
571
+ if (!data || typeof data.snapshot_id !== "string" || !/^[0-9a-f]{64}$/.test(data.snapshot_id)) {
572
+ throw new Error("Malformed Activity version response.");
573
+ }
574
+ return withApiMetadata(api, sanitizeProPayload(api.data));
575
+ }));
576
+ registerTool("get_program_payouts", {
577
+ title: "Get Program Payouts and Response Facts",
578
+ description: "Read reported totals, measured counter increases, source dates, precision, report and response facts, derived signals and label explanations for one exact platform:handle. Keep null separate from zero, bounds separate from exact amounts and unknown payment dates null. Payout timestamps are when increases were seen, never payment dates. No provider requests are made.",
579
+ inputSchema: z.object({ program_id: programIdSchema, snapshot: activitySnapshotSchema.optional(), include_map: includeMapSchema }).strict(),
580
+ // Mirrors the API response, including its version identity: a client that
581
+ // discovered this schema validates every structured result against it.
582
+ outputSchema: { ...apiEnvelopeOutputShape(), program_id: z.string().optional(), program: jsonRecordSchema().optional(), summary: jsonRecordSchema().optional(), as_of: z.string().optional(), snapshot_id: z.string().optional(), expires_at: z.string().nullable().optional(), facts: z.array(jsonRecordSchema()).optional(), windows: jsonRecordSchema().optional(), series: z.array(jsonRecordSchema()).optional(), payout_summary: jsonRecordSchema().nullable().optional(), views: z.array(jsonRecordSchema()).optional(), reports: jsonRecordSchema().optional(), response: jsonRecordSchema().optional(), last_increase_at: z.string().nullable().optional(), signals: jsonRecordSchema().optional(), label_details: z.array(jsonRecordSchema()).optional(), labels: z.array(z.string()).optional(), value_points: z.array(jsonRecordSchema()).optional(), map_points_omitted: z.number().int().optional(), meta: jsonRecordSchema().optional() },
583
+ annotations: readOnlyAnnotations
584
+ }, (args) => runTool("get_program_payouts", config, rateLimiter, async () => {
585
+ assertProFeature(client, "program_payouts", "enabled");
586
+ assertActivitySnapshotSelector(client, "program_payouts", args.snapshot);
587
+ const api = await client.getProgramPayouts(args.program_id, toQuery({ snapshot: args.snapshot }));
588
+ if (!readObject(api.data))
589
+ throw new Error("Malformed program payout response.");
590
+ return omitMapPoints(withApiMetadata(api, sanitizeProPayload(api.data)), "value_points", args.include_map);
591
+ }));
592
+ const readActivityRanking = async (tab, args) => {
593
+ assertProFeature(client, "activity_rankings", "enabled");
594
+ assertActivitySnapshotSelector(client, "activity_rankings", args.snapshot);
595
+ assertActivityTierFilter(client, args.opportunity_tiers);
596
+ const { include_map, ...query } = args;
597
+ return activityRankingPage(await client.getActivityRankings(toQuery({ ...query, tab })), include_map === true);
598
+ };
599
+ registerTool("get_activity_rankings", {
600
+ title: "Get Activity Ranking",
601
+ description: "One globally ranked server page of an Activity view, frozen at meta.as_of. Read get_activity_capabilities first: explicit period/basis/precision/sort/direction never fall back, and each tab only accepts its own inputs. tab: payouts (reported and tracked bases stay separate; proven_bounds desc = highest reported minimum, asc = lowest reported maximum, never an exact ranking; source totals can lag); value (fixed lifetime generosity and 90-day volume percentiles, no period; one label gives its shortlist); scope (recorded additions/removals/net, not inventory differences); rewards (net reward-range change, unknown % stays null); updates (active UTC days, change groups); reports (received counts; volume is not competition; all_time is the lifetime counter); response (resolved counts, last resolution, response time; time to bounty is award timing); status (first_seen/paused/resumed/left_listing/returned events); all_changes (chronological events; get_activity_change_detail for before/after). Event tabs use rolling 7d/30d/90d, not calendar months. launched_within applies to reports/response. Payout increases are observed counters, not payment dates. Reuse cursors only with identical inputs. For several signals at once, or with Web2/scope/competition/fee filters, use find_programs or find_hunt_targets.",
602
+ inputSchema: z.object(activityParams).strict(),
603
+ outputSchema: activityOutput,
604
+ annotations: readOnlyAnnotations
605
+ }, (args) => runTool("get_activity_rankings", config, rateLimiter, async () => {
606
+ assertActivityTabInputs(args.tab, args);
607
+ return readActivityRanking(args.tab, args);
608
+ }));
609
+ // Single-view aliases for the full profile; each schema exposes only the
610
+ // inputs its view accepts and shares the ranking tool's request path.
611
+ const activityDescriptions = {
612
+ value: "Discover programs with frozen within-platform value and report-volume percentiles. Generosity is lifetime; volume is 90 days. No period or launch-cohort input applies. Use a label-specific shortlist: Pays near top orders by realization, No payouts seen by received_90d (raw count with its basis; small cohorts remain eligible). Comparative ratio sorts use server percentiles; never recompute after filtering. Preserve label_details explanations and destinations. A counter increase is observed activity, not an individual payment. Map points are returned only with include_map.",
613
+ payouts: "Rank payouts using the same server calculation as BBRadar. Last 7 days and last 30 days are rolling windows. Explicit periods never fall back. For high or low payouts lately, use the same enabled tracked-30-day cohort, or separately label a native-90-day cohort. Reported and tracked bases stay separate. With proven_bounds, descending is Highest reported minimum using lower bounds; ascending is Lowest reported maximum using upper bounds. Neither claims an exact ranking. Source totals can be delayed, including Immunefi.",
614
+ scope: "Rank recorded scope additions, removals, net movement or largest update; not differences between inventory totals. Periods are rolling 7, 30 or 90 days, not calendar months.",
615
+ rewards: "Rank comparable net reward-range changes over rolling 7, 30 or 90 days. Unknown change percentages remain null.",
616
+ updates: "Rank active UTC days and recorded change groups over rolling periods. Baselines and internal corrections are excluded.",
617
+ reports: "Rank received report counts. Each program uses measured lifetime-counter growth where available, otherwise its supported native period. All-time is the current lifetime counter. Previous and change compare like-for-like. Report volume is not competition. Launch cohorts use the canonical launch date; amounts are not reports since launch.",
618
+ response: "Rank resolved counter growth or current lifetime counters, last resolution, response duration or time to bounty. Keep the provider's definition: time to bounty is award timing, not payment settlement. Period applies only to resolved counts.",
619
+ status: "Read distinct First seen, Paused, Resumed, Left listing and Returned events. Event type and current status are separate filters; never merge events from one program.",
620
+ all_changes: "Read chronological semantic program changes with event IDs. Fetch get_activity_change_detail lazily for before/after items, including eligible events for programs that left the catalog. Separate events stay separate."
621
+ };
622
+ for (const tab of ACTIVITY_TABS) {
623
+ const name = `get_activity_${tab === "all_changes" ? "changes" : tab}`;
624
+ const allowed = new Set([...ACTIVITY_SHARED_PARAMS, ...ACTIVITY_TAB_PARAMS[tab]]);
625
+ const shape = Object.fromEntries(Object.entries(activityParams).filter(([key]) => key !== "tab" && allowed.has(key)));
626
+ if (!ACTIVITY_COUNT_TABS.has(tab) && shape.period)
627
+ shape.period = z.enum(ACTIVITY_EVENT_PERIODS).optional();
628
+ registerTool(name, {
629
+ title: `Get Activity ${tab === "all_changes" ? "Changes" : tab[0].toUpperCase() + tab.slice(1)}`,
630
+ description: `${activityDescriptions[tab]} One globally ranked server page, with a frozen as-of cutoff. Reuse cursors only with unchanged filters. Use get_activity_capabilities for supported combinations.`,
631
+ inputSchema: z.object(shape).strict(), outputSchema: activityOutput, annotations: readOnlyAnnotations
632
+ }, (args) => runTool(name, config, rateLimiter, () => readActivityRanking(tab, args)));
633
+ }
634
+ registerTool("get_activity_change_detail", {
635
+ title: "Get Activity Change Detail",
636
+ description: "Read one bounded page of recorded before/after items for an exact Activity event ID in its source snapshot. Pass meta.snapshot_id from the Activity page. Retained eligible events may describe programs that have left the catalog. Reuse an opaque cursor only with the same event, snapshot and limit.",
637
+ inputSchema: z.object({ event_id: z.string().min(1).max(128), snapshot: activitySnapshotSchema.describe("meta.snapshot_id from the Activity page that supplied this event."), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) }).strict(),
638
+ outputSchema: { ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
639
+ }, (args) => runTool("get_activity_change_detail", config, rateLimiter, async () => {
640
+ assertProFeature(client, "activity_event_detail", "enabled");
641
+ return proPage(await client.getActivityChangeDetail(args.event_id, toQuery({ snapshot: args.snapshot, cursor: args.cursor, limit: args.limit })), "items");
642
+ }));
643
+ registerTool("get_history_changes", {
644
+ title: "Get Program History Changes",
645
+ 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.",
646
+ inputSchema: {
647
+ cursor: opaqueCursorSchema.optional(), days: z.number().int().min(0).max(3650).default(90),
648
+ platform: z.string().min(1).max(100).optional(), program_id: programIdSchema.optional(),
649
+ category: z.enum(["scope", "rewards", "rules", "program", "repositories"]).optional(),
650
+ search: z.string().min(1).max(120).optional(), limit: proLimitSchema.default(50)
651
+ }, outputSchema: historyOutput, annotations: readOnlyAnnotations
652
+ }, (args) => runTool("get_history_changes", config, rateLimiter, async () => {
653
+ assertProFeature(client, "history_changes", "history_capture_enabled");
654
+ if (args.days === 0 && !args.program_id)
655
+ throw new Error("days=0 requires program_id; use get_program_history for Self-Hosted catalog IDs.");
656
+ const api = await client.getHistoryChanges(toQuery(args));
657
+ return proPage(api, "changes");
658
+ }));
659
+ registerTool("get_history_change_detail", {
660
+ title: "Get History Change Detail",
661
+ description: "Fetch one semantic History change's paginated before/after items only when needed.",
662
+ inputSchema: { change_id: z.string().min(1).max(128), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) },
663
+ outputSchema: { ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
664
+ }, (args) => runTool("get_history_change_detail", config, rateLimiter, async () => {
665
+ assertProFeature(client, "history_change_detail");
666
+ assertHistoryCapture(client);
667
+ return proPage(await client.getHistoryChangeDetail(args.change_id, toQuery({ cursor: args.cursor, limit: args.limit })), "items");
668
+ }));
669
+ registerTool("get_program_history", {
670
+ title: "Get Program History",
671
+ description: "One page of exact semantic History for a platform:handle or Self-Hosted:slug catalog ID. Preserve archive id and catalog_id separately.",
672
+ inputSchema: { program_id: programIdSchema, cursor: opaqueCursorSchema.optional(), days: z.number().int().min(0).max(3650).default(90), limit: proLimitSchema.default(50) },
673
+ outputSchema: historyOutput, annotations: readOnlyAnnotations
674
+ }, (args) => runTool("get_program_history", config, rateLimiter, async () => {
675
+ assertProFeature(client, "program_history");
676
+ assertHistoryCapture(client);
677
+ return proPage(await client.getProgramHistory(args.program_id, toQuery({ cursor: args.cursor, days: args.days, limit: args.limit })), "changes");
678
+ }));
679
+ registerTool("get_program_activity", {
680
+ title: "Get Exact Program Activity",
681
+ description: "Read the authoritative activity_summary for one exact program ID.",
682
+ inputSchema: { program_id: programIdSchema },
683
+ outputSchema: { ...apiEnvelopeOutputShape(), program_id: z.string().optional(), activity_summary: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
684
+ }, (args) => runTool("get_program_activity", config, rateLimiter, async () => {
685
+ assertProFeature(client, "program_activity");
686
+ return proPage(await client.getProgramActivity(args.program_id), "activity_summary");
687
+ }));
688
+ registerTool("get_analytics_platform_series", {
689
+ title: "Get Platform Daily Analytics",
690
+ description: "One page of inclusive UTC daily platform analytics; inspect generation, projection_pending, and tracking_since.",
691
+ inputSchema: { platform: z.string().min(1).max(100), start: analyticsDateSchema().optional(), end: analyticsDateSchema().optional(), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(100) },
692
+ outputSchema: analyticsOutput, annotations: readOnlyAnnotations
693
+ }, (args) => runTool("get_analytics_platform_series", config, rateLimiter, async () => {
694
+ assertProFeature(client, "analytics_platform_series", "enabled");
695
+ const { platform, ...query } = args;
696
+ return proPage(await client.getAnalyticsPlatformSeries(platform, toQuery(query)), "series");
697
+ }));
698
+ registerTool("get_analytics_program_activity", {
699
+ title: "Rank Programs by Recorded Activity",
700
+ description: "Server-side daily activity ranking. One page; semantic activity excludes baselines and corrections.",
701
+ 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) },
702
+ outputSchema: analyticsOutput, annotations: readOnlyAnnotations
703
+ }, (args) => runTool("get_analytics_program_activity", config, rateLimiter, async () => {
704
+ assertProFeature(client, "analytics_program_activity", "enabled");
705
+ return proPage(await client.getAnalyticsProgramActivity(toQuery(args)), "programs");
706
+ }));
707
+ registerTool("get_analytics_program_detail", {
708
+ title: "Get Program Daily Analytics",
709
+ description: "One page of program daily metrics, carried inventory state, report observations, and source coverage.",
710
+ inputSchema: { program_id: programIdSchema, start: analyticsDateSchema().optional(), end: analyticsDateSchema().optional(), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(100) },
711
+ outputSchema: analyticsOutput, annotations: readOnlyAnnotations
712
+ }, (args) => runTool("get_analytics_program_detail", config, rateLimiter, async () => {
713
+ assertProFeature(client, "analytics_program_detail", "enabled");
714
+ const { program_id, ...query } = args;
715
+ return proPage(await client.getAnalyticsProgramDetail(program_id, toQuery(query)), "series");
716
+ }));
462
717
  registerTool("search_programs", {
463
718
  title: "Search BBRadar Programs",
464
- 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.`,
465
- inputSchema: {
719
+ description: `Search or list active and confirmed paused platform programs plus active Self-Hosted programs. page_size max ${maxProgramsPageSize}. Stable snapshots are opt-in. For payout, report, scope-change or other Activity filters use find_programs.`,
720
+ inputSchema: z.object({
466
721
  search: searchTextSchema.optional().describe("Server-side program name, handle, platform, policy-host, or target search."),
722
+ program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
467
723
  platforms: platformListSchema(),
468
724
  tags: tagListSchema(),
469
725
  updated_since: isoDateTimeSchema.optional().describe("ISO datetime."),
@@ -471,7 +727,7 @@ export function createBbradarServer(client, config) {
471
727
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
472
728
  page: pageSchema,
473
729
  page_size: programsPageSizeSchema
474
- },
730
+ }).strict(),
475
731
  outputSchema: programListOutputShape(),
476
732
  annotations: readOnlyAnnotations
477
733
  }, (args) => runTool("search_programs", config, rateLimiter, async () => {
@@ -718,16 +974,16 @@ export function createBbradarServer(client, config) {
718
974
  }, (args) => runTool("get_recent_changes", config, rateLimiter, async () => {
719
975
  const { output_mode, resume_cursor, max_cursor_pages, cursor_time_budget_ms, ...filters } = args;
720
976
  assertStableSnapshotSupported(client, "target_changes", filters.snapshot);
721
- const cursor = resolveFeedCursor(feedCursorStore, "target_changes", filters, resume_cursor);
977
+ const cursor = resolveFeedCursor(feedCursorStore, "target_changes", filters, resume_cursor, config);
722
978
  const { min_target_score, target_opportunity_label, competition_risk, snapshot, ...upstreamFilters } = filters;
723
- const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, snapshot, (afterId, recoveryProbe, activeSnapshot) => client.getRecentChanges(toQuery({
979
+ const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, snapshot, filters.page, (afterId, recoveryProbe, activeSnapshot, pageNumber) => client.getRecentChanges(toQuery({
724
980
  ...upstreamFilters,
725
981
  minTargetScore: min_target_score,
726
982
  targetOpportunityLabel: target_opportunity_label,
727
983
  competitionRisk: competition_risk,
728
984
  snapshot: activeSnapshot,
729
985
  after_id: afterId,
730
- page: afterId === undefined ? filters.page : 1
986
+ page: activeSnapshot ? pageNumber : afterId === undefined ? filters.page : 1
731
987
  }), recoveryProbe ? { cache: false, coalesce: false } : {}), (startPage, recoveryProbe) => refreshInventoryBaseline((page, pageProbe) => client.searchTargets(toQuery({
732
988
  q: upstreamFilters.search,
733
989
  platforms: upstreamFilters.platforms,
@@ -795,13 +1051,13 @@ export function createBbradarServer(client, config) {
795
1051
  }
796
1052
  const { output_mode, resume_cursor, max_cursor_pages, cursor_time_budget_ms, ...filters } = args;
797
1053
  assertStableSnapshotSupported(client, "repository_changes", filters.snapshot);
798
- const cursor = resolveFeedCursor(feedCursorStore, "repository_changes", filters, resume_cursor);
1054
+ const cursor = resolveFeedCursor(feedCursorStore, "repository_changes", filters, resume_cursor, config);
799
1055
  const { snapshot, ...upstreamFilters } = filters;
800
- const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, snapshot, (afterId, recoveryProbe, activeSnapshot) => client.getRepositoryChanges(toQuery({
1056
+ const collected = await collectCursorFeedPages(feedCursorStore, cursor, max_cursor_pages, cursor_time_budget_ms, snapshot, filters.page, (afterId, recoveryProbe, activeSnapshot, pageNumber) => client.getRepositoryChanges(toQuery({
801
1057
  ...upstreamFilters,
802
1058
  snapshot: activeSnapshot,
803
1059
  after_id: afterId,
804
- page: afterId === undefined ? filters.page : 1
1060
+ page: activeSnapshot ? pageNumber : afterId === undefined ? filters.page : 1
805
1061
  }), recoveryProbe ? { cache: false, coalesce: false } : {}), (startPage, recoveryProbe) => refreshInventoryBaseline((page, pageProbe) => client.getRepositoryChanges(toQuery({
806
1062
  ...upstreamFilters,
807
1063
  after_id: undefined,
@@ -830,8 +1086,9 @@ export function createBbradarServer(client, config) {
830
1086
  }));
831
1087
  registerTool("find_hunt_targets", {
832
1088
  title: "Find Best Hunt Targets",
833
- 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.",
834
- inputSchema: {
1089
+ description: "Preferred one-call path for requests combining good rewards, broad Web2/Web3 scope, target quality, competition, recent payouts or scope changes, and recently added targets. Activity inputs (e.g. payout_period=30d with payout_positive=true, scope_period=30d with scope_added_min=1) first narrow candidates on the server in global order; the other filters and ranking_mode then apply locally. 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.",
1090
+ inputSchema: z.object({
1091
+ ...activityCatalogInput,
835
1092
  platforms: platformListSchema(),
836
1093
  exclude_platforms: stringListSchema("Platforms to exclude from the final shortlist."),
837
1094
  tags: tagListSchema(),
@@ -858,9 +1115,10 @@ export function createBbradarServer(client, config) {
858
1115
  max_program_pages: z.number().int().min(1).max(MAX_FIND_PROGRAM_PAGES).default(DEFAULT_HUNT_PROGRAM_PAGES),
859
1116
  max_results: z.number().int().min(1).max(MAX_HUNT_RESULTS).default(10),
860
1117
  targets_per_result: z.number().int().min(1).max(MAX_HUNT_TARGETS_PER_RESULT).default(5),
1118
+ free_submission_only: freeSubmissionOnlySchema,
861
1119
  ranking_mode: z.enum(["balanced", "highest_reward", "widest_scope", "freshest_targets", "lowest_competition"]).default("balanced"),
862
1120
  upstream_request_budget: z.number().int().min(2).max(MAX_FIND_UPSTREAM_REQUEST_BUDGET).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
863
- },
1121
+ }).strict(),
864
1122
  outputSchema: {
865
1123
  ...apiEnvelopeOutputShape(),
866
1124
  matches: z.array(jsonRecordSchema()).optional(),
@@ -868,11 +1126,15 @@ export function createBbradarServer(client, config) {
868
1126
  errors: z.array(jsonRecordSchema()).optional(),
869
1127
  upstream_budget: jsonRecordSchema().optional(),
870
1128
  ranking_scope: jsonRecordSchema().optional(),
1129
+ scan: jsonRecordSchema().optional(),
871
1130
  result_guidance: jsonRecordSchema().optional(),
872
1131
  meta: jsonRecordSchema().optional()
873
1132
  },
874
1133
  annotations: readOnlyAnnotations
875
- }, (args) => runTool("find_hunt_targets", config, rateLimiter, async () => findHuntTargets(client, config, args)));
1134
+ }, (args) => runTool("find_hunt_targets", config, rateLimiter, async () => {
1135
+ const activity = Object.fromEntries(Object.keys(activityCatalogInput).map((key) => [key, args[key]]));
1136
+ return findHuntTargets(client, config, args, activity);
1137
+ }));
876
1138
  registerTool("find_repository_hunt_targets", {
877
1139
  title: "Find Repository-Aware Hunt Targets",
878
1140
  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.",
@@ -1090,16 +1352,19 @@ export function createBbradarServer(client, config) {
1090
1352
  }, (args) => runTool("check_watchlist_new_targets", config, rateLimiter, async () => checkWatchlistNewTargets(client, config, args)));
1091
1353
  registerTool("get_opportunities", {
1092
1354
  title: "Get BBRadar Opportunities",
1093
- description: `List programs in one opportunity tier. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
1094
- inputSchema: {
1355
+ description: `List programs in one opportunity tier. page_size max ${MAX_PROGRAMS_PER_SEARCH}. For Activity filters within a tier use find_programs with one opportunity_levels entry.`,
1356
+ inputSchema: z.object({
1095
1357
  level: opportunityLevelSchema.default("elite"),
1358
+ program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
1359
+ search: searchTextSchema.optional(),
1096
1360
  platforms: platformListSchema(),
1097
1361
  tags: tagListSchema(),
1098
1362
  updated_since: isoDateTimeSchema.optional(),
1363
+ snapshot: stableSnapshotSchema.optional(),
1099
1364
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
1100
1365
  page: pageSchema,
1101
1366
  page_size: programsPageSizeSchema
1102
- },
1367
+ }).strict(),
1103
1368
  outputSchema: {
1104
1369
  ...programListOutputShape(),
1105
1370
  level: opportunityLevelSchema.optional()
@@ -1107,11 +1372,15 @@ export function createBbradarServer(client, config) {
1107
1372
  annotations: readOnlyAnnotations
1108
1373
  }, (args) => runTool("get_opportunities", config, rateLimiter, async () => {
1109
1374
  const { level, vdp_mode, ...filters } = args;
1110
- const api = await client.getOpportunities(level, toQuery(filters));
1375
+ assertStableSnapshotSupported(client, "opportunities", filters.snapshot);
1376
+ const combined = args.program_kind !== undefined;
1377
+ if (combined)
1378
+ assertCatalogQuerySupported(client, "opportunities", args);
1379
+ const api = await client.getOpportunities(level, toQuery({ ...filters, vdp_mode: combined ? vdp_mode : undefined }));
1111
1380
  const data = readObject(api.data);
1112
1381
  const programs = readArray(data?.programs)
1113
1382
  .map((program) => toProgramCandidate(program, config.webBaseUrl))
1114
- .filter((candidate) => candidateMatchesVdpMode(candidate, vdp_mode))
1383
+ .filter((candidate) => combined || candidateMatchesVdpMode(candidate, vdp_mode))
1115
1384
  .map((candidate) => candidate.program);
1116
1385
  return withApiMetadata(api, {
1117
1386
  level,
@@ -1121,8 +1390,12 @@ export function createBbradarServer(client, config) {
1121
1390
  }));
1122
1391
  registerTool("find_programs", {
1123
1392
  title: "Find BBRadar Programs",
1124
- description: "Lower-level program-only discovery. For normal 'find good targets/programs' requests, prefer one find_hunt_targets call because it already combines ranking, exact bounded target samples, and recent additions. Use this tool when raw program filtering without target enrichment is specifically needed; keep output_mode=compact and avoid parallel alternate-sort calls.",
1125
- inputSchema: {
1393
+ description: `Program discovery. Activity inputs (payouts, reports, scope/reward changes, response, status, labels) run on the server over the complete catalog in one global order, within at most one opportunity tier. They combine freely with local filters (Web2/Web3, wildcards, target counts, bounty, competition_risks, report counts, free_submission_only, freshness): pages are read in server order and local filters only remove programs; scan.complete and scan.next_cursor report coverage. Sampled sorts need no Activity inputs. ${activityCatalogGuidance}`,
1394
+ inputSchema: z.object({
1395
+ ...activityCatalogInput,
1396
+ search: searchTextSchema.optional(),
1397
+ snapshot: stableSnapshotSchema.optional(),
1398
+ page: z.number().int().min(1).optional(),
1126
1399
  platforms: platformListSchema(),
1127
1400
  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."),
1128
1401
  tags: tagListSchema(),
@@ -1130,7 +1403,11 @@ export function createBbradarServer(client, config) {
1130
1403
  target_types: targetTypeListSchema(),
1131
1404
  language_tags: languageTagListSchema(),
1132
1405
  web3: z.boolean().default(false).describe("Require Web3 signals."),
1406
+ program_kind: z.enum(["bounty", "contest", "challenge"]).optional().describe("Source-classified program kind; sent to the Pro program list."),
1133
1407
  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."),
1408
+ free_submission_only: freeSubmissionOnlySchema,
1409
+ competition_risks: z.array(competitionRiskSchema).max(4).default([]).describe("Program competition labels to keep, e.g. [\"Low\"]."),
1410
+ cursor: z.string().min(1).max(512).optional().describe("scan.next_cursor from the previous result; repeat the same filters."),
1134
1411
  opportunity_levels: z.array(opportunityLevelSchema).max(4).default([]),
1135
1412
  updated_since: isoDateTimeSchema.optional(),
1136
1413
  min_bounty_min: z.number().int().min(0).optional(),
@@ -1144,7 +1421,7 @@ export function createBbradarServer(client, config) {
1144
1421
  exclude_mobile_only: z.boolean().default(false),
1145
1422
  exclude_non_web: z.boolean().default(false),
1146
1423
  has_api_scope: z.boolean().default(false),
1147
- contest_like: z.boolean().default(false).describe("Require contest/audit signals."),
1424
+ contest_like: z.boolean().default(false).describe("Require source-classified contests."),
1148
1425
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
1149
1426
  fresh_launch_days: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).optional(),
1150
1427
  min_wildcards: z.number().int().min(0).max(1000).default(0),
@@ -1154,7 +1431,7 @@ export function createBbradarServer(client, config) {
1154
1431
  min_added_7d: z.number().int().min(0).optional(),
1155
1432
  max_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1156
1433
  max_results: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(10),
1157
- sort_by: programSortSchema,
1434
+ sort_by: z.union([programSortSchema.removeDefault(), activityCatalogSortSchema]).optional(),
1158
1435
  include_target_samples: z.boolean().default(false),
1159
1436
  target_sample_programs: z.number().int().min(0).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1160
1437
  target_sample_size: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(5),
@@ -1162,13 +1439,40 @@ export function createBbradarServer(client, config) {
1162
1439
  only_bounty_eligible_targets: z.boolean().default(true),
1163
1440
  output_mode: programListModeSchema.default("compact"),
1164
1441
  upstream_request_budget: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
1165
- },
1442
+ }).strict(),
1166
1443
  outputSchema: findProgramsOutputShape(),
1167
1444
  annotations: readOnlyAnnotations
1168
- }, (args) => runTool("find_programs", config, rateLimiter, async () => findPrograms(client, config, {
1169
- ...args,
1170
- target_samples_only_wildcards: args.min_wildcards > 0
1171
- })));
1445
+ }, (args) => runTool("find_programs", config, rateLimiter, async () => {
1446
+ const serverCatalog = hasActivityCatalogInput(args) || args.snapshot !== undefined || args.page !== undefined || args.cursor !== undefined
1447
+ || (args.search !== undefined && supportsCatalogFilters(client, args.opportunity_levels.length ? "opportunities" : "programs")
1448
+ && !legacyCatalogFilters(args).length && ["include", "exclude", "only"].includes(args.vdp_mode));
1449
+ if (serverCatalog && args.cursor === undefined && !legacyCatalogFilters(args).length)
1450
+ return findServerCatalogPrograms(client, config, args);
1451
+ if (serverCatalog) {
1452
+ // Platform, tag, kind, VDP, update and status predicates were applied
1453
+ // by the server; only the remaining filters run locally.
1454
+ return findFilteredServerPrograms(client, config, args, {
1455
+ ...args, sort_by: "best_match", platforms: [], tags: [], program_kind: undefined, contest_like: false,
1456
+ vdp_mode: "include", opportunity_levels: [], updated_since: undefined, skip_status_check: true,
1457
+ min_bounty_min: args.min_bounty_min, min_bounty_max: args.min_bounty_max, max_bounty_max: args.max_bounty_max,
1458
+ max_public_report_count: args.max_public_report_count, fresh_launch_days: args.fresh_launch_days,
1459
+ min_eligible_targets: args.min_eligible_targets, min_total_targets: args.min_total_targets,
1460
+ min_added_24h: args.min_added_24h, min_added_7d: args.min_added_7d,
1461
+ target_samples_only_wildcards: args.min_wildcards > 0
1462
+ });
1463
+ }
1464
+ return findPrograms(client, config, {
1465
+ ...args,
1466
+ updated_since: args.updated_since,
1467
+ min_bounty_min: args.min_bounty_min, min_bounty_max: args.min_bounty_max,
1468
+ max_bounty_max: args.max_bounty_max, max_public_report_count: args.max_public_report_count,
1469
+ fresh_launch_days: args.fresh_launch_days,
1470
+ min_eligible_targets: args.min_eligible_targets, min_total_targets: args.min_total_targets,
1471
+ min_added_24h: args.min_added_24h, min_added_7d: args.min_added_7d,
1472
+ sort_by: programSortSchema.parse(args.sort_by),
1473
+ target_samples_only_wildcards: args.min_wildcards > 0
1474
+ });
1475
+ }));
1172
1476
  registerTool("find_wildcard_programs", {
1173
1477
  title: "Find Wildcard Programs",
1174
1478
  description: "Wildcard program shortlist with platform, minimum reward, freshness, and report-count filters.",
@@ -1772,7 +2076,8 @@ export function createBbradarServer(client, config) {
1772
2076
  ...apiEnvelopeOutputShape(),
1773
2077
  program: programOutputSchema().optional(),
1774
2078
  activity: jsonRecordSchema().optional(),
1775
- changes: z.array(jsonRecordSchema()).optional()
2079
+ changes: z.array(jsonRecordSchema()).optional(),
2080
+ meta: jsonRecordSchema().optional()
1776
2081
  },
1777
2082
  annotations: readOnlyAnnotations
1778
2083
  }, (args) => runTool("summarize_program_activity", config, rateLimiter, async () => runProgramIdTool(client, config, args.program_id, (programId) => summarizeProgramActivity(client, config, programId, args.recent_changes_limit))));
@@ -1976,13 +2281,15 @@ export function createBbradarServer(client, config) {
1976
2281
  const exportId = randomUUID();
1977
2282
  const sanitizedExport = sanitizeTargetsForExport(api.data, args.limit);
1978
2283
  const resourceUri = exportResourceUri(exportId);
1979
- rememberExport(exportStore, exportId, sanitizedExport);
2284
+ if (!api.noStore)
2285
+ rememberExport(exportStore, exportId, sanitizedExport);
1980
2286
  return withApiMetadata(api, {
1981
2287
  source_requests: resolvedProgramIds.sourceRequests.length > 0 ? resolvedProgramIds.sourceRequests : undefined,
1982
2288
  warnings: resolvedProgramIds.warnings.length > 0 ? resolvedProgramIds.warnings : undefined,
1983
2289
  export: {
1984
- export_id: exportId,
1985
- resource_uri: resourceUri,
2290
+ export_id: api.noStore ? undefined : exportId,
2291
+ resource_uri: api.noStore ? undefined : resourceUri,
2292
+ data: api.noStore ? sanitizedExport : undefined,
1986
2293
  preview: previewExportPayload(sanitizedExport),
1987
2294
  returned_preview_count: previewExportCount(sanitizedExport),
1988
2295
  limit: args.limit
@@ -2079,6 +2386,12 @@ function bbradarGuide(topic, profile, client) {
2079
2386
  arguments: { include_targets: false, max_cursor_pages: 2 },
2080
2387
  then: "For incremental polling, seed once with after_id and later set resume_cursor=true with the same filters."
2081
2388
  },
2389
+ { user_goal: "program payouts, recent reports, response times or Activity rankings", first_tool: "get_activity_capabilities", arguments: {}, then: "Choose an enabled period, sort and direction, then call get_activity_rankings with that tab for one server-ranked page. Do not approximate by paging or re-ranking the catalog." },
2390
+ { user_goal: "programs with recent payouts and new scope that also match Web2, competition or fee criteria", first_tool: "find_hunt_targets", arguments: { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", free_submission_only: true, ranking_mode: "lowest_competition" }, then: "Use find_programs with the same Activity inputs and competition_risks for program-only results; continue with scan.next_cursor." },
2391
+ { user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
2392
+ { 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." },
2393
+ { 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." },
2394
+ { user_goal: "exact recorded activity for one program", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
2082
2395
  {
2083
2396
  user_goal: "hunt targets combining repository activity with rewards, target quality, platforms, or Web2/Web3 constraints",
2084
2397
  first_tool: "find_repository_hunt_targets",
@@ -2221,6 +2534,12 @@ function coreBbradarGuide(topic, client) {
2221
2534
  first_tool: "get_repository_changes",
2222
2535
  arguments: { include_targets: false, max_cursor_pages: 2 }
2223
2536
  },
2537
+ { user_goal: "program payouts, recent reports, response times or Activity rankings", first_tool: "get_activity_capabilities", arguments: {}, then: "Read supported view combinations and call get_activity_rankings with that tab. Use get_activity_change_detail lazily for a selected Activity event." },
2538
+ { user_goal: "programs with recent payouts and new scope that also match Web2, competition or fee criteria", first_tool: "find_hunt_targets", arguments: { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", free_submission_only: true, ranking_mode: "lowest_competition" } },
2539
+ { user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
2540
+ { user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch change detail only when needed." },
2541
+ { user_goal: "most active programs", first_tool: "get_analytics_program_activity", arguments: {} },
2542
+ { user_goal: "exact recorded activity", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
2224
2543
  {
2225
2544
  user_goal: "hunt targets combining repository activity with rewards, target quality, platforms, or Web2/Web3 constraints",
2226
2545
  first_tool: "find_repository_hunt_targets",
@@ -2598,7 +2917,7 @@ function getCachedProgramFallback(config, requestedProgramId) {
2598
2917
  return cloneProgramFallbackForRequest(cached.fallback, requestedProgramId, true);
2599
2918
  }
2600
2919
  function rememberProgramFallback(config, fallback) {
2601
- if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0) {
2920
+ if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0 || hasNoStoreSource(fallback.sourceRequests)) {
2602
2921
  return;
2603
2922
  }
2604
2923
  pruneProgramFallbackCache();
@@ -2623,7 +2942,13 @@ function pruneProgramFallbackCache() {
2623
2942
  }
2624
2943
  }
2625
2944
  function programFallbackCacheKey(config, requestedProgramId) {
2626
- return `${config.apiBaseUrl}\n${normalizeTag(requestedProgramId)}`;
2945
+ return `${config.apiBaseUrl}\n${credentialCacheScope(config)}\n${isStrictProgramId(requestedProgramId) ? requestedProgramId : normalizeTag(requestedProgramId)}`;
2946
+ }
2947
+ function credentialCacheScope(config) {
2948
+ return createHash("sha256").update(config.apiKey).digest("hex");
2949
+ }
2950
+ function hasNoStoreSource(sourceRequests) {
2951
+ return sourceRequests.some((request) => readBoolean(request.no_store) === true);
2627
2952
  }
2628
2953
  function cloneProgramFallbackForRequest(fallback, requestedProgramId, fromCache) {
2629
2954
  const warnings = fromCache
@@ -3077,7 +3402,8 @@ function getCachedResolveProgram(config, input) {
3077
3402
  });
3078
3403
  }
3079
3404
  function rememberResolveProgram(config, input, payload) {
3080
- if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0) {
3405
+ const sourceRequests = readArray(payload.source_requests).filter((request) => readObject(request) !== undefined);
3406
+ if (config.cacheTtlMs <= 0 || config.cacheMaxEntries <= 0 || hasNoStoreSource(sourceRequests)) {
3081
3407
  return;
3082
3408
  }
3083
3409
  pruneResolveProgramCache();
@@ -3103,7 +3429,8 @@ function pruneResolveProgramCache() {
3103
3429
  function resolveProgramCacheKey(config, input) {
3104
3430
  return stableStringify({
3105
3431
  apiBaseUrl: config.apiBaseUrl,
3106
- query: normalizeTag(input.query),
3432
+ credentialScope: credentialCacheScope(config),
3433
+ query: isStrictProgramId(input.query) ? input.query : normalizeTag(input.query),
3107
3434
  platforms: input.platforms.map(normalizeTag).sort(),
3108
3435
  tags: input.tags.map(normalizeTag).sort(),
3109
3436
  opportunity_levels: [...input.opportunity_levels].sort(),
@@ -4311,8 +4638,9 @@ async function comparePrograms(client, config, input) {
4311
4638
  });
4312
4639
  }
4313
4640
  async function summarizeProgramActivity(client, config, programId, recentChangesLimit) {
4314
- const [programApi, changes] = await Promise.all([
4641
+ const [programApi, activityApi, changes] = await Promise.all([
4315
4642
  client.getProgram(programId),
4643
+ client.supportsEndpoint("program_activity") ? client.getProgramActivity(programId) : Promise.resolve(undefined),
4316
4644
  recentChangesLimit > 0
4317
4645
  ? fetchProgramChanges(client, config, programId, recentChangesLimit, true, true, true)
4318
4646
  : Promise.resolve(undefined)
@@ -4329,15 +4657,18 @@ async function summarizeProgramActivity(client, config, programId, recentChanges
4329
4657
  upstream_request_id: programApi.upstreamRequestId,
4330
4658
  ...apiSourceMetadata(programApi)
4331
4659
  },
4660
+ ...(activityApi ? [{ source: "program_activity", request_id: activityApi.requestId, upstream_request_id: activityApi.upstreamRequestId, ...apiSourceMetadata(activityApi) }] : []),
4332
4661
  ...(changes?.sourceRequests ?? [])
4333
4662
  ],
4334
4663
  program,
4335
4664
  activity: stripUndefined({
4665
+ activity_summary: activityApi ? sanitizeProPayload(readObject(activityApi.data)?.activity_summary) : undefined,
4336
4666
  target_counts: readObject(scopeSummary?.target_counts),
4337
4667
  target_activity: targetActivity,
4338
4668
  recent_change_count: changes?.changes.length
4339
4669
  }),
4340
- changes: changes?.changes ?? []
4670
+ changes: changes?.changes ?? [],
4671
+ meta: { target_change_samples_are_semantic_history: false, activity_source: activityApi ? "pro_activity_endpoint" : "legacy_program_projection" }
4341
4672
  };
4342
4673
  }
4343
4674
  async function getLatestAddedTargets(client, config, input) {
@@ -4593,10 +4924,12 @@ async function fetchProgramTargetsWithEndpointFallback(client, programId) {
4593
4924
  requestId: api.requestId,
4594
4925
  upstreamRequestId: api.upstreamRequestId,
4595
4926
  fetchedAt: api.fetchedAt,
4927
+ noStore: api.noStore,
4596
4928
  cache: readObject(stripUndefined({
4597
4929
  hit: api.cached,
4598
4930
  coalesced_live_request: api.coalesced,
4599
- expires_at: api.cacheExpiresAt
4931
+ expires_at: api.cacheExpiresAt,
4932
+ no_store: api.noStore
4600
4933
  })),
4601
4934
  source: "program_targets",
4602
4935
  rawTargets,
@@ -4654,7 +4987,7 @@ function getCachedProgramTargetsFallback(client, programId) {
4654
4987
  }
4655
4988
  function rememberProgramTargetsFallback(client, programId, lookup) {
4656
4989
  const diagnostics = client.diagnostics();
4657
- if (!diagnostics.response_cache.enabled || lookup.unavailable) {
4990
+ if (!diagnostics.response_cache.enabled || lookup.unavailable || lookup.noStore) {
4658
4991
  return;
4659
4992
  }
4660
4993
  const cache = programTargetsFallbackCacheForClient(client);
@@ -4692,7 +5025,7 @@ function pruneProgramTargetsFallbackCache(cache) {
4692
5025
  }
4693
5026
  }
4694
5027
  function programTargetsFallbackCacheKey(programId) {
4695
- return normalizeTag(programId);
5028
+ return programId;
4696
5029
  }
4697
5030
  function cacheTtlMsFromDiagnostics(cache) {
4698
5031
  return cache.enabled ? cache.ttl_ms : 0;
@@ -4711,10 +5044,12 @@ async function fetchProgramTargetsFromExportFallback(client, programId) {
4711
5044
  requestId: api.requestId,
4712
5045
  upstreamRequestId: api.upstreamRequestId,
4713
5046
  fetchedAt: api.fetchedAt,
5047
+ noStore: api.noStore,
4714
5048
  cache: readObject(stripUndefined({
4715
5049
  hit: api.cached,
4716
5050
  coalesced_live_request: api.coalesced,
4717
- expires_at: api.cacheExpiresAt
5051
+ expires_at: api.cacheExpiresAt,
5052
+ no_store: api.noStore
4718
5053
  })),
4719
5054
  source: "target_export",
4720
5055
  rawTargets,
@@ -4761,7 +5096,8 @@ async function getProgramFromTargetsOnlyFallback(client, programId, programError
4761
5096
  cache: readObject(stripUndefined({
4762
5097
  hit: targetsApi.cached,
4763
5098
  coalesced_live_request: targetsApi.coalesced,
4764
- expires_at: targetsApi.cacheExpiresAt
5099
+ expires_at: targetsApi.cacheExpiresAt,
5100
+ no_store: targetsApi.noStore
4765
5101
  })),
4766
5102
  source_requests: [
4767
5103
  stripUndefined({
@@ -4906,8 +5242,6 @@ async function exportTargetsFromProgramTargetsFallback(client, config, exportSto
4906
5242
  generated_at: new Date().toISOString()
4907
5243
  });
4908
5244
  const exportId = randomUUID();
4909
- const resourceUri = exportResourceUri(exportId);
4910
- rememberExport(exportStore, exportId, exportPayload);
4911
5245
  return stripUndefined({
4912
5246
  request_id: randomUUID(),
4913
5247
  source_requests: sourceRequests,
@@ -4915,7 +5249,7 @@ async function exportTargetsFromProgramTargetsFallback(client, config, exportSto
4915
5249
  errors: errors.length > 0 ? errors : undefined,
4916
5250
  export: {
4917
5251
  export_id: exportId,
4918
- resource_uri: resourceUri,
5252
+ data: exportPayload,
4919
5253
  preview: previewExportPayload(exportPayload),
4920
5254
  returned_preview_count: previewExportCount(exportPayload),
4921
5255
  limit: input.limit
@@ -5642,6 +5976,10 @@ function applyCollectionContinuation(payload, field, label, store, config) {
5642
5976
  if (!items || items.length <= inlineLimit) {
5643
5977
  return payload;
5644
5978
  }
5979
+ // A no-store page stays only in this tool result, never in a reusable resource.
5980
+ if (readBoolean(readObject(payload.cache)?.no_store) === true) {
5981
+ return payload;
5982
+ }
5645
5983
  const continuationId = randomUUID();
5646
5984
  const expiresAt = Date.now() + (config.continuationTtlMs ?? LOCAL_EXPORT_TTL_MS);
5647
5985
  const stored = rememberContinuation(store, continuationId, label, items, expiresAt);
@@ -5898,10 +6236,12 @@ function formatProgram(program, mode) {
5898
6236
  function compactProgram(program) {
5899
6237
  return stripUndefined({
5900
6238
  id: stringField(program, "id"),
6239
+ catalog_id: stringField(program, "catalog_id"),
5901
6240
  handle: stringField(program, "handle"),
5902
6241
  name: stringField(program, "name"),
5903
6242
  platform: stringField(program, "platform"),
5904
6243
  catalog: stringField(program, "catalog"),
6244
+ program_kind: stringField(program, "program_kind"),
5905
6245
  program_status: stringField(program, "program_status"),
5906
6246
  is_active: readBoolean(program.is_active),
5907
6247
  program_type: stringField(program, "program_type"),
@@ -5910,15 +6250,24 @@ function compactProgram(program) {
5910
6250
  policy_url: stringField(program, "policy_url"),
5911
6251
  reporting_url: stringField(program, "reporting_url"),
5912
6252
  reward_status: stringField(program, "reward_status"),
5913
- data_capabilities: readObject(program.data_capabilities),
5914
- opportunity_tag: readObject(program.opportunity_tag),
6253
+ data_capabilities: program.data_capabilities,
6254
+ opportunity_tag: program.opportunity_tag,
5915
6255
  competition_risk: stringField(program, "competition_risk"),
5916
6256
  competition_risk_reason: stringField(program, "competition_risk_reason"),
5917
- submission_cost: readObject(program.submission_cost),
5918
- reward_range: readObject(program.reward_range),
5919
- public_report_count: readNumber(program.public_report_count),
6257
+ submission_cost: program.submission_cost,
6258
+ payout_summary: program.payout_summary,
6259
+ activity: program.activity,
6260
+ activity_summary: program.activity_summary,
6261
+ reward_range: program.reward_range,
6262
+ reward_range_label: stringField(program, "reward_range_label"),
6263
+ public_report_count: program.public_report_count,
5920
6264
  public_report_count_label: stringField(program, "public_report_count_label"),
5921
6265
  first_seen_date: stringField(program, "first_seen_date"),
6266
+ launch_at: program.launch_at,
6267
+ launch_at_precision: stringField(program, "launch_at_precision"),
6268
+ ends_at: program.ends_at,
6269
+ ends_on: program.ends_on,
6270
+ verification: program.verification,
5922
6271
  last_updated: stringField(program, "last_updated"),
5923
6272
  targets_updated_at: stringField(program, "targets_updated_at"),
5924
6273
  scope_summary: readObject(program.scope_summary),
@@ -6240,7 +6589,7 @@ function candidateLooksLikeCtf(candidate) {
6240
6589
  return candidate.looksLikeCtf;
6241
6590
  }
6242
6591
  function candidateLooksContestLike(candidate) {
6243
- return tagIncludesAnyNormalized(candidate.normalizedSearchSignals, WEB3_CONTEST_SIGNALS);
6592
+ return stringField(candidate.program, "program_kind") === "contest";
6244
6593
  }
6245
6594
  function candidateHasKnownNoBounty(candidate) {
6246
6595
  const programType = normalizeTag(stringField(candidate.program, "program_type") ?? "");
@@ -6467,7 +6816,10 @@ function clampLimit(name, value, max, warnings) {
6467
6816
  warnings.push(`${name} was clamped from ${value} to ${max}.`);
6468
6817
  return max;
6469
6818
  }
6470
- async function findHuntTargets(client, config, input) {
6819
+ // Candidate pool for hunts narrowed by Activity on the server. Matches arrive in
6820
+ // server order, so a larger pool lets ranking_mode choose among more of them.
6821
+ const HUNT_SERVER_POOL = 100;
6822
+ async function findHuntTargets(client, config, input, activity = {}) {
6471
6823
  const warnings = [];
6472
6824
  const errors = [];
6473
6825
  const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
@@ -6510,6 +6862,7 @@ async function findHuntTargets(client, config, input) {
6510
6862
  min_total_targets: input.min_total_targets,
6511
6863
  min_added_24h: undefined,
6512
6864
  min_added_7d: input.min_added_7d,
6865
+ free_submission_only: input.free_submission_only,
6513
6866
  max_pages: input.max_program_pages,
6514
6867
  max_results: candidatePoolSize,
6515
6868
  sort_by: huntProgramSort(input.ranking_mode),
@@ -6531,8 +6884,17 @@ async function findHuntTargets(client, config, input) {
6531
6884
  else if (recentChangeQueries.length < desiredRecentChangeQueries.length) {
6532
6885
  warnings.push(`Recent target-change queries were reduced from ${desiredRecentChangeQueries.length} to ${recentChangeQueries.length} to preserve the upstream request budget.`);
6533
6886
  }
6887
+ const serverFiltered = hasActivityCatalogInput(activity);
6534
6888
  const [programPayload, recentResult] = await Promise.all([
6535
- findPrograms(client, config, programInput),
6889
+ serverFiltered
6890
+ ? findFilteredServerPrograms(client, config, {
6891
+ ...programInput, ...activity, opportunity_levels: [], sort_by: activity.sort_by, sort_direction: activity.sort_direction,
6892
+ platforms: input.platforms, tags: input.tags, updated_since: input.updated_since, vdp_mode: "exclude", include_target_samples: false
6893
+ }, {
6894
+ ...programInput, platforms: [], tags: [], vdp_mode: "include", updated_since: undefined,
6895
+ max_pages: programRequestBudget, upstream_request_budget: programRequestBudget
6896
+ }, HUNT_SERVER_POOL)
6897
+ : findPrograms(client, config, programInput),
6536
6898
  recentChangesEnabled
6537
6899
  ? fetchHuntRecentChanges(client, config, input, recentChangeQueries)
6538
6900
  : Promise.resolve({ changes: [], sourceRequests: [], requestsUsed: 0, errors: [] })
@@ -6602,6 +6964,8 @@ async function findHuntTargets(client, config, input) {
6602
6964
  min_target_score: input.min_target_score,
6603
6965
  target_opportunity_label: input.target_opportunity_label,
6604
6966
  competition_risk: input.competition_risk,
6967
+ free_submission_only: input.free_submission_only || undefined,
6968
+ activity: serverFiltered ? stripUndefined(activity) : undefined,
6605
6969
  recent_target_days: input.include_recent_changes ? input.recent_target_days : undefined,
6606
6970
  ranking_mode: input.ranking_mode
6607
6971
  }),
@@ -6612,10 +6976,12 @@ async function findHuntTargets(client, config, input) {
6612
6976
  },
6613
6977
  ranking_scope: stripUndefined({
6614
6978
  ...(readObject(programPayload.ranking_scope) ?? {}),
6979
+ local_ranking: serverFiltered ? input.ranking_mode : undefined,
6615
6980
  single_program_catalog_pass: true,
6616
6981
  recent_change_window_days: input.include_recent_changes ? input.recent_target_days : undefined,
6617
6982
  recent_change_page_size: input.include_recent_changes ? input.recent_changes_page_size : undefined
6618
6983
  }),
6984
+ scan: readObject(programPayload.scan),
6619
6985
  result_guidance: {
6620
6986
  final_shortlist: true,
6621
6987
  follow_up_calls_required: false,
@@ -7328,6 +7694,262 @@ function uniqueProgramsFromRepositoryMatches(matches) {
7328
7694
  }
7329
7695
  return [...programs.values()];
7330
7696
  }
7697
+ /** Filters the MCP applies to program records after retrieval. */
7698
+ function localCatalogFilters(input) {
7699
+ const keys = ["exclude_platforms", "scope_tags", "target_types", "language_tags", "web3", "exclude_web3",
7700
+ "min_bounty_min", "min_bounty_max", "max_bounty_max", "require_bounty", "max_public_report_count",
7701
+ "require_known_report_count", "include_unknown_report_count", "exclude_ctf", "exclude_mobile_only",
7702
+ "exclude_non_web", "has_api_scope", "fresh_launch_days", "min_eligible_targets", "min_total_targets",
7703
+ "min_added_24h", "min_added_7d", "free_submission_only", "competition_risks"].filter(key => {
7704
+ const value = input[key];
7705
+ return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false;
7706
+ });
7707
+ if (Number(input.min_wildcards) > 0)
7708
+ keys.push("min_wildcards");
7709
+ return keys;
7710
+ }
7711
+ function legacyCatalogFilters(input) {
7712
+ return input.include_target_samples === true ? [...localCatalogFilters(input), "include_target_samples"] : localCatalogFilters(input);
7713
+ }
7714
+ function supportsCatalogFilters(client, endpoint) {
7715
+ return client.capabilitySnapshot().status === "negotiated"
7716
+ && readObject(client.endpointCapability(endpoint)?.catalog_filters)?.enabled === true;
7717
+ }
7718
+ function assertCatalogQuerySupported(client, endpoint, input) {
7719
+ if (!["include", "exclude", "only"].includes(String(input.vdp_mode ?? "exclude"))) {
7720
+ throw new Error("Server-ranked queries accept vdp_mode include, exclude or only. The heuristic only_likely and only_known_no_bounty modes are available only in legacy sampled discovery.");
7721
+ }
7722
+ const capability = client.endpointCapability(endpoint);
7723
+ if (!supportsCatalogFilters(client, endpoint)) {
7724
+ throw new Error("This API deployment does not advertise the required catalog filters. Upgrade the API, or use legacy discovery without server paging or Activity filters.");
7725
+ }
7726
+ if (requiresActivityQuery(input) && readObject(capability?.activity_filters)?.enabled !== true) {
7727
+ throw new Error("Activity catalog filters are unavailable on this API deployment. Ordinary program discovery remains available.");
7728
+ }
7729
+ const kind = input.program_kind ?? (input.contest_like ? "contest" : undefined);
7730
+ if (kind && !readArray(capability?.program_kind_filter).includes(kind)) {
7731
+ throw new Error(`This API deployment does not support program_kind=${String(kind)} on ${endpoint}; the filter was not sent or discarded.`);
7732
+ }
7733
+ }
7734
+ function hasActivityCatalogInput(input) {
7735
+ return Object.keys(activityCatalogInput).some((key) => key === "sort_by"
7736
+ ? activityCatalogSortSchema.safeParse(input[key]).success
7737
+ : input[key] !== undefined);
7738
+ }
7739
+ /** Validates and builds one server-ordered catalog query; nothing is sent. */
7740
+ function serverCatalogQuery(client, input) {
7741
+ const levels = Array.isArray(input.opportunity_levels) ? input.opportunity_levels : [];
7742
+ if (levels.length > 1)
7743
+ throw new Error("Combined Activity queries accept at most one opportunity tier; separate tier pages cannot form a global ranking.");
7744
+ if (input.sort_by !== undefined && !activityCatalogSortSchema.safeParse(input.sort_by).success) {
7745
+ throw new Error("Combined Activity queries require an enabled server sort_by. Omit it for updated descending; legacy sampled sorts cannot rank these results.");
7746
+ }
7747
+ if (input.contest_like && input.program_kind && input.program_kind !== "contest")
7748
+ throw new Error("contest_like conflicts with program_kind.");
7749
+ const endpoint = levels.length ? "opportunities" : "programs";
7750
+ assertCatalogQuerySupported(client, endpoint, input);
7751
+ assertStableSnapshotSupported(client, endpoint, typeof input.snapshot === "string" ? input.snapshot : undefined);
7752
+ const signals = Object.fromEntries(Object.keys(activityCatalogInput).filter((key) => input[key] !== undefined).map((key) => [key, input[key]]));
7753
+ return {
7754
+ endpoint, level: levels[0], maxPageSize: client.endpointNumber(endpoint, "max_page_size", MAX_PROGRAMS_PER_SEARCH),
7755
+ query: { ...signals, search: input.search, platforms: input.platforms, tags: input.tags,
7756
+ program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined), updated_since: input.updated_since,
7757
+ vdp_mode: input.vdp_mode, snapshot: input.snapshot }
7758
+ };
7759
+ }
7760
+ function readServerCatalogPage(client, catalog, page, pageSize, snapshot) {
7761
+ const query = toQuery({ ...catalog.query, snapshot, page, page_size: pageSize });
7762
+ return catalog.level ? client.getOpportunities(catalog.level, query) : client.listPrograms(query);
7763
+ }
7764
+ async function findServerCatalogPrograms(client, config, input) {
7765
+ // One exact server page: page and snapshot continue it. Local filters use the
7766
+ // ordered scan below instead.
7767
+ const catalog = serverCatalogQuery(client, input);
7768
+ const pageSize = Number(input.max_results) || 10;
7769
+ if (pageSize > catalog.maxPageSize)
7770
+ throw new Error(`Combined Activity pages support max_results up to ${catalog.maxPageSize}; use page and the returned stable snapshot to continue.`);
7771
+ const api = await readServerCatalogPage(client, catalog, Number(input.page ?? 1), pageSize, input.snapshot);
7772
+ const data = readObject(api.data);
7773
+ const mode = input.output_mode === "full" ? "full" : "compact";
7774
+ return withApiMetadata(api, {
7775
+ programs: readArray(data?.programs).map((program) => formatProgram(sanitizeProgram(program, config.webBaseUrl), mode)),
7776
+ ranking_scope: { mode: "server", sort_by: input.sort_by ?? "updated", sort_direction: input.sort_direction ?? "desc" },
7777
+ meta: sanitizeJson(data?.meta)
7778
+ });
7779
+ }
7780
+ // Pages requested together while scanning. Activity catalog reads share the
7781
+ // two-request changes pool, so a wider window would only queue.
7782
+ const CATALOG_SCAN_PREFETCH = 2;
7783
+ function encodeScanCursor(cursor) {
7784
+ return Buffer.from(JSON.stringify(cursor)).toString("base64url");
7785
+ }
7786
+ function decodeScanCursor(value, fingerprint, pageSize) {
7787
+ let cursor;
7788
+ try {
7789
+ cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
7790
+ }
7791
+ catch {
7792
+ cursor = undefined;
7793
+ }
7794
+ if (!cursor || cursor.v !== 1 || !Number.isInteger(cursor.p) || cursor.p < 1 || !Number.isInteger(cursor.k) || cursor.k < 0) {
7795
+ throw new Error("Invalid scan cursor. Use scan.next_cursor from the previous find_programs result.");
7796
+ }
7797
+ if (cursor.f !== fingerprint || cursor.s !== pageSize) {
7798
+ throw new Error("This scan cursor belongs to different filters. Repeat the previous filters exactly, or start again without cursor.");
7799
+ }
7800
+ return cursor;
7801
+ }
7802
+ /**
7803
+ * Reads server-ordered pages and keeps the programs that also pass the local
7804
+ * filters, until enough match or the page/request budget is spent. Filtering
7805
+ * never reorders, so the result is a prefix of the server's global order.
7806
+ */
7807
+ async function scanServerCatalog(client, config, input, local, limits) {
7808
+ const catalog = serverCatalogQuery(client, input);
7809
+ const pageSize = catalog.maxPageSize;
7810
+ const localFilters = localCatalogFilters(input);
7811
+ const fingerprint = createHash("sha256")
7812
+ .update(JSON.stringify([catalog.endpoint, catalog.level, toQuery({ ...catalog.query, snapshot: undefined }), localFilters.map((key) => [key, input[key]])]))
7813
+ .digest("base64url").slice(0, 16);
7814
+ const cursorValue = typeof input.cursor === "string" ? input.cursor : undefined;
7815
+ if (cursorValue && input.page !== undefined)
7816
+ throw new Error("Use either cursor or page, not both.");
7817
+ const start = cursorValue ? decodeScanCursor(cursorValue, fingerprint, pageSize)
7818
+ : { v: 1, p: Number(input.page ?? 1), k: 0, s: pageSize, f: fingerprint, a: undefined, t: undefined };
7819
+ const sourceRequests = [];
7820
+ let matches = [];
7821
+ let seen = new Set();
7822
+ let nextPage = start.p;
7823
+ let skip = start.k;
7824
+ let version = start.a;
7825
+ let token = start.t ?? input.snapshot;
7826
+ let totalPages;
7827
+ let serverTotal;
7828
+ let rowsScanned = 0;
7829
+ let pagesRead = 0;
7830
+ let restarted = false;
7831
+ let exhausted = false;
7832
+ let resume;
7833
+ scan: while (matches.length < limits.maxResults) {
7834
+ const pagesLeft = totalPages === undefined ? Number.POSITIVE_INFINITY : totalPages - nextPage + 1;
7835
+ const allowance = Math.min(limits.maxPages - pagesRead, limits.budget.remaining, pagesLeft);
7836
+ if (allowance <= 0)
7837
+ break;
7838
+ // Speculate only from an observed match rate, never before the first page.
7839
+ const rate = rowsScanned > 0 ? Math.max(matches.length / rowsScanned, 0.05) : 1;
7840
+ const needed = pagesRead === 0 ? 1 : Math.ceil((limits.maxResults - matches.length) / (rate * pageSize));
7841
+ const batch = Math.max(1, Math.min(allowance, needed, CATALOG_SCAN_PREFETCH));
7842
+ const pageToken = token;
7843
+ const pages = await Promise.all(Array.from({ length: batch }, (_, index) => nextPage + index)
7844
+ .map(async (page) => ({ page, api: await readServerCatalogPage(client, catalog, page, pageSize, pageToken) })));
7845
+ for (const { page, api } of pages) {
7846
+ pagesRead += 1;
7847
+ limits.budget.remaining -= api.cached || api.coalesced ? 0 : 1;
7848
+ sourceRequests.push({ source: catalog.level ? `opportunities:${catalog.level}` : "programs", page, request_id: api.requestId,
7849
+ upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
7850
+ const data = readObject(api.data);
7851
+ const meta = readObject(data?.meta);
7852
+ const pageVersion = stringField(meta ?? {}, "activity_snapshot_id");
7853
+ if (version && pageVersion && pageVersion !== version) {
7854
+ // Never mix rows from two Activity versions. Within one call, restart
7855
+ // once on the new version; a continued scan must be restarted by the caller.
7856
+ if (cursorValue || restarted) {
7857
+ throw new Error("The Activity version changed while scanning. Rerun the same query without cursor.");
7858
+ }
7859
+ restarted = true;
7860
+ matches = [];
7861
+ seen = new Set();
7862
+ rowsScanned = 0;
7863
+ nextPage = start.p;
7864
+ skip = start.k;
7865
+ totalPages = undefined;
7866
+ version = pageVersion;
7867
+ continue scan;
7868
+ }
7869
+ version ??= pageVersion;
7870
+ totalPages = readNumber(meta?.total_pages) ?? totalPages;
7871
+ serverTotal = readNumber(meta?.total) ?? serverTotal;
7872
+ if (token !== undefined && typeof meta?.snapshot === "string")
7873
+ token = meta.snapshot;
7874
+ const rows = readArray(data?.programs);
7875
+ const lastPage = rows.length < pageSize || (totalPages !== undefined && page >= totalPages);
7876
+ for (let index = skip; index < rows.length; index += 1) {
7877
+ rowsScanned += 1;
7878
+ const candidate = toProgramCandidate(rows[index], config.webBaseUrl);
7879
+ const id = stringField(candidate.program, "id") ?? `${page}:${index}`;
7880
+ if (seen.has(id) || !programCandidateMatches(candidate, local))
7881
+ continue;
7882
+ seen.add(id);
7883
+ matches.push(candidate);
7884
+ if (matches.length >= limits.maxResults) {
7885
+ resume = index + 1 < rows.length ? { p: page, k: index + 1 } : lastPage ? undefined : { p: page + 1, k: 0 };
7886
+ exhausted = resume === undefined;
7887
+ break scan;
7888
+ }
7889
+ }
7890
+ skip = 0;
7891
+ if (lastPage) {
7892
+ exhausted = true;
7893
+ break scan;
7894
+ }
7895
+ nextPage = page + 1;
7896
+ }
7897
+ }
7898
+ if (!exhausted && !resume)
7899
+ resume = { p: nextPage, k: skip };
7900
+ return {
7901
+ candidates: matches,
7902
+ sourceRequests,
7903
+ scan: stripUndefined({
7904
+ order: "Server order; local filters only remove programs.",
7905
+ sort_by: input.sort_by ?? "updated",
7906
+ sort_direction: input.sort_direction ?? "desc",
7907
+ local_filters: localFilters.length > 0 ? localFilters : undefined,
7908
+ server_matches: serverTotal,
7909
+ rows_scanned: rowsScanned,
7910
+ pages_read: pagesRead,
7911
+ complete: exhausted,
7912
+ next_cursor: resume
7913
+ ? encodeScanCursor({ v: 1, p: resume.p, k: resume.k, s: pageSize, f: fingerprint, a: version, t: typeof token === "string" ? token : undefined })
7914
+ : undefined,
7915
+ activity_snapshot_id: version,
7916
+ restarted_on_new_activity_version: restarted || undefined
7917
+ })
7918
+ };
7919
+ }
7920
+ /** find_programs with server Activity filters plus local filters or target samples. */
7921
+ async function findFilteredServerPrograms(client, config, input, local, maxResults = local.max_results) {
7922
+ const normalized = normalizeFindProgramsInput(local);
7923
+ const warnings = normalized.warnings;
7924
+ local = normalized.input;
7925
+ const budget = { initial: local.upstream_request_budget, remaining: local.upstream_request_budget };
7926
+ const result = await scanServerCatalog(client, config, input, local, { maxResults, maxPages: local.max_pages, budget });
7927
+ if (result.scan.complete !== true) {
7928
+ warnings.push("More server pages remain; scan.next_cursor continues in the same order.");
7929
+ }
7930
+ const samplePrograms = Math.min(local.target_sample_programs, result.candidates.length, Math.max(0, budget.remaining));
7931
+ const targetSamples = local.include_target_samples && samplePrograms > 0
7932
+ ? await collectTargetSamples(client, result.candidates, { ...local, target_sample_programs: samplePrograms }, budget)
7933
+ : {};
7934
+ const outputMode = local.output_mode ?? "compact";
7935
+ return stripUndefined({
7936
+ request_id: randomUUID(),
7937
+ fetched_at: new Date().toISOString(),
7938
+ source_requests: result.sourceRequests,
7939
+ warnings: warnings.length > 0 ? warnings : undefined,
7940
+ upstream_budget: { used: budget.initial - budget.remaining, remaining: budget.remaining },
7941
+ ranking_scope: { mode: "server_filtered", sort_by: result.scan.sort_by, sort_direction: result.scan.sort_direction, complete: result.scan.complete },
7942
+ scan: result.scan,
7943
+ programs: result.candidates.map((candidate, index) => candidateOutput(candidate, index, targetSamples, outputMode)),
7944
+ meta: { returned: result.candidates.length, server_matches: result.scan.server_matches }
7945
+ });
7946
+ }
7947
+ function programHasNoSubmissionFee(program) {
7948
+ // Mirrors the API: no detected cost, or a detected zero with no maximum.
7949
+ const cost = readObject(program.submission_cost);
7950
+ const amount = numberField(cost, "amount");
7951
+ return amount === undefined || (amount === 0 && (numberField(cost, "maximum_amount") ?? 0) === 0);
7952
+ }
7331
7953
  async function findPrograms(client, config, input) {
7332
7954
  const normalized = normalizeFindProgramsInput(input);
7333
7955
  const warnings = normalized.warnings;
@@ -7344,8 +7966,10 @@ async function findPrograms(client, config, input) {
7344
7966
  ...(input.min_wildcards > 0 ? ["wildcard"] : [])
7345
7967
  ]);
7346
7968
  const collected = await collectPrograms(client, {
7969
+ search: input.search,
7347
7970
  platforms: input.platforms,
7348
7971
  tags: upstreamTags,
7972
+ program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined),
7349
7973
  updated_since: input.updated_since,
7350
7974
  opportunity_levels: input.opportunity_levels,
7351
7975
  max_pages: input.max_pages,
@@ -7394,6 +8018,7 @@ async function findPrograms(client, config, input) {
7394
8018
  exclude_non_web: input.exclude_non_web,
7395
8019
  has_api_scope: input.has_api_scope,
7396
8020
  contest_like: input.contest_like ? true : undefined,
8021
+ program_kind: input.program_kind,
7397
8022
  vdp_mode: input.vdp_mode && input.vdp_mode !== "include" ? input.vdp_mode : undefined,
7398
8023
  fresh_launch_days: input.fresh_launch_days,
7399
8024
  min_wildcards: input.min_wildcards,
@@ -7401,6 +8026,8 @@ async function findPrograms(client, config, input) {
7401
8026
  min_total_targets: input.min_total_targets,
7402
8027
  min_added_24h: input.min_added_24h,
7403
8028
  min_added_7d: input.min_added_7d,
8029
+ free_submission_only: input.free_submission_only || undefined,
8030
+ competition_risks: input.competition_risks?.length ? input.competition_risks : undefined,
7404
8031
  sort_by: input.sort_by,
7405
8032
  output_mode: outputMode
7406
8033
  }),
@@ -7823,6 +8450,7 @@ async function fetchProgramPage(client, source, sourceIndex, page, options) {
7823
8450
  platforms: options.platforms,
7824
8451
  tags: options.tags,
7825
8452
  updated_since: options.updated_since,
8453
+ program_kind: source.kind === "programs" ? options.program_kind : undefined,
7826
8454
  page,
7827
8455
  page_size: MAX_PROGRAMS_PER_SEARCH
7828
8456
  });
@@ -7908,8 +8536,11 @@ function toProgramCandidate(rawProgram, webBaseUrl) {
7908
8536
  return candidate;
7909
8537
  }
7910
8538
  function programCandidateMatches(candidate, filters) {
8539
+ if (filters.program_kind && stringField(candidate.program, "program_kind") !== filters.program_kind) {
8540
+ return false;
8541
+ }
7911
8542
  const programStatus = normalizeTag(stringField(candidate.program, "program_status") ?? "");
7912
- if (readBoolean(candidate.program.is_active) === false || programStatus === "paused") {
8543
+ if (!filters.skip_status_check && (readBoolean(candidate.program.is_active) === false || programStatus === "paused")) {
7913
8544
  return false;
7914
8545
  }
7915
8546
  if (filters.platforms.length > 0 && !filters.platforms.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
@@ -8002,6 +8633,15 @@ function programCandidateMatches(candidate, filters) {
8002
8633
  if (filters.min_added_7d !== undefined && (candidate.added7d ?? 0) < filters.min_added_7d) {
8003
8634
  return false;
8004
8635
  }
8636
+ if (filters.free_submission_only && !programHasNoSubmissionFee(candidate.program)) {
8637
+ return false;
8638
+ }
8639
+ if ((filters.competition_risks?.length ?? 0) > 0) {
8640
+ const risk = normalizeTag(stringField(candidate.program, "competition_risk") ?? "unknown");
8641
+ if (!filters.competition_risks.some((value) => normalizeTag(value) === risk)) {
8642
+ return false;
8643
+ }
8644
+ }
8005
8645
  return true;
8006
8646
  }
8007
8647
  function compareProgramCandidates(left, right, sortBy) {
@@ -8042,6 +8682,8 @@ function compareProgramCandidates(left, right, sortBy) {
8042
8682
  right.lastUpdatedTime - left.lastUpdatedTime);
8043
8683
  }
8044
8684
  function candidateMatchesVdpMode(candidate, vdpMode) {
8685
+ if (vdpMode === "only")
8686
+ return stringField(candidate.program, "program_type") === "vdp";
8045
8687
  if (vdpMode === "exclude") {
8046
8688
  return !candidateLooksLikeVdp(candidate);
8047
8689
  }
@@ -8270,7 +8912,7 @@ function registerPrompts(server, profile) {
8270
8912
  tags: z.string().optional().describe("Optional comma-separated scope/language/target-type tags."),
8271
8913
  max_programs: z.string().optional().describe("Optional maximum shortlist size.")
8272
8914
  }
8273
- }, (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.
8915
+ }, (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.
8274
8916
 
8275
8917
  Find the best BBRadar program candidates.
8276
8918
  ${discoveryToolInstructions}
@@ -8321,8 +8963,12 @@ The plan must:
8321
8963
  - Prioritize targets by bounty eligibility, freshness, wildcard status, target type, and severity labels.
8322
8964
  - Include a short checklist of BBRadar follow-up tool calls and decision points.`));
8323
8965
  }
8324
- function resolveFeedCursor(store, endpoint, filters, resume) {
8325
- const normalizedFilters = stableStringify(normalizeFeedCursorFilters(filters));
8966
+ function resolveFeedCursor(store, endpoint, filters, resume, config) {
8967
+ const normalizedFilters = stableStringify({
8968
+ api_base_url: config.apiBaseUrl,
8969
+ credential_scope: credentialCacheScope(config),
8970
+ filters: normalizeFeedCursorFilters(filters)
8971
+ });
8326
8972
  const key = `${endpoint}:${createHash("sha256").update(normalizedFilters).digest("hex")}`;
8327
8973
  const explicitAfterId = readNumber(filters.after_id);
8328
8974
  if (explicitAfterId !== undefined) {
@@ -8353,7 +8999,7 @@ function advanceFeedCursor(store, cursor, rawMeta) {
8353
8999
  stored_in_memory: true
8354
9000
  };
8355
9001
  }
8356
- async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudgetMs, initialSnapshot, fetchPage, refreshInventory) {
9002
+ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudgetMs, initialSnapshot, initialPage, fetchPage, refreshInventory) {
8357
9003
  const startedAt = Date.now();
8358
9004
  const results = [];
8359
9005
  const sourceRequests = [];
@@ -8370,7 +9016,7 @@ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudget
8370
9016
  do {
8371
9017
  let api;
8372
9018
  try {
8373
- api = await fetchPage(currentCursor.afterId, undefined, activeSnapshot);
9019
+ api = await fetchPage(currentCursor.afterId, undefined, activeSnapshot, activeSnapshot ? initialPage + pagesFetched : 1);
8374
9020
  feedRecoveryProgress.get(store)?.delete(currentCursor.key);
8375
9021
  }
8376
9022
  catch (error) {
@@ -8466,6 +9112,10 @@ async function collectCursorFeedPages(store, initialCursor, maxPages, timeBudget
8466
9112
  }
8467
9113
  const data = readObject(api.data);
8468
9114
  const returnedSnapshot = readString(readObject(data?.meta)?.snapshot);
9115
+ if (activeSnapshot && !returnedSnapshot)
9116
+ throw new Error("Stable snapshot response omitted its token; discard this page and start a new snapshot.");
9117
+ if (activeSnapshot && activeSnapshot !== "start" && returnedSnapshot !== activeSnapshot)
9118
+ throw new Error("Stable snapshot changed between pages; discard these pages and start a new snapshot.");
8469
9119
  if (activeSnapshot === "start" && returnedSnapshot) {
8470
9120
  activeSnapshot = returnedSnapshot;
8471
9121
  }
@@ -8668,21 +9318,97 @@ function withApiMetadata(api, payload) {
8668
9318
  hit: api.cached,
8669
9319
  stale_while_revalidate: api.stale,
8670
9320
  coalesced_live_request: api.coalesced,
8671
- expires_at: api.cacheExpiresAt
9321
+ expires_at: api.cacheExpiresAt,
9322
+ no_store: api.noStore
8672
9323
  }),
8673
9324
  fetched_at: api.fetchedAt,
8674
9325
  ...payload
8675
9326
  });
8676
9327
  }
9328
+ function proPage(api, field) {
9329
+ const raw = readObject(api.data);
9330
+ if (!raw || !(field === "activity_summary" ? readObject(raw[field]) : Array.isArray(raw[field]))) {
9331
+ throw new Error(`BBRadar Pro API returned a malformed page: expected ${field}.`);
9332
+ }
9333
+ const data = sanitizeProPayload(api.data);
9334
+ return { ...data, ...withApiMetadata(api, {}) };
9335
+ }
9336
+ function assertProFeature(client, endpoint, flag) {
9337
+ const state = client.capabilitySnapshot();
9338
+ if (state.status !== "negotiated")
9339
+ return;
9340
+ const capability = client.endpointCapability(endpoint);
9341
+ if (!capability)
9342
+ throw new Error(`The negotiated BBRadar API does not advertise ${endpoint}. Upgrade the API deployment to use this tool.`);
9343
+ if (flag && capability[flag] === false) {
9344
+ throw new Error(`${endpoint} is advertised but ${flag}=false on this API deployment.`);
9345
+ }
9346
+ }
9347
+ function assertActivityTabInputs(tab, args) {
9348
+ const allowed = new Set([...ACTIVITY_SHARED_PARAMS, ...ACTIVITY_TAB_PARAMS[tab]]);
9349
+ const misplaced = Object.entries(args)
9350
+ .filter(([key, value]) => !allowed.has(key) && value !== undefined && value !== false)
9351
+ .map(([key]) => key);
9352
+ if (misplaced.length > 0) {
9353
+ throw new Error(`${misplaced.join(", ")} ${misplaced.length === 1 ? "does" : "do"} not apply to tab=${tab}. Remove ${misplaced.length === 1 ? "it" : "them"} rather than querying a different ranking.`);
9354
+ }
9355
+ if (typeof args.period === "string" && !ACTIVITY_COUNT_TABS.has(tab) && !ACTIVITY_EVENT_PERIODS.includes(args.period)) {
9356
+ throw new Error(`tab=${tab} uses rolling 7d, 30d or 90d periods.`);
9357
+ }
9358
+ }
9359
+ /** One ranking page without the capabilities document the API repeats on every page. */
9360
+ function activityRankingPage(api, includeMap) {
9361
+ const page = proPage(api, "rows");
9362
+ const capabilities = readObject(page.capabilities);
9363
+ delete page.capabilities;
9364
+ // With no enabled default view the API answers sort=null and no rows; keep
9365
+ // only this tab's views so the caller can choose an explicit one.
9366
+ if (page.sort === null && readArray(page.rows).length === 0 && capabilities) {
9367
+ const views = readArray(readArray(capabilities.tabs).map(readObject).find((tab) => tab?.id === page.tab)?.views);
9368
+ page.available_views = views;
9369
+ }
9370
+ return omitMapPoints(page, "points", includeMap);
9371
+ }
9372
+ function omitMapPoints(payload, field, include) {
9373
+ if (include || !Array.isArray(payload[field]))
9374
+ return payload;
9375
+ const { [field]: points, ...rest } = payload;
9376
+ return { ...rest, map_points_omitted: points.length };
9377
+ }
9378
+ function assertActivitySnapshotSelector(client, endpoint, snapshot) {
9379
+ // Older deployments ignore unknown parameters silently; never let a binding
9380
+ // the caller relies on be dropped without saying so.
9381
+ if (snapshot === undefined || client.capabilitySnapshot().status !== "negotiated")
9382
+ return;
9383
+ if (client.endpointCapability(endpoint)?.snapshot_parameter !== "snapshot") {
9384
+ throw new Error(`The negotiated BBRadar API does not advertise Activity version binding for ${endpoint}. Omit snapshot or upgrade the API deployment.`);
9385
+ }
9386
+ }
9387
+ function assertActivityTierFilter(client, tiers) {
9388
+ // An API without the filter answers 422; say why locally and send nothing.
9389
+ if (!Array.isArray(tiers) || tiers.length === 0 || client.capabilitySnapshot().status !== "negotiated")
9390
+ return;
9391
+ if (!Array.isArray(client.endpointCapability("activity_rankings")?.opportunity_tiers)) {
9392
+ throw new Error("The negotiated BBRadar API does not advertise Activity opportunity tier filtering. Omit opportunity_tiers or upgrade the API deployment.");
9393
+ }
9394
+ }
9395
+ function assertHistoryCapture(client) {
9396
+ if (client.capabilitySnapshot().status === "negotiated" &&
9397
+ client.endpointCapability("history_changes")?.history_capture_enabled === false) {
9398
+ throw new Error("History endpoints are advertised but history_capture_enabled=false on this API deployment.");
9399
+ }
9400
+ }
8677
9401
  function apiSourceMetadata(api) {
8678
9402
  return stripUndefined({
8679
9403
  cache_hit: api.cached,
8680
9404
  cache_expires_at: api.cacheExpiresAt,
8681
- coalesced_live_request: api.coalesced
9405
+ coalesced_live_request: api.coalesced,
9406
+ no_store: api.noStore
8682
9407
  });
8683
9408
  }
8684
9409
  function errorPayload(error, decision, toolName, durationMs) {
8685
9410
  if (error instanceof BBRadarApiError) {
9411
+ const recovery = error.status === 410 ? recoveryForGone(error, toolName) : undefined;
8686
9412
  return stripUndefined({
8687
9413
  request_id: error.requestId,
8688
9414
  upstream_request_id: error.upstreamRequestId,
@@ -8692,6 +9418,7 @@ function errorPayload(error, decision, toolName, durationMs) {
8692
9418
  detail: sanitizeJson(error.detail),
8693
9419
  errors: sanitizeJson(error.errors)
8694
9420
  }),
9421
+ recovery,
8695
9422
  mcp_rate_limit: toRateLimitPayload(decision),
8696
9423
  mcp_timing: toolTimingPayload(toolName, durationMs)
8697
9424
  });
@@ -8705,6 +9432,25 @@ function errorPayload(error, decision, toolName, durationMs) {
8705
9432
  mcp_timing: toolTimingPayload(toolName, durationMs)
8706
9433
  };
8707
9434
  }
9435
+ function recoveryForGone(error, toolName) {
9436
+ if (toolName === "get_activity_change_detail") {
9437
+ return { action: "refresh_activity_source", message: "Rerun the originating Activity changes or status query without its cursor, keeping its filters. Use the new meta.snapshot_id and event ID for this detail request; do not use snapshot=start." };
9438
+ }
9439
+ if (toolName.startsWith("get_activity_") || toolName === "get_program_payouts") {
9440
+ return { action: "restart_activity_query", message: "Restart this Activity query without its expired cursor or snapshot binding, keeping the same explicit period, basis, precision, sort, direction and filters; call get_activity_snapshot for the current version if binding is needed. Do not combine old pages or pass snapshot=start or after_id." };
9441
+ }
9442
+ const detail = typeof error.detail === "string" ? error.detail.toLowerCase() : error.message.toLowerCase();
9443
+ if (detail.includes("retained analytics") || detail.includes("precede")) {
9444
+ return { action: "choose_supported_interval", message: "Choose dates inside the retained analytics window." };
9445
+ }
9446
+ if (detail.includes("snapshot")) {
9447
+ return { action: "start_new_snapshot", message: "Restart on page 1 with snapshot=start; do not combine pages from the old snapshot." };
9448
+ }
9449
+ if (toolName.startsWith("get_history") || toolName === "get_program_history" || toolName.startsWith("get_analytics")) {
9450
+ return { action: "restart_query", message: "Restart this History or analytics query without its expired opaque cursor, keeping the intended filters." };
9451
+ }
9452
+ return { action: "reconcile_inventory", message: "The retained numeric feed has a gap; reconcile current inventory before seeding a new after_id." };
9453
+ }
8708
9454
  function recordToolMetric(toolName, durationMs, status) {
8709
9455
  const existing = TOOL_METRICS.get(toolName) ??
8710
9456
  {
@@ -8823,15 +9569,19 @@ function compactTarget(target) {
8823
9569
  identifier: targetIdentifier(target),
8824
9570
  target_type: stringField(target, "target_type"),
8825
9571
  in_scope: readBoolean(target.in_scope),
8826
- eligible_for_bounty: readBoolean(target.eligible_for_bounty),
9572
+ eligible_for_bounty: target.eligible_for_bounty,
8827
9573
  wildcard: readBoolean(target.wildcard),
8828
9574
  path_kind: stringField(target, "path_kind"),
8829
9575
  path_prefix: stringField(target, "path_prefix"),
8830
9576
  match_quality: stringField(target, "match_quality"),
8831
9577
  match_reason: stringField(target, "match_reason"),
8832
- target_opportunity_score: readNumber(target.target_opportunity_score),
9578
+ target_opportunity_score: target.target_opportunity_score,
8833
9579
  target_opportunity_label: stringField(target, "target_opportunity_label"),
9580
+ score_version: target.score_version,
9581
+ evidence_coverage: target.evidence_coverage,
9582
+ target_score_breakdown: target.target_score_breakdown,
8834
9583
  competition_risk: stringField(target, "competition_risk"),
9584
+ competition_risk_reason: stringField(target, "competition_risk_reason"),
8835
9585
  why_this_target: stringField(target, "why_this_target")
8836
9586
  });
8837
9587
  }