@bbradar/mcp 0.2.6 → 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,4 +1,5 @@
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";
@@ -6,7 +7,7 @@ import { NumericCursorStore } from "./cursorStore.js";
6
7
  import { stableStringify, stripUndefined } from "./json.js";
7
8
  import { FixedWindowRateLimiter } from "./rateLimit.js";
8
9
  import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeProPayload, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
9
- const SERVER_VERSION = "0.2.6";
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;
@@ -71,7 +72,8 @@ const CORE_TOOL_NAMES = new Set([
71
72
  "get_program_activity",
72
73
  "get_analytics_platform_series",
73
74
  "get_analytics_program_activity",
74
- "get_analytics_program_detail"
75
+ "get_analytics_program_detail",
76
+ "get_activity_capabilities", "get_activity_snapshot", "get_activity_rankings", "get_program_payouts", "get_activity_change_detail"
75
77
  ]);
76
78
  const TOOL_ENDPOINT_REQUIREMENTS = {
77
79
  search_targets: "target_search",
@@ -187,7 +189,7 @@ const changeListModeSchema = z.enum(["compact", "full"]);
187
189
  const repositoryChangeListModeSchema = z.enum(["compact", "full"]);
188
190
  const programListModeSchema = z.enum(["compact", "full"]);
189
191
  const rewardThresholdModeSchema = z.enum(["max_at_least", "min_at_least"]);
190
- 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"]);
191
193
  const targetMatchModeSchema = z
192
194
  .enum(["smart", "contains", "exact", "suffix", "wildcard"])
193
195
  .describe("How to match target_query. smart is forgiving for URLs, wildcards, target names, and partial words.");
@@ -283,6 +285,59 @@ const programSortSchema = z
283
285
  "best_hunt_value"
284
286
  ])
285
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.");
286
341
  const programIdSchema = z
287
342
  .string()
288
343
  .trim()
@@ -305,51 +360,24 @@ const programIdentifierSchema = z
305
360
  function jsonRecordSchema() {
306
361
  return z.record(z.unknown());
307
362
  }
308
- function rateLimitOutputSchema() {
309
- return z.object({
310
- remaining: z.number().int().min(0),
311
- reset_at: z.string()
312
- });
313
- }
314
- function toolTimingOutputSchema() {
315
- return z.object({
316
- tool_name: z.string(),
317
- duration_ms: z.number().int().min(0)
318
- });
319
- }
320
- function cacheOutputSchema() {
321
- return z.object({
322
- hit: z.boolean(),
323
- stale_while_revalidate: z.boolean().optional(),
324
- coalesced_live_request: z.boolean().optional(),
325
- no_store: z.boolean().optional(),
326
- expires_at: z.string().optional()
327
- });
328
- }
329
- function errorOutputSchema() {
330
- return z.object({
331
- message: z.string(),
332
- status: z.number().int().optional(),
333
- detail: z.unknown().optional(),
334
- errors: z.unknown().optional()
335
- });
336
- }
337
363
  function programOutputSchema() {
338
364
  return jsonRecordSchema();
339
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.
340
368
  function apiEnvelopeOutputShape() {
341
369
  return {
342
370
  request_id: z.string(),
343
371
  upstream_request_id: z.string().optional(),
344
372
  fetched_at: z.string().optional(),
345
- cache: cacheOutputSchema().optional(),
373
+ cache: jsonRecordSchema().optional(),
346
374
  source_requests: z.array(jsonRecordSchema()).optional(),
347
- mcp_rate_limit: rateLimitOutputSchema().optional(),
348
- mcp_timing: toolTimingOutputSchema().optional(),
375
+ mcp_rate_limit: jsonRecordSchema().optional(),
376
+ mcp_timing: jsonRecordSchema().optional(),
349
377
  warnings: z.array(z.string()).optional(),
350
378
  program_id_resolution: jsonRecordSchema().optional(),
351
379
  recovery: jsonRecordSchema().optional(),
352
- error: errorOutputSchema().optional()
380
+ error: jsonRecordSchema().optional()
353
381
  };
354
382
  }
355
383
  function programListOutputShape() {
@@ -367,7 +395,8 @@ function findProgramsOutputShape() {
367
395
  warnings: z.array(z.string()).optional(),
368
396
  errors: z.array(jsonRecordSchema()).optional(),
369
397
  upstream_budget: jsonRecordSchema().optional(),
370
- ranking_scope: jsonRecordSchema().optional()
398
+ ranking_scope: jsonRecordSchema().optional(),
399
+ scan: jsonRecordSchema().optional()
371
400
  };
372
401
  }
373
402
  function decisionOutputShape() {
@@ -432,6 +461,8 @@ export function createBbradarServer(client, config) {
432
461
  "Use resolve_program or run_program_name_action for a named program.",
433
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.",
434
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.",
435
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.",
436
467
  "Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
437
468
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
@@ -442,6 +473,8 @@ export function createBbradarServer(client, config) {
442
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.",
443
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.",
444
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.",
445
478
  "Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
446
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.",
447
480
  "Recently updated programs across platforms: find_recently_updated_programs. Do not infer a platform such as Immunefi unless the user explicitly names it.",
@@ -478,6 +511,135 @@ export function createBbradarServer(client, config) {
478
511
  const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
479
512
  const historyOutput = { ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() };
480
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
+ }));
481
643
  registerTool("get_history_changes", {
482
644
  title: "Get Program History Changes",
483
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.",
@@ -554,8 +716,8 @@ export function createBbradarServer(client, config) {
554
716
  }));
555
717
  registerTool("search_programs", {
556
718
  title: "Search BBRadar Programs",
557
- description: `Search or list active and confirmed paused platform programs plus active Self-Hosted programs. page_size max ${maxProgramsPageSize}. Stable snapshots are opt-in because creating one can be slower than live indexed pagination.`,
558
- 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({
559
721
  search: searchTextSchema.optional().describe("Server-side program name, handle, platform, policy-host, or target search."),
560
722
  program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
561
723
  platforms: platformListSchema(),
@@ -565,7 +727,7 @@ export function createBbradarServer(client, config) {
565
727
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
566
728
  page: pageSchema,
567
729
  page_size: programsPageSizeSchema
568
- },
730
+ }).strict(),
569
731
  outputSchema: programListOutputShape(),
570
732
  annotations: readOnlyAnnotations
571
733
  }, (args) => runTool("search_programs", config, rateLimiter, async () => {
@@ -924,8 +1086,9 @@ export function createBbradarServer(client, config) {
924
1086
  }));
925
1087
  registerTool("find_hunt_targets", {
926
1088
  title: "Find Best Hunt Targets",
927
- description: "Preferred one-call path for requests combining good rewards, broad Web2/Web3 scope, target quality, competition, and recently added targets. Returns a final ranked shortlist with exact bounded target samples and recent additions; do not fan out into repeated find_programs/get_program_targets calls. Self-Hosted is supported through current inventory and automatically skips its unavailable change feed. Use find_repository_hunt_targets instead only when Git or repository activity is explicitly requested.",
928
- 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,
929
1092
  platforms: platformListSchema(),
930
1093
  exclude_platforms: stringListSchema("Platforms to exclude from the final shortlist."),
931
1094
  tags: tagListSchema(),
@@ -952,9 +1115,10 @@ export function createBbradarServer(client, config) {
952
1115
  max_program_pages: z.number().int().min(1).max(MAX_FIND_PROGRAM_PAGES).default(DEFAULT_HUNT_PROGRAM_PAGES),
953
1116
  max_results: z.number().int().min(1).max(MAX_HUNT_RESULTS).default(10),
954
1117
  targets_per_result: z.number().int().min(1).max(MAX_HUNT_TARGETS_PER_RESULT).default(5),
1118
+ free_submission_only: freeSubmissionOnlySchema,
955
1119
  ranking_mode: z.enum(["balanced", "highest_reward", "widest_scope", "freshest_targets", "lowest_competition"]).default("balanced"),
956
1120
  upstream_request_budget: z.number().int().min(2).max(MAX_FIND_UPSTREAM_REQUEST_BUDGET).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
957
- },
1121
+ }).strict(),
958
1122
  outputSchema: {
959
1123
  ...apiEnvelopeOutputShape(),
960
1124
  matches: z.array(jsonRecordSchema()).optional(),
@@ -962,11 +1126,15 @@ export function createBbradarServer(client, config) {
962
1126
  errors: z.array(jsonRecordSchema()).optional(),
963
1127
  upstream_budget: jsonRecordSchema().optional(),
964
1128
  ranking_scope: jsonRecordSchema().optional(),
1129
+ scan: jsonRecordSchema().optional(),
965
1130
  result_guidance: jsonRecordSchema().optional(),
966
1131
  meta: jsonRecordSchema().optional()
967
1132
  },
968
1133
  annotations: readOnlyAnnotations
969
- }, (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
+ }));
970
1138
  registerTool("find_repository_hunt_targets", {
971
1139
  title: "Find Repository-Aware Hunt Targets",
972
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.",
@@ -1184,16 +1352,19 @@ export function createBbradarServer(client, config) {
1184
1352
  }, (args) => runTool("check_watchlist_new_targets", config, rateLimiter, async () => checkWatchlistNewTargets(client, config, args)));
1185
1353
  registerTool("get_opportunities", {
1186
1354
  title: "Get BBRadar Opportunities",
1187
- description: `List programs in one opportunity tier. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
1188
- 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({
1189
1357
  level: opportunityLevelSchema.default("elite"),
1358
+ program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
1359
+ search: searchTextSchema.optional(),
1190
1360
  platforms: platformListSchema(),
1191
1361
  tags: tagListSchema(),
1192
1362
  updated_since: isoDateTimeSchema.optional(),
1363
+ snapshot: stableSnapshotSchema.optional(),
1193
1364
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
1194
1365
  page: pageSchema,
1195
1366
  page_size: programsPageSizeSchema
1196
- },
1367
+ }).strict(),
1197
1368
  outputSchema: {
1198
1369
  ...programListOutputShape(),
1199
1370
  level: opportunityLevelSchema.optional()
@@ -1201,11 +1372,15 @@ export function createBbradarServer(client, config) {
1201
1372
  annotations: readOnlyAnnotations
1202
1373
  }, (args) => runTool("get_opportunities", config, rateLimiter, async () => {
1203
1374
  const { level, vdp_mode, ...filters } = args;
1204
- 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 }));
1205
1380
  const data = readObject(api.data);
1206
1381
  const programs = readArray(data?.programs)
1207
1382
  .map((program) => toProgramCandidate(program, config.webBaseUrl))
1208
- .filter((candidate) => candidateMatchesVdpMode(candidate, vdp_mode))
1383
+ .filter((candidate) => combined || candidateMatchesVdpMode(candidate, vdp_mode))
1209
1384
  .map((candidate) => candidate.program);
1210
1385
  return withApiMetadata(api, {
1211
1386
  level,
@@ -1215,8 +1390,12 @@ export function createBbradarServer(client, config) {
1215
1390
  }));
1216
1391
  registerTool("find_programs", {
1217
1392
  title: "Find BBRadar Programs",
1218
- 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.",
1219
- 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(),
1220
1399
  platforms: platformListSchema(),
1221
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."),
1222
1401
  tags: tagListSchema(),
@@ -1226,6 +1405,9 @@ export function createBbradarServer(client, config) {
1226
1405
  web3: z.boolean().default(false).describe("Require Web3 signals."),
1227
1406
  program_kind: z.enum(["bounty", "contest", "challenge"]).optional().describe("Source-classified program kind; sent to the Pro program list."),
1228
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."),
1229
1411
  opportunity_levels: z.array(opportunityLevelSchema).max(4).default([]),
1230
1412
  updated_since: isoDateTimeSchema.optional(),
1231
1413
  min_bounty_min: z.number().int().min(0).optional(),
@@ -1249,7 +1431,7 @@ export function createBbradarServer(client, config) {
1249
1431
  min_added_7d: z.number().int().min(0).optional(),
1250
1432
  max_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1251
1433
  max_results: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(10),
1252
- sort_by: programSortSchema,
1434
+ sort_by: z.union([programSortSchema.removeDefault(), activityCatalogSortSchema]).optional(),
1253
1435
  include_target_samples: z.boolean().default(false),
1254
1436
  target_sample_programs: z.number().int().min(0).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1255
1437
  target_sample_size: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(5),
@@ -1257,13 +1439,40 @@ export function createBbradarServer(client, config) {
1257
1439
  only_bounty_eligible_targets: z.boolean().default(true),
1258
1440
  output_mode: programListModeSchema.default("compact"),
1259
1441
  upstream_request_budget: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
1260
- },
1442
+ }).strict(),
1261
1443
  outputSchema: findProgramsOutputShape(),
1262
1444
  annotations: readOnlyAnnotations
1263
- }, (args) => runTool("find_programs", config, rateLimiter, async () => findPrograms(client, config, {
1264
- ...args,
1265
- target_samples_only_wildcards: args.min_wildcards > 0
1266
- })));
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
+ }));
1267
1476
  registerTool("find_wildcard_programs", {
1268
1477
  title: "Find Wildcard Programs",
1269
1478
  description: "Wildcard program shortlist with platform, minimum reward, freshness, and report-count filters.",
@@ -2177,6 +2386,9 @@ function bbradarGuide(topic, profile, client) {
2177
2386
  arguments: { include_targets: false, max_cursor_pages: 2 },
2178
2387
  then: "For incremental polling, seed once with after_id and later set resume_cursor=true with the same filters."
2179
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" } },
2180
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." },
2181
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." },
2182
2394
  { user_goal: "exact recorded activity for one program", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
@@ -2322,6 +2534,9 @@ function coreBbradarGuide(topic, client) {
2322
2534
  first_tool: "get_repository_changes",
2323
2535
  arguments: { include_targets: false, max_cursor_pages: 2 }
2324
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" } },
2325
2540
  { user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch change detail only when needed." },
2326
2541
  { user_goal: "most active programs", first_tool: "get_analytics_program_activity", arguments: {} },
2327
2542
  { user_goal: "exact recorded activity", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
@@ -6040,6 +6255,9 @@ function compactProgram(program) {
6040
6255
  competition_risk: stringField(program, "competition_risk"),
6041
6256
  competition_risk_reason: stringField(program, "competition_risk_reason"),
6042
6257
  submission_cost: program.submission_cost,
6258
+ payout_summary: program.payout_summary,
6259
+ activity: program.activity,
6260
+ activity_summary: program.activity_summary,
6043
6261
  reward_range: program.reward_range,
6044
6262
  reward_range_label: stringField(program, "reward_range_label"),
6045
6263
  public_report_count: program.public_report_count,
@@ -6598,7 +6816,10 @@ function clampLimit(name, value, max, warnings) {
6598
6816
  warnings.push(`${name} was clamped from ${value} to ${max}.`);
6599
6817
  return max;
6600
6818
  }
6601
- 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 = {}) {
6602
6823
  const warnings = [];
6603
6824
  const errors = [];
6604
6825
  const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
@@ -6641,6 +6862,7 @@ async function findHuntTargets(client, config, input) {
6641
6862
  min_total_targets: input.min_total_targets,
6642
6863
  min_added_24h: undefined,
6643
6864
  min_added_7d: input.min_added_7d,
6865
+ free_submission_only: input.free_submission_only,
6644
6866
  max_pages: input.max_program_pages,
6645
6867
  max_results: candidatePoolSize,
6646
6868
  sort_by: huntProgramSort(input.ranking_mode),
@@ -6662,8 +6884,17 @@ async function findHuntTargets(client, config, input) {
6662
6884
  else if (recentChangeQueries.length < desiredRecentChangeQueries.length) {
6663
6885
  warnings.push(`Recent target-change queries were reduced from ${desiredRecentChangeQueries.length} to ${recentChangeQueries.length} to preserve the upstream request budget.`);
6664
6886
  }
6887
+ const serverFiltered = hasActivityCatalogInput(activity);
6665
6888
  const [programPayload, recentResult] = await Promise.all([
6666
- 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),
6667
6898
  recentChangesEnabled
6668
6899
  ? fetchHuntRecentChanges(client, config, input, recentChangeQueries)
6669
6900
  : Promise.resolve({ changes: [], sourceRequests: [], requestsUsed: 0, errors: [] })
@@ -6733,6 +6964,8 @@ async function findHuntTargets(client, config, input) {
6733
6964
  min_target_score: input.min_target_score,
6734
6965
  target_opportunity_label: input.target_opportunity_label,
6735
6966
  competition_risk: input.competition_risk,
6967
+ free_submission_only: input.free_submission_only || undefined,
6968
+ activity: serverFiltered ? stripUndefined(activity) : undefined,
6736
6969
  recent_target_days: input.include_recent_changes ? input.recent_target_days : undefined,
6737
6970
  ranking_mode: input.ranking_mode
6738
6971
  }),
@@ -6743,10 +6976,12 @@ async function findHuntTargets(client, config, input) {
6743
6976
  },
6744
6977
  ranking_scope: stripUndefined({
6745
6978
  ...(readObject(programPayload.ranking_scope) ?? {}),
6979
+ local_ranking: serverFiltered ? input.ranking_mode : undefined,
6746
6980
  single_program_catalog_pass: true,
6747
6981
  recent_change_window_days: input.include_recent_changes ? input.recent_target_days : undefined,
6748
6982
  recent_change_page_size: input.include_recent_changes ? input.recent_changes_page_size : undefined
6749
6983
  }),
6984
+ scan: readObject(programPayload.scan),
6750
6985
  result_guidance: {
6751
6986
  final_shortlist: true,
6752
6987
  follow_up_calls_required: false,
@@ -7459,6 +7694,262 @@ function uniqueProgramsFromRepositoryMatches(matches) {
7459
7694
  }
7460
7695
  return [...programs.values()];
7461
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
+ }
7462
7953
  async function findPrograms(client, config, input) {
7463
7954
  const normalized = normalizeFindProgramsInput(input);
7464
7955
  const warnings = normalized.warnings;
@@ -7475,6 +7966,7 @@ async function findPrograms(client, config, input) {
7475
7966
  ...(input.min_wildcards > 0 ? ["wildcard"] : [])
7476
7967
  ]);
7477
7968
  const collected = await collectPrograms(client, {
7969
+ search: input.search,
7478
7970
  platforms: input.platforms,
7479
7971
  tags: upstreamTags,
7480
7972
  program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined),
@@ -7534,6 +8026,8 @@ async function findPrograms(client, config, input) {
7534
8026
  min_total_targets: input.min_total_targets,
7535
8027
  min_added_24h: input.min_added_24h,
7536
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,
7537
8031
  sort_by: input.sort_by,
7538
8032
  output_mode: outputMode
7539
8033
  }),
@@ -8046,7 +8540,7 @@ function programCandidateMatches(candidate, filters) {
8046
8540
  return false;
8047
8541
  }
8048
8542
  const programStatus = normalizeTag(stringField(candidate.program, "program_status") ?? "");
8049
- if (readBoolean(candidate.program.is_active) === false || programStatus === "paused") {
8543
+ if (!filters.skip_status_check && (readBoolean(candidate.program.is_active) === false || programStatus === "paused")) {
8050
8544
  return false;
8051
8545
  }
8052
8546
  if (filters.platforms.length > 0 && !filters.platforms.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
@@ -8139,6 +8633,15 @@ function programCandidateMatches(candidate, filters) {
8139
8633
  if (filters.min_added_7d !== undefined && (candidate.added7d ?? 0) < filters.min_added_7d) {
8140
8634
  return false;
8141
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
+ }
8142
8645
  return true;
8143
8646
  }
8144
8647
  function compareProgramCandidates(left, right, sortBy) {
@@ -8179,6 +8682,8 @@ function compareProgramCandidates(left, right, sortBy) {
8179
8682
  right.lastUpdatedTime - left.lastUpdatedTime);
8180
8683
  }
8181
8684
  function candidateMatchesVdpMode(candidate, vdpMode) {
8685
+ if (vdpMode === "only")
8686
+ return stringField(candidate.program, "program_type") === "vdp";
8182
8687
  if (vdpMode === "exclude") {
8183
8688
  return !candidateLooksLikeVdp(candidate);
8184
8689
  }
@@ -8839,6 +9344,54 @@ function assertProFeature(client, endpoint, flag) {
8839
9344
  throw new Error(`${endpoint} is advertised but ${flag}=false on this API deployment.`);
8840
9345
  }
8841
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
+ }
8842
9395
  function assertHistoryCapture(client) {
8843
9396
  if (client.capabilitySnapshot().status === "negotiated" &&
8844
9397
  client.endpointCapability("history_changes")?.history_capture_enabled === false) {
@@ -8880,6 +9433,12 @@ function errorPayload(error, decision, toolName, durationMs) {
8880
9433
  };
8881
9434
  }
8882
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
+ }
8883
9442
  const detail = typeof error.detail === "string" ? error.detail.toLowerCase() : error.message.toLowerCase();
8884
9443
  if (detail.includes("retained analytics") || detail.includes("precede")) {
8885
9444
  return { action: "choose_supported_interval", message: "Choose dates inside the retained analytics window." };