@bbradar/mcp 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { requiresActivityQuery, validateActivityCatalogInput } 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.1";
10
11
  const MAX_PROGRAMS_PER_SEARCH = 100;
11
12
  const MAX_TARGET_EXPORT = 500;
12
13
  const MAX_RECENT_CHANGES = 100;
@@ -45,7 +46,7 @@ const MAX_IN_MEMORY_FEED_CURSORS = 500;
45
46
  const MAX_RESOLVE_SEARCH_QUERIES = 4;
46
47
  const FAST_RESOLVE_SEARCH_QUERIES = 2;
47
48
  const MAX_DIRECT_HANDLE_RESOLUTION_REQUESTS = 96;
48
- const SDK_VERSION = "1.29.0";
49
+ const SDK_VERSION = "1.31.0";
49
50
  const CORE_TOOL_NAMES = new Set([
50
51
  "search_programs",
51
52
  "get_program",
@@ -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.");
@@ -255,11 +257,11 @@ const stableSnapshotSchema = z
255
257
  .string()
256
258
  .trim()
257
259
  .min(1)
258
- .max(1_024)
260
+ .max(32, "Catalog snapshot tokens have at most 32 characters. Use 'start', then meta.snapshot; Activity snapshot IDs belong only to Activity and payout tools.")
259
261
  .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
260
262
  message: "Snapshot tokens cannot contain control characters."
261
263
  })
262
- .describe("Optional stable-page token. Send 'start' on page 1, then reuse meta.snapshot with identical filters. Snapshot creation is consistency-first and can be slower; omit it for the fastest live result.");
264
+ .describe("Optional catalog/feed snapshot token, at most 32 characters. Send 'start' on page 1, then reuse meta.snapshot with identical filters. Do not pass get_activity_snapshot's snapshot_id here. Snapshot creation can be slower; omit it for the fastest live result.");
263
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.");
264
266
  const analyticsDateSchema = () => z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use an inclusive UTC YYYY-MM-DD date.");
265
267
  const proLimitSchema = z.number().int().min(1).max(100);
@@ -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().describe("payout_total requires all_time; payout_increase is a tracked counter increase in 24h/7d/30d/90d; payout_native_90d requires 90d and is provider-reported. Omit to select the period's default metric."),
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().describe("Update-event category; requires its own updates_period. scope_period with scope_added_min already filters scope additions and does not need this field."),
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 own period. payout_total requires all_time; payout_increase uses 24h/7d/30d/90d; payout_native_90d requires 90d. change_category requires updates_period even when scope_period is present; scope_period with scope_added_min needs no change_category. Use enabled sort_by/sort_direction combinations; default is updated descending. Tracked no_increase/lower require 30d. Catalog snapshot is 'start' or meta.snapshot, never an Activity snapshot_id. An empty result does not authorize relaxing the user's thresholds or restricting platforms to a recent-feed sample.";
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() {
@@ -415,7 +444,13 @@ function createProfiledToolRegistrar(server, profile, client) {
415
444
  const extra = args[args.length - 1];
416
445
  return client.withRequestContext(extra.signal, () => callback(...args), readObject(args[0])?.snapshot === "start");
417
446
  });
418
- const registeredTool = server.registerTool(name, toolConfig, scopedCallback);
447
+ // Raw SDK shapes otherwise strip unknown arguments. Every advertised
448
+ // filter must be applied or rejected, including on the older shortcuts.
449
+ const inputSchema = toolConfig.inputSchema;
450
+ const strictConfig = inputSchema && !(inputSchema instanceof z.ZodType)
451
+ ? { ...toolConfig, inputSchema: z.object(inputSchema).strict() }
452
+ : toolConfig;
453
+ const registeredTool = server.registerTool(name, strictConfig, scopedCallback);
419
454
  const requiredEndpoint = TOOL_ENDPOINT_REQUIREMENTS[name];
420
455
  if ((profile === "core" && !CORE_TOOL_NAMES.has(name)) ||
421
456
  (requiredEndpoint !== undefined && !client.supportsEndpoint(requiredEndpoint))) {
@@ -432,6 +467,9 @@ export function createBbradarServer(client, config) {
432
467
  "Use resolve_program or run_program_name_action for a named program.",
433
468
  "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
469
  "Use find_programs for lower-level program-only discovery and find_programs_by_target for reverse target lookup.",
470
+ "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.",
471
+ "Activity data is published in versions. get_activity_snapshot's snapshot_id binds only Activity capabilities, Activity rankings and program payouts. Catalog/feed tools use snapshot=start then meta.snapshot instead; do not send an Activity snapshot_id to find_programs. Never mix rows from different versions.",
472
+ "Preserve the user's thresholds, periods and platform scope when discovery times out or returns no matches. A sampled recent-change feed does not enumerate all supported platforms. Inspect partial_success, errors, ranking_scope and scan before claiming complete coverage.",
435
473
  "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
474
  "Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
437
475
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
@@ -442,6 +480,9 @@ export function createBbradarServer(client, config) {
442
480
  "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
481
  "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
482
  "Use advanced tools only after the user's goal is clear or the guide points to them.",
483
+ "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.",
484
+ "Activity data is published in versions. get_activity_snapshot's snapshot_id binds only Activity capabilities, Activity rankings and program payouts. Catalog/feed tools use snapshot=start then meta.snapshot instead; do not send an Activity snapshot_id to find_programs. Never mix rows from different versions.",
485
+ "Preserve the user's thresholds, periods and platform scope when discovery times out or returns no matches. A sampled recent-change feed does not enumerate all supported platforms. Inspect partial_success, errors, ranking_scope and scan before claiming complete coverage.",
445
486
  "Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
446
487
  "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
488
  "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 +519,135 @@ export function createBbradarServer(client, config) {
478
519
  const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
479
520
  const historyOutput = { ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() };
480
521
  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() };
522
+ // Published Activity versions are 64 lowercase hex characters. "start" and
523
+ // catalog snapshot tokens are different contracts and are rejected here.
524
+ const activitySnapshotSchema = z.string().regex(/^[0-9a-f]{64}$/, "Use a snapshot_id returned by Activity.")
525
+ .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.");
526
+ const includeMapSchema = z.boolean().default(false)
527
+ .describe("Return Value map points (up to thousands). Off by default: map_points_omitted reports the count.");
528
+ const activityParams = {
529
+ tab: z.enum(ACTIVITY_TABS),
530
+ period: z.enum(["24h", "7d", "30d", "90d", "all_time"]).optional(),
531
+ basis: z.enum(["reported", "tracked"]).optional(),
532
+ precision: z.enum(["exact", "proven_bounds"]).optional(),
533
+ sort: z.string().min(1).max(64).optional(),
534
+ direction: z.enum(["asc", "desc"]).optional(),
535
+ platforms: platformListSchema(), tags: tagListSchema(),
536
+ program_kind: z.enum(["bounty", "contest", "audit", "challenge"]).optional(),
537
+ program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
538
+ search: z.string().min(1).max(200).optional(),
539
+ current_status: z.enum(["active", "paused", "suspended", "disabled", "closed", "unavailable", "unknown"]).optional(),
540
+ labels: z.array(z.string().min(1).max(64)).max(10).optional().describe("Only labels listed by get_activity_capabilities; others return 422."),
541
+ 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."),
542
+ launched_within: z.enum(["7d", "30d", "90d"]).optional(),
543
+ category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
544
+ change_type: z.enum(["first_seen", "paused", "resumed", "left_listing", "returned"]).optional(),
545
+ report_change_min: z.number().finite().optional(), report_change_max: z.number().finite().optional(),
546
+ include_map: includeMapSchema,
547
+ cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(25),
548
+ snapshot: activitySnapshotSchema.optional()
549
+ };
550
+ const activityOutput = {
551
+ ...apiEnvelopeOutputShape(), rows: z.array(jsonRecordSchema()).optional(), points: z.array(jsonRecordSchema()).optional(),
552
+ tab: z.string().optional(), period: z.string().nullable().optional(),
553
+ basis: z.string().nullable().optional(), precision: z.string().nullable().optional(),
554
+ sort: z.string().nullable().optional(), direction: z.string().optional(), meta: jsonRecordSchema().optional(),
555
+ cohort_median: jsonRecordSchema().nullable().optional(), available_views: z.array(jsonRecordSchema()).optional(),
556
+ map_points_omitted: z.number().int().optional()
557
+ };
558
+ registerTool("get_activity_capabilities", {
559
+ title: "Get Available Activity Views",
560
+ 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.",
561
+ inputSchema: z.object({ snapshot: activitySnapshotSchema.optional() }).strict(),
562
+ 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() },
563
+ annotations: readOnlyAnnotations
564
+ }, (args) => runTool("get_activity_capabilities", config, rateLimiter, async () => {
565
+ assertProFeature(client, "activity_capabilities", "enabled");
566
+ assertActivitySnapshotSelector(client, "activity_capabilities", args.snapshot);
567
+ return proPage(await client.getActivityCapabilities(toQuery({ snapshot: args.snapshot })), "tabs");
568
+ }));
569
+ registerTool("get_activity_snapshot", {
570
+ title: "Get Current Activity Version",
571
+ 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. Pass snapshot_id as snapshot only to get_activity_capabilities, get_activity_rankings and get_program_payouts. Catalog/feed tools such as find_programs use snapshot=start then meta.snapshot instead. as_of is when BBRadar froze its calculation, not a provider payment or update time.",
572
+ inputSchema: z.object({}).strict(),
573
+ 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() },
574
+ annotations: readOnlyAnnotations
575
+ }, () => runTool("get_activity_snapshot", config, rateLimiter, async () => {
576
+ assertProFeature(client, "activity_snapshot", "enabled");
577
+ const api = await client.getActivitySnapshot();
578
+ const data = readObject(api.data);
579
+ if (!data || typeof data.snapshot_id !== "string" || !/^[0-9a-f]{64}$/.test(data.snapshot_id)) {
580
+ throw new Error("Malformed Activity version response.");
581
+ }
582
+ return withApiMetadata(api, sanitizeProPayload(api.data));
583
+ }));
584
+ registerTool("get_program_payouts", {
585
+ title: "Get Program Payouts and Response Facts",
586
+ 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.",
587
+ inputSchema: z.object({ program_id: programIdSchema, snapshot: activitySnapshotSchema.optional(), include_map: includeMapSchema }).strict(),
588
+ // Mirrors the API response, including its version identity: a client that
589
+ // discovered this schema validates every structured result against it.
590
+ 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() },
591
+ annotations: readOnlyAnnotations
592
+ }, (args) => runTool("get_program_payouts", config, rateLimiter, async () => {
593
+ assertProFeature(client, "program_payouts", "enabled");
594
+ assertActivitySnapshotSelector(client, "program_payouts", args.snapshot);
595
+ const api = await client.getProgramPayouts(args.program_id, toQuery({ snapshot: args.snapshot }));
596
+ if (!readObject(api.data))
597
+ throw new Error("Malformed program payout response.");
598
+ return omitMapPoints(withApiMetadata(api, sanitizeProPayload(api.data)), "value_points", args.include_map);
599
+ }));
600
+ const readActivityRanking = async (tab, args) => {
601
+ assertProFeature(client, "activity_rankings", "enabled");
602
+ assertActivitySnapshotSelector(client, "activity_rankings", args.snapshot);
603
+ assertActivityTierFilter(client, args.opportunity_tiers);
604
+ const { include_map, ...query } = args;
605
+ return activityRankingPage(await client.getActivityRankings(toQuery({ ...query, tab })), include_map === true);
606
+ };
607
+ registerTool("get_activity_rankings", {
608
+ title: "Get Activity Ranking",
609
+ 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.",
610
+ inputSchema: z.object(activityParams).strict(),
611
+ outputSchema: activityOutput,
612
+ annotations: readOnlyAnnotations
613
+ }, (args) => runTool("get_activity_rankings", config, rateLimiter, async () => {
614
+ assertActivityTabInputs(args.tab, args);
615
+ return readActivityRanking(args.tab, args);
616
+ }));
617
+ // Single-view aliases for the full profile; each schema exposes only the
618
+ // inputs its view accepts and shares the ranking tool's request path.
619
+ const activityDescriptions = {
620
+ 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.",
621
+ 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.",
622
+ 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.",
623
+ rewards: "Rank comparable net reward-range changes over rolling 7, 30 or 90 days. Unknown change percentages remain null.",
624
+ updates: "Rank active UTC days and recorded change groups over rolling periods. Baselines and internal corrections are excluded.",
625
+ 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.",
626
+ 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.",
627
+ 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.",
628
+ 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."
629
+ };
630
+ for (const tab of ACTIVITY_TABS) {
631
+ const name = `get_activity_${tab === "all_changes" ? "changes" : tab}`;
632
+ const allowed = new Set([...ACTIVITY_SHARED_PARAMS, ...ACTIVITY_TAB_PARAMS[tab]]);
633
+ const shape = Object.fromEntries(Object.entries(activityParams).filter(([key]) => key !== "tab" && allowed.has(key)));
634
+ if (!ACTIVITY_COUNT_TABS.has(tab) && shape.period)
635
+ shape.period = z.enum(ACTIVITY_EVENT_PERIODS).optional();
636
+ registerTool(name, {
637
+ title: `Get Activity ${tab === "all_changes" ? "Changes" : tab[0].toUpperCase() + tab.slice(1)}`,
638
+ 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.`,
639
+ inputSchema: z.object(shape).strict(), outputSchema: activityOutput, annotations: readOnlyAnnotations
640
+ }, (args) => runTool(name, config, rateLimiter, () => readActivityRanking(tab, args)));
641
+ }
642
+ registerTool("get_activity_change_detail", {
643
+ title: "Get Activity Change Detail",
644
+ 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.",
645
+ 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(),
646
+ outputSchema: { ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
647
+ }, (args) => runTool("get_activity_change_detail", config, rateLimiter, async () => {
648
+ assertProFeature(client, "activity_event_detail", "enabled");
649
+ return proPage(await client.getActivityChangeDetail(args.event_id, toQuery({ snapshot: args.snapshot, cursor: args.cursor, limit: args.limit })), "items");
650
+ }));
481
651
  registerTool("get_history_changes", {
482
652
  title: "Get Program History Changes",
483
653
  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 +724,8 @@ export function createBbradarServer(client, config) {
554
724
  }));
555
725
  registerTool("search_programs", {
556
726
  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: {
727
+ 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.`,
728
+ inputSchema: z.object({
559
729
  search: searchTextSchema.optional().describe("Server-side program name, handle, platform, policy-host, or target search."),
560
730
  program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
561
731
  platforms: platformListSchema(),
@@ -565,7 +735,7 @@ export function createBbradarServer(client, config) {
565
735
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
566
736
  page: pageSchema,
567
737
  page_size: programsPageSizeSchema
568
- },
738
+ }).strict(),
569
739
  outputSchema: programListOutputShape(),
570
740
  annotations: readOnlyAnnotations
571
741
  }, (args) => runTool("search_programs", config, rateLimiter, async () => {
@@ -756,6 +926,7 @@ export function createBbradarServer(client, config) {
756
926
  program_id: programIdentifierSchema,
757
927
  search: searchTextSchema.optional(),
758
928
  target_type: filterValueSchema().optional(),
929
+ language_tags: languageTagListSchema().describe("Keep targets with any requested language tag. Applied while paging; repeat with the returned next_offset."),
759
930
  min_target_score: z.number().int().min(0).max(100).optional(),
760
931
  target_opportunity_label: targetOpportunityLabelSchema.optional(),
761
932
  competition_risk: competitionRiskSchema.optional(),
@@ -924,8 +1095,9 @@ export function createBbradarServer(client, config) {
924
1095
  }));
925
1096
  registerTool("find_hunt_targets", {
926
1097
  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: {
1098
+ 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.",
1099
+ inputSchema: z.object({
1100
+ ...activityCatalogInput,
929
1101
  platforms: platformListSchema(),
930
1102
  exclude_platforms: stringListSchema("Platforms to exclude from the final shortlist."),
931
1103
  tags: tagListSchema(),
@@ -952,9 +1124,10 @@ export function createBbradarServer(client, config) {
952
1124
  max_program_pages: z.number().int().min(1).max(MAX_FIND_PROGRAM_PAGES).default(DEFAULT_HUNT_PROGRAM_PAGES),
953
1125
  max_results: z.number().int().min(1).max(MAX_HUNT_RESULTS).default(10),
954
1126
  targets_per_result: z.number().int().min(1).max(MAX_HUNT_TARGETS_PER_RESULT).default(5),
1127
+ free_submission_only: freeSubmissionOnlySchema,
955
1128
  ranking_mode: z.enum(["balanced", "highest_reward", "widest_scope", "freshest_targets", "lowest_competition"]).default("balanced"),
956
1129
  upstream_request_budget: z.number().int().min(2).max(MAX_FIND_UPSTREAM_REQUEST_BUDGET).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
957
- },
1130
+ }).strict(),
958
1131
  outputSchema: {
959
1132
  ...apiEnvelopeOutputShape(),
960
1133
  matches: z.array(jsonRecordSchema()).optional(),
@@ -962,11 +1135,15 @@ export function createBbradarServer(client, config) {
962
1135
  errors: z.array(jsonRecordSchema()).optional(),
963
1136
  upstream_budget: jsonRecordSchema().optional(),
964
1137
  ranking_scope: jsonRecordSchema().optional(),
1138
+ scan: jsonRecordSchema().optional(),
965
1139
  result_guidance: jsonRecordSchema().optional(),
966
1140
  meta: jsonRecordSchema().optional()
967
1141
  },
968
1142
  annotations: readOnlyAnnotations
969
- }, (args) => runTool("find_hunt_targets", config, rateLimiter, async () => findHuntTargets(client, config, args)));
1143
+ }, (args) => runTool("find_hunt_targets", config, rateLimiter, async () => {
1144
+ const activity = Object.fromEntries(Object.keys(activityCatalogInput).map((key) => [key, args[key]]));
1145
+ return findHuntTargets(client, config, args, activity);
1146
+ }));
970
1147
  registerTool("find_repository_hunt_targets", {
971
1148
  title: "Find Repository-Aware Hunt Targets",
972
1149
  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.",
@@ -1027,7 +1204,7 @@ export function createBbradarServer(client, config) {
1027
1204
  include_ineligible: z.boolean().default(false).describe("Include ineligible."),
1028
1205
  recent_changes_page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
1029
1206
  max_results: z.number().int().min(1).max(MAX_RECENT_CHANGES).default(10),
1030
- include_full_target_list: z.boolean().default(true).describe("Fetch the newest matching program's complete target list. Set false for the fastest cross-program discovery."),
1207
+ include_full_target_list: z.boolean().default(true).describe("Fetch up to full_target_limit targets for the newest matching program. Check full_targets_has_more and continue with get_program_targets at full_targets_next_offset. Set false for the fastest cross-program discovery."),
1031
1208
  full_target_list_mode: targetListModeSchema.default("identifiers"),
1032
1209
  full_target_limit: z.number().int().min(1).max(MAX_TARGETS_PER_PROGRAM).default(MAX_TARGETS_PER_PROGRAM),
1033
1210
  full_list_include_out_of_scope: z.boolean().default(false),
@@ -1184,16 +1361,19 @@ export function createBbradarServer(client, config) {
1184
1361
  }, (args) => runTool("check_watchlist_new_targets", config, rateLimiter, async () => checkWatchlistNewTargets(client, config, args)));
1185
1362
  registerTool("get_opportunities", {
1186
1363
  title: "Get BBRadar Opportunities",
1187
- description: `List programs in one opportunity tier. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
1188
- inputSchema: {
1364
+ 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.`,
1365
+ inputSchema: z.object({
1189
1366
  level: opportunityLevelSchema.default("elite"),
1367
+ program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
1368
+ search: searchTextSchema.optional(),
1190
1369
  platforms: platformListSchema(),
1191
1370
  tags: tagListSchema(),
1192
1371
  updated_since: isoDateTimeSchema.optional(),
1372
+ snapshot: stableSnapshotSchema.optional(),
1193
1373
  vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
1194
1374
  page: pageSchema,
1195
1375
  page_size: programsPageSizeSchema
1196
- },
1376
+ }).strict(),
1197
1377
  outputSchema: {
1198
1378
  ...programListOutputShape(),
1199
1379
  level: opportunityLevelSchema.optional()
@@ -1201,11 +1381,15 @@ export function createBbradarServer(client, config) {
1201
1381
  annotations: readOnlyAnnotations
1202
1382
  }, (args) => runTool("get_opportunities", config, rateLimiter, async () => {
1203
1383
  const { level, vdp_mode, ...filters } = args;
1204
- const api = await client.getOpportunities(level, toQuery(filters));
1384
+ assertStableSnapshotSupported(client, "opportunities", filters.snapshot);
1385
+ const combined = args.program_kind !== undefined;
1386
+ if (combined)
1387
+ assertCatalogQuerySupported(client, "opportunities", args);
1388
+ const api = await client.getOpportunities(level, toQuery({ ...filters, vdp_mode: combined ? vdp_mode : undefined }));
1205
1389
  const data = readObject(api.data);
1206
1390
  const programs = readArray(data?.programs)
1207
1391
  .map((program) => toProgramCandidate(program, config.webBaseUrl))
1208
- .filter((candidate) => candidateMatchesVdpMode(candidate, vdp_mode))
1392
+ .filter((candidate) => combined || candidateMatchesVdpMode(candidate, vdp_mode))
1209
1393
  .map((candidate) => candidate.program);
1210
1394
  return withApiMetadata(api, {
1211
1395
  level,
@@ -1215,8 +1399,12 @@ export function createBbradarServer(client, config) {
1215
1399
  }));
1216
1400
  registerTool("find_programs", {
1217
1401
  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: {
1402
+ 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}`,
1403
+ inputSchema: z.object({
1404
+ ...activityCatalogInput,
1405
+ search: searchTextSchema.optional(),
1406
+ snapshot: stableSnapshotSchema.optional(),
1407
+ page: z.number().int().min(1).optional(),
1220
1408
  platforms: platformListSchema(),
1221
1409
  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
1410
  tags: tagListSchema(),
@@ -1226,6 +1414,9 @@ export function createBbradarServer(client, config) {
1226
1414
  web3: z.boolean().default(false).describe("Require Web3 signals."),
1227
1415
  program_kind: z.enum(["bounty", "contest", "challenge"]).optional().describe("Source-classified program kind; sent to the Pro program list."),
1228
1416
  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."),
1417
+ free_submission_only: freeSubmissionOnlySchema,
1418
+ competition_risks: z.array(competitionRiskSchema).max(4).default([]).describe("Program competition labels to keep, e.g. [\"Low\"]."),
1419
+ cursor: z.string().min(1).max(512).optional().describe("scan.next_cursor from the previous result; repeat the same filters."),
1229
1420
  opportunity_levels: z.array(opportunityLevelSchema).max(4).default([]),
1230
1421
  updated_since: isoDateTimeSchema.optional(),
1231
1422
  min_bounty_min: z.number().int().min(0).optional(),
@@ -1249,7 +1440,7 @@ export function createBbradarServer(client, config) {
1249
1440
  min_added_7d: z.number().int().min(0).optional(),
1250
1441
  max_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1251
1442
  max_results: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(10),
1252
- sort_by: programSortSchema,
1443
+ sort_by: z.union([programSortSchema.removeDefault(), activityCatalogSortSchema]).optional(),
1253
1444
  include_target_samples: z.boolean().default(false),
1254
1445
  target_sample_programs: z.number().int().min(0).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1255
1446
  target_sample_size: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(5),
@@ -1257,13 +1448,40 @@ export function createBbradarServer(client, config) {
1257
1448
  only_bounty_eligible_targets: z.boolean().default(true),
1258
1449
  output_mode: programListModeSchema.default("compact"),
1259
1450
  upstream_request_budget: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
1260
- },
1451
+ }).strict(),
1261
1452
  outputSchema: findProgramsOutputShape(),
1262
1453
  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
- })));
1454
+ }, (args) => runTool("find_programs", config, rateLimiter, async () => {
1455
+ const serverCatalog = hasActivityCatalogInput(args) || args.snapshot !== undefined || args.page !== undefined || args.cursor !== undefined
1456
+ || (args.search !== undefined && supportsCatalogFilters(client, args.opportunity_levels.length ? "opportunities" : "programs")
1457
+ && !legacyCatalogFilters(args).length && ["include", "exclude", "only"].includes(args.vdp_mode));
1458
+ if (serverCatalog && args.cursor === undefined && !legacyCatalogFilters(args).length)
1459
+ return findServerCatalogPrograms(client, config, args);
1460
+ if (serverCatalog) {
1461
+ // Platform, tag, kind, VDP, update and status predicates were applied
1462
+ // by the server; only the remaining filters run locally.
1463
+ return findFilteredServerPrograms(client, config, args, {
1464
+ ...args, sort_by: "best_match", platforms: [], tags: [], program_kind: undefined, contest_like: false,
1465
+ vdp_mode: "include", opportunity_levels: [], updated_since: undefined, skip_status_check: true,
1466
+ min_bounty_min: args.min_bounty_min, min_bounty_max: args.min_bounty_max, max_bounty_max: args.max_bounty_max,
1467
+ max_public_report_count: args.max_public_report_count, fresh_launch_days: args.fresh_launch_days,
1468
+ min_eligible_targets: args.min_eligible_targets, min_total_targets: args.min_total_targets,
1469
+ min_added_24h: args.min_added_24h, min_added_7d: args.min_added_7d,
1470
+ target_samples_only_wildcards: args.min_wildcards > 0
1471
+ });
1472
+ }
1473
+ return findPrograms(client, config, {
1474
+ ...args,
1475
+ updated_since: args.updated_since,
1476
+ min_bounty_min: args.min_bounty_min, min_bounty_max: args.min_bounty_max,
1477
+ max_bounty_max: args.max_bounty_max, max_public_report_count: args.max_public_report_count,
1478
+ fresh_launch_days: args.fresh_launch_days,
1479
+ min_eligible_targets: args.min_eligible_targets, min_total_targets: args.min_total_targets,
1480
+ min_added_24h: args.min_added_24h, min_added_7d: args.min_added_7d,
1481
+ sort_by: programSortSchema.parse(args.sort_by),
1482
+ target_samples_only_wildcards: args.min_wildcards > 0
1483
+ });
1484
+ }));
1267
1485
  registerTool("find_wildcard_programs", {
1268
1486
  title: "Find Wildcard Programs",
1269
1487
  description: "Wildcard program shortlist with platform, minimum reward, freshness, and report-count filters.",
@@ -2177,6 +2395,9 @@ function bbradarGuide(topic, profile, client) {
2177
2395
  arguments: { include_targets: false, max_cursor_pages: 2 },
2178
2396
  then: "For incremental polling, seed once with after_id and later set resume_cursor=true with the same filters."
2179
2397
  },
2398
+ { 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." },
2399
+ { 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." },
2400
+ { user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
2180
2401
  { 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
2402
  { 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
2403
  { user_goal: "exact recorded activity for one program", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
@@ -2290,6 +2511,7 @@ function bbradarGuide(topic, profile, client) {
2290
2511
  decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
2291
2512
  (topic === "start" || guideEntryMatchesTopic(topic, entry))),
2292
2513
  filters,
2514
+ activity_catalog: activityCatalogGuidance,
2293
2515
  advanced_tools: advancedTools,
2294
2516
  examples
2295
2517
  };
@@ -2322,6 +2544,9 @@ function coreBbradarGuide(topic, client) {
2322
2544
  first_tool: "get_repository_changes",
2323
2545
  arguments: { include_targets: false, max_cursor_pages: 2 }
2324
2546
  },
2547
+ { 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." },
2548
+ { 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" } },
2549
+ { user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
2325
2550
  { user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch change detail only when needed." },
2326
2551
  { user_goal: "most active programs", first_tool: "get_analytics_program_activity", arguments: {} },
2327
2552
  { user_goal: "exact recorded activity", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
@@ -2365,7 +2590,8 @@ function coreBbradarGuide(topic, client) {
2365
2590
  ],
2366
2591
  decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
2367
2592
  (topic === "start" || guideEntryMatchesTopic(topic, entry))),
2368
- available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name))
2593
+ available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name)),
2594
+ activity_catalog: activityCatalogGuidance
2369
2595
  };
2370
2596
  }
2371
2597
  function toolAvailableForContract(client, toolName) {
@@ -3375,29 +3601,14 @@ async function runResolvedProgramAction(client, config, programId, input) {
3375
3601
  });
3376
3602
  }
3377
3603
  if (input.action === "targets") {
3378
- const api = await client.getProgramTargets(programId);
3379
- const data = readObject(api.data);
3380
- const rawTargets = readArray(data?.targets);
3381
- const sanitizedTargets = rawTargets
3382
- .map(sanitizeTarget)
3383
- .filter((target) => targetHasAllowedScope(target, {
3604
+ return getProgramTargetsPayload(client, {
3605
+ target_type: input.target_type,
3606
+ language_tags: input.language_tags,
3384
3607
  include_out_of_scope: input.include_out_of_scope,
3385
3608
  include_ineligible: input.include_ineligible,
3386
- strict_scope_filter: true
3387
- }));
3388
- const targets = sanitizedTargets.slice(0, input.max_targets);
3389
- return withApiMetadata(api, {
3390
- program_id: programId,
3391
- targets: formatTargetList(targets, input.target_list_mode),
3392
- meta: {
3393
- limit: input.max_targets,
3394
- output_mode: input.target_list_mode,
3395
- total_active_targets: rawTargets.length,
3396
- total_after_filters: sanitizedTargets.length,
3397
- returned: targets.length,
3398
- has_more: sanitizedTargets.length > targets.length
3399
- }
3400
- });
3609
+ strict_scope_filter: true,
3610
+ offset: 0, limit: input.max_targets, output_mode: input.target_list_mode
3611
+ }, programId);
3401
3612
  }
3402
3613
  return checkProgramNewTargets(client, config, {
3403
3614
  program_id: programId,
@@ -4523,30 +4734,18 @@ async function getLatestAddedTargets(client, config, input) {
4523
4734
  .map((change) => readObject(change.target))
4524
4735
  .filter((target) => target !== undefined);
4525
4736
  let fullTargets = [];
4526
- let totalActiveTargets;
4527
- let totalAfterFilters;
4737
+ let fullTargetsMeta;
4528
4738
  if (input.include_full_target_list && programId) {
4529
- const targetsApi = await client.getProgramTargets(programId);
4530
- const targetsData = readObject(targetsApi.data);
4531
- const rawTargets = readArray(targetsData?.targets);
4532
- fullTargets = rawTargets
4533
- .map(sanitizeTarget)
4534
- .filter((target) => targetHasAllowedScope(target, {
4739
+ const payload = await getProgramTargetsPayload(client, {
4535
4740
  include_out_of_scope: input.full_list_include_out_of_scope,
4536
4741
  include_ineligible: input.full_list_include_ineligible,
4537
- strict_scope_filter: false
4538
- }));
4539
- totalActiveTargets = rawTargets.length;
4540
- totalAfterFilters = fullTargets.length;
4541
- sourceRequests.push({
4542
- source: "program_targets",
4543
- program_id: programId,
4544
- request_id: targetsApi.requestId,
4545
- upstream_request_id: targetsApi.upstreamRequestId,
4546
- ...apiSourceMetadata(targetsApi)
4547
- });
4742
+ strict_scope_filter: false,
4743
+ offset: 0, limit: input.full_target_limit, output_mode: input.full_target_list_mode
4744
+ }, programId);
4745
+ fullTargets = readArray(payload.targets);
4746
+ fullTargetsMeta = readObject(payload.meta);
4747
+ sourceRequests.push(...readArray(payload.source_requests).map(readObject).filter((request) => request !== undefined));
4548
4748
  }
4549
- const limitedFullTargets = fullTargets.slice(0, input.full_target_limit);
4550
4749
  return stripUndefined({
4551
4750
  request_id: randomUUID(),
4552
4751
  source_requests: sourceRequests,
@@ -4555,17 +4754,19 @@ async function getLatestAddedTargets(client, config, input) {
4555
4754
  program,
4556
4755
  matches,
4557
4756
  new_targets: formatTargetList(latestNewTargets, input.full_target_list_mode),
4558
- full_targets: input.include_full_target_list ? formatTargetList(limitedFullTargets, input.full_target_list_mode) : undefined,
4757
+ full_targets: input.include_full_target_list ? fullTargets : undefined,
4559
4758
  meta: stripUndefined({
4560
4759
  recent_changes_scanned: changes.length,
4561
4760
  matching_changes_scanned: matchingChanges.length,
4562
4761
  matches_returned: matches.length,
4563
4762
  matches_has_more: matches.length < matchingChanges.length,
4564
4763
  new_targets_returned: formatTargetList(latestNewTargets, input.full_target_list_mode).length,
4565
- full_targets_total_active: totalActiveTargets,
4566
- full_targets_total_after_filters: totalAfterFilters,
4567
- full_targets_returned: input.include_full_target_list ? formatTargetList(limitedFullTargets, input.full_target_list_mode).length : undefined,
4568
- full_targets_has_more: input.include_full_target_list ? limitedFullTargets.length < fullTargets.length : undefined
4764
+ full_targets_total_active: fullTargetsMeta?.total_active_targets,
4765
+ full_targets_total_after_filters: fullTargetsMeta?.total_after_filters,
4766
+ full_targets_returned: input.include_full_target_list ? fullTargets.length : undefined,
4767
+ full_targets_has_more: fullTargetsMeta?.has_more,
4768
+ full_targets_next_offset: fullTargetsMeta?.next_offset,
4769
+ full_targets_possibly_incomplete: fullTargetsMeta?.possibly_incomplete
4569
4770
  })
4570
4771
  });
4571
4772
  }
@@ -4603,15 +4804,21 @@ function latestAddedProgramMatches(change, input, platformFilters) {
4603
4804
  return true;
4604
4805
  }
4605
4806
  async function getProgramTargetsPayload(client, input, programId) {
4606
- const pageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
4807
+ const maxPageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
4607
4808
  // `eligibility=eligible` is an API-level in-scope bounty-eligibility filter,
4608
4809
  // so the common eligible-only path needs no second local scope pass.
4609
- const directOffsetPaging = !input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope);
4810
+ const languageTags = new Set((input.language_tags ?? []).map(normalizeTag));
4811
+ const directOffsetPaging = languageTags.size === 0 && (!input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope));
4812
+ // Bound direct reads to the requested prefix. Local post-filters still need
4813
+ // full pages to find sparse matches. Offsets remain filtered row offsets,
4814
+ // independent of the page size used by an earlier call.
4815
+ const pageSize = directOffsetPaging ? Math.min(maxPageSize, input.offset + input.limit) : maxPageSize;
4610
4816
  const collected = [];
4611
4817
  let matchedSeen = directOffsetPaging ? Math.floor(input.offset / pageSize) * pageSize : 0;
4612
4818
  let page = directOffsetPaging ? Math.floor(input.offset / pageSize) + 1 : 1;
4613
4819
  const firstPage = page;
4614
4820
  let api;
4821
+ const sourceRequests = [];
4615
4822
  let upstreamMeta;
4616
4823
  let upstreamHasMore = false;
4617
4824
  do {
@@ -4625,11 +4832,15 @@ async function getProgramTargetsPayload(client, input, programId) {
4625
4832
  page,
4626
4833
  page_size: pageSize
4627
4834
  });
4835
+ sourceRequests.push({ source: "program_targets", program_id: programId, page,
4836
+ request_id: api.requestId, upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
4628
4837
  const data = readObject(api.data);
4629
4838
  upstreamMeta = readObject(data?.meta);
4630
4839
  for (const target of readArray(data?.targets).map(sanitizeTarget)) {
4631
4840
  if (!targetHasAllowedScope(target, input))
4632
4841
  continue;
4842
+ if (languageTags.size > 0 && !arraysIntersectNormalized(languageTags, readStringArrayField(target, "language_tags")))
4843
+ continue;
4633
4844
  if (matchedSeen >= input.offset && collected.length < input.limit)
4634
4845
  collected.push(target);
4635
4846
  matchedSeen += 1;
@@ -4647,8 +4858,10 @@ async function getProgramTargetsPayload(client, input, programId) {
4647
4858
  throw new Error("No program target response was collected.");
4648
4859
  }
4649
4860
  const targets = collected;
4861
+ const hasMore = upstreamHasMore || matchedSeen > input.offset + targets.length;
4650
4862
  return withApiMetadata(api, {
4651
4863
  program_id: programId,
4864
+ source_requests: sourceRequests,
4652
4865
  targets: formatTargetList(targets, input.output_mode),
4653
4866
  meta: {
4654
4867
  offset: input.offset,
@@ -4657,7 +4870,8 @@ async function getProgramTargetsPayload(client, input, programId) {
4657
4870
  total_active_targets: readNumber(upstreamMeta?.total) ?? matchedSeen,
4658
4871
  total_after_filters: upstreamHasMore ? undefined : matchedSeen,
4659
4872
  returned: targets.length,
4660
- has_more: upstreamHasMore || matchedSeen > input.offset + targets.length,
4873
+ has_more: hasMore,
4874
+ next_offset: hasMore ? input.offset + targets.length : undefined,
4661
4875
  upstream_has_more: upstreamHasMore,
4662
4876
  possibly_incomplete: upstreamHasMore,
4663
4877
  upstream_pages_fetched: page - firstPage,
@@ -4670,9 +4884,16 @@ async function getProgramTargetsPayload(client, input, programId) {
4670
4884
  });
4671
4885
  }
4672
4886
  async function getProgramTargetsExportFallbackPayload(client, input, programId) {
4887
+ if (input.search || input.target_type || input.min_target_score !== undefined
4888
+ || input.target_opportunity_label || input.competition_risk) {
4889
+ throw new Error("The per-program targets endpoint is unavailable. Its search, type and intelligence filters cannot be preserved by the export fallback; the requested filters were not discarded.");
4890
+ }
4673
4891
  const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, programId);
4674
- const sanitizedTargets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, input));
4892
+ const languageTags = new Set((input.language_tags ?? []).map(normalizeTag));
4893
+ const sanitizedTargets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, input)
4894
+ && (languageTags.size === 0 || arraysIntersectNormalized(languageTags, readStringArrayField(target, "language_tags"))));
4675
4895
  const targets = sanitizedTargets.slice(input.offset, input.offset + input.limit);
4896
+ const hasMore = input.offset + targets.length < sanitizedTargets.length;
4676
4897
  return stripUndefined({
4677
4898
  request_id: exportLookup.requestId,
4678
4899
  upstream_request_id: exportLookup.upstreamRequestId,
@@ -4686,10 +4907,10 @@ async function getProgramTargetsExportFallbackPayload(client, input, programId)
4686
4907
  offset: input.offset,
4687
4908
  limit: input.limit,
4688
4909
  output_mode: input.output_mode,
4689
- total_active_targets: exportLookup.rawTargets.length,
4690
- total_after_filters: sanitizedTargets.length,
4910
+ ...fallbackTargetCoverage(exportLookup, sanitizedTargets.length),
4691
4911
  returned: targets.length,
4692
- has_more: input.offset + input.limit < sanitizedTargets.length,
4912
+ has_more: hasMore || exportLookup.coverage?.possibly_incomplete === true,
4913
+ next_offset: hasMore ? input.offset + targets.length : undefined,
4693
4914
  target_source: exportLookup.source,
4694
4915
  target_list_unavailable: exportLookup.unavailable,
4695
4916
  fallback_error: exportLookup.error
@@ -4718,6 +4939,7 @@ async function fetchProgramTargetsWithEndpointFallback(client, programId) {
4718
4939
  })),
4719
4940
  source: "program_targets",
4720
4941
  rawTargets,
4942
+ coverage: targetPageCoverage(data, rawTargets.length, rawTargets.length),
4721
4943
  sourceRequests: [
4722
4944
  {
4723
4945
  source: "program_targets",
@@ -4817,14 +5039,21 @@ function cacheTtlMsFromDiagnostics(cache) {
4817
5039
  }
4818
5040
  async function fetchProgramTargetsFromExportFallback(client, programId) {
4819
5041
  try {
5042
+ const limit = Math.min(MAX_TARGET_EXPORT, client.endpointNumber("target_export", "max_targets", MAX_TARGET_EXPORT));
4820
5043
  const api = await client.exportTargets({
4821
5044
  program_ids: [programId],
4822
5045
  include_out_of_scope: true,
4823
5046
  include_ineligible: true,
4824
5047
  format: "json",
4825
- limit: MAX_TARGET_EXPORT
5048
+ limit
4826
5049
  });
4827
5050
  const rawTargets = readExportTargetRows(api.data).map((row) => sanitizeTarget(readObject(row)?.target ?? row));
5051
+ const data = readObject(api.data);
5052
+ const coverage = targetPageCoverage({ meta: {
5053
+ ...readObject(data?.meta),
5054
+ // A full capped export is not proof that the inventory ends here.
5055
+ has_more: readObject(data?.meta)?.has_more === true || rawTargets.length >= limit
5056
+ } }, rawTargets.length, rawTargets.length);
4828
5057
  return {
4829
5058
  requestId: api.requestId,
4830
5059
  upstreamRequestId: api.upstreamRequestId,
@@ -4838,6 +5067,7 @@ async function fetchProgramTargetsFromExportFallback(client, programId) {
4838
5067
  })),
4839
5068
  source: "target_export",
4840
5069
  rawTargets,
5070
+ coverage,
4841
5071
  sourceRequests: [
4842
5072
  {
4843
5073
  source: "target_export",
@@ -5098,8 +5328,7 @@ async function getProgramScopeSummary(client, config, input) {
5098
5328
  program: formatProgram(lookup.program, "compact"),
5099
5329
  scope: buildTargetScopeSummary(targets, input.target_list_mode, input.group_limit, input.include_out_of_scope, input.include_ineligible),
5100
5330
  meta: {
5101
- total_active_targets: rawTargets.length,
5102
- total_after_filters: targets.length,
5331
+ ...targetPageCoverage(targetsData, rawTargets.length, targets.length),
5103
5332
  target_list_mode: input.target_list_mode,
5104
5333
  group_limit: input.group_limit,
5105
5334
  target_source: "program_targets",
@@ -5124,8 +5353,7 @@ async function getProgramScopeSummaryFromIndexFallback(client, indexFallback, in
5124
5353
  program: formatProgram(indexFallback.program, "compact"),
5125
5354
  scope: buildTargetScopeSummary(targets, input.target_list_mode, input.group_limit, input.include_out_of_scope, input.include_ineligible),
5126
5355
  meta: stripUndefined({
5127
- total_active_targets: exportLookup.rawTargets.length,
5128
- total_after_filters: targets.length,
5356
+ ...fallbackTargetCoverage(exportLookup, targets.length),
5129
5357
  target_list_mode: input.target_list_mode,
5130
5358
  group_limit: input.group_limit,
5131
5359
  target_source: exportLookup.source,
@@ -5161,8 +5389,7 @@ async function getProgramTargetBreakdown(client, config, input) {
5161
5389
  groups: readObject(scope.groups)
5162
5390
  },
5163
5391
  meta: {
5164
- total_active_targets: rawTargets.length,
5165
- total_after_filters: targets.length,
5392
+ ...targetPageCoverage(targetsData, rawTargets.length, targets.length),
5166
5393
  target_list_mode: input.target_list_mode,
5167
5394
  group_limit: input.group_limit,
5168
5395
  target_source: "program_targets",
@@ -5170,6 +5397,32 @@ async function getProgramTargetBreakdown(client, config, input) {
5170
5397
  }
5171
5398
  });
5172
5399
  }
5400
+ function targetPageCoverage(data, rows, filteredRows) {
5401
+ const meta = readObject(data?.meta);
5402
+ const total = numberField(meta, "total");
5403
+ const incomplete = meta?.has_more === true || (numberField(meta, "total_pages") ?? 1) > 1
5404
+ || (total !== undefined && total > rows);
5405
+ return stripUndefined({
5406
+ total_active_targets: total ?? (incomplete ? undefined : rows),
5407
+ total_after_filters: incomplete ? undefined : filteredRows,
5408
+ targets_scanned: rows,
5409
+ matching_targets_scanned: filteredRows,
5410
+ possibly_incomplete: incomplete,
5411
+ count_scope: incomplete ? "sampled_target_page" : "complete_target_inventory"
5412
+ });
5413
+ }
5414
+ function fallbackTargetCoverage(lookup, filteredRows) {
5415
+ const incomplete = lookup.unavailable || lookup.coverage?.possibly_incomplete === true;
5416
+ return stripUndefined({
5417
+ ...lookup.coverage,
5418
+ total_active_targets: lookup.unavailable ? undefined : lookup.coverage?.total_active_targets ?? (incomplete ? undefined : lookup.rawTargets.length),
5419
+ total_after_filters: incomplete ? undefined : filteredRows,
5420
+ targets_scanned: lookup.rawTargets.length,
5421
+ matching_targets_scanned: filteredRows,
5422
+ possibly_incomplete: incomplete,
5423
+ count_scope: lookup.unavailable ? "unavailable" : incomplete ? "sampled_target_page" : "complete_target_inventory"
5424
+ });
5425
+ }
5173
5426
  async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback, input) {
5174
5427
  const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, input.program_id);
5175
5428
  const targets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, {
@@ -5196,8 +5449,7 @@ async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback,
5196
5449
  groups: readObject(scope.groups)
5197
5450
  },
5198
5451
  meta: stripUndefined({
5199
- total_active_targets: exportLookup.rawTargets.length,
5200
- total_after_filters: targets.length,
5452
+ ...fallbackTargetCoverage(exportLookup, targets.length),
5201
5453
  target_list_mode: input.target_list_mode,
5202
5454
  group_limit: input.group_limit,
5203
5455
  target_source: exportLookup.source,
@@ -6040,6 +6292,9 @@ function compactProgram(program) {
6040
6292
  competition_risk: stringField(program, "competition_risk"),
6041
6293
  competition_risk_reason: stringField(program, "competition_risk_reason"),
6042
6294
  submission_cost: program.submission_cost,
6295
+ payout_summary: program.payout_summary,
6296
+ activity: program.activity,
6297
+ activity_summary: program.activity_summary,
6043
6298
  reward_range: program.reward_range,
6044
6299
  reward_range_label: stringField(program, "reward_range_label"),
6045
6300
  public_report_count: program.public_report_count,
@@ -6598,7 +6853,22 @@ function clampLimit(name, value, max, warnings) {
6598
6853
  warnings.push(`${name} was clamped from ${value} to ${max}.`);
6599
6854
  return max;
6600
6855
  }
6601
- async function findHuntTargets(client, config, input) {
6856
+ // Candidate pool for hunts narrowed by Activity on the server. Matches arrive in
6857
+ // server order, so a larger pool lets ranking_mode choose among more of them.
6858
+ const HUNT_SERVER_POOL = 100;
6859
+ async function findHuntTargets(client, config, input, activity = {}) {
6860
+ const huntStartedAt = Date.now();
6861
+ const phaseTiming = {};
6862
+ const runStage = async (phase, timeoutMs, callback) => {
6863
+ const startedAt = Date.now();
6864
+ try {
6865
+ return await client.withRequestTimeout(timeoutMs, callback);
6866
+ }
6867
+ finally {
6868
+ phaseTiming[phase] = Date.now() - startedAt;
6869
+ }
6870
+ };
6871
+ validateActivityCatalogInput(activity);
6602
6872
  const warnings = [];
6603
6873
  const errors = [];
6604
6874
  const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
@@ -6641,6 +6911,7 @@ async function findHuntTargets(client, config, input) {
6641
6911
  min_total_targets: input.min_total_targets,
6642
6912
  min_added_24h: undefined,
6643
6913
  min_added_7d: input.min_added_7d,
6914
+ free_submission_only: input.free_submission_only,
6644
6915
  max_pages: input.max_program_pages,
6645
6916
  max_results: candidatePoolSize,
6646
6917
  sort_by: huntProgramSort(input.ranking_mode),
@@ -6662,14 +6933,29 @@ async function findHuntTargets(client, config, input) {
6662
6933
  else if (recentChangeQueries.length < desiredRecentChangeQueries.length) {
6663
6934
  warnings.push(`Recent target-change queries were reduced from ${desiredRecentChangeQueries.length} to ${recentChangeQueries.length} to preserve the upstream request budget.`);
6664
6935
  }
6936
+ const serverFiltered = hasActivityCatalogInput(activity);
6937
+ // Catalog discovery and optional changes share at most 60% of the tool
6938
+ // deadline. Keep at least 30% for exact targets and 10% for the response.
6665
6939
  const [programPayload, recentResult] = await Promise.all([
6666
- findPrograms(client, config, programInput),
6940
+ runStage("program_discovery_ms", Math.floor(config.requestTimeoutMs * 0.6), () => serverFiltered
6941
+ ? findFilteredServerPrograms(client, config, {
6942
+ ...programInput, ...activity, opportunity_levels: [], sort_by: activity.sort_by, sort_direction: activity.sort_direction,
6943
+ platforms: input.platforms, tags: input.tags, updated_since: input.updated_since, vdp_mode: "exclude", include_target_samples: false
6944
+ }, {
6945
+ ...programInput, platforms: [], tags: [], vdp_mode: "include", updated_since: undefined,
6946
+ max_pages: programRequestBudget, upstream_request_budget: programRequestBudget
6947
+ }, HUNT_SERVER_POOL, true)
6948
+ : findPrograms(client, config, programInput, {
6949
+ allowPartialTimeout: true, excludeVdpUpstream: true
6950
+ })),
6667
6951
  recentChangesEnabled
6668
- ? fetchHuntRecentChanges(client, config, input, recentChangeQueries)
6952
+ ? runStage("recent_changes_ms", Math.floor(config.requestTimeoutMs * 0.3), () => fetchHuntRecentChanges(client, config, input, recentChangeQueries))
6669
6953
  : Promise.resolve({ changes: [], sourceRequests: [], requestsUsed: 0, errors: [] })
6670
6954
  ]);
6955
+ client.throwIfRequestAborted();
6671
6956
  const programWarnings = readArray(programPayload.warnings).filter((warning) => typeof warning === "string");
6672
6957
  warnings.push(...programWarnings);
6958
+ errors.push(...readArray(programPayload.errors).map(readObject).filter((error) => error !== undefined));
6673
6959
  errors.push(...recentResult.errors);
6674
6960
  const programBudget = readObject(programPayload.upstream_budget);
6675
6961
  const programRequestsUsed = readNumber(programBudget?.used) ?? 0;
@@ -6688,8 +6974,12 @@ async function findHuntTargets(client, config, input) {
6688
6974
  const finalistEntries = preliminaryRankedEntries
6689
6975
  .slice(0, sampledProgramCount)
6690
6976
  .map((entry) => entry.entry);
6691
- const targetResult = await collectHuntTargetSamples(client, finalistEntries, input);
6977
+ const targetResult = await runStage("target_sampling_ms", Math.floor(config.requestTimeoutMs * 0.9) - (Date.now() - huntStartedAt), () => collectHuntTargetSamples(client, finalistEntries, input));
6978
+ client.throwIfRequestAborted();
6692
6979
  errors.push(...targetResult.errors);
6980
+ if (errors.length > 0) {
6981
+ warnings.push("Some discovery or enrichment reads failed or reached their time budget. Returned matches preserve the requested filters; missing target samples or change evidence are unavailable, not confirmed absent. Report the incomplete coverage without narrowing platforms or relaxing thresholds.");
6982
+ }
6693
6983
  if (sampledProgramCount < Math.min(input.max_results, programEntries.length)) {
6694
6984
  warnings.push(`The upstream request budget limited the final exact target shortlist to ${sampledProgramCount} of ${Math.min(input.max_results, programEntries.length)} requested results.`);
6695
6985
  }
@@ -6703,6 +6993,10 @@ async function findHuntTargets(client, config, input) {
6703
6993
  }));
6704
6994
  const targetRequestsUsed = targetResult.requestsUsed;
6705
6995
  const totalRequestsUsed = programRequestsUsed + recentResult.requestsUsed + targetRequestsUsed;
6996
+ const coverageIncomplete = errors.length > 0
6997
+ || readObject(programPayload.ranking_scope)?.possibly_incomplete === true
6998
+ || readObject(programPayload.scan)?.complete === false
6999
+ || sampledProgramCount < Math.min(input.max_results, programEntries.length);
6706
7000
  return stripUndefined({
6707
7001
  request_id: randomUUID(),
6708
7002
  fetched_at: new Date().toISOString(),
@@ -6733,6 +7027,8 @@ async function findHuntTargets(client, config, input) {
6733
7027
  min_target_score: input.min_target_score,
6734
7028
  target_opportunity_label: input.target_opportunity_label,
6735
7029
  competition_risk: input.competition_risk,
7030
+ free_submission_only: input.free_submission_only || undefined,
7031
+ activity: serverFiltered ? stripUndefined(activity) : undefined,
6736
7032
  recent_target_days: input.include_recent_changes ? input.recent_target_days : undefined,
6737
7033
  ranking_mode: input.ranking_mode
6738
7034
  }),
@@ -6743,14 +7039,19 @@ async function findHuntTargets(client, config, input) {
6743
7039
  },
6744
7040
  ranking_scope: stripUndefined({
6745
7041
  ...(readObject(programPayload.ranking_scope) ?? {}),
7042
+ local_ranking: serverFiltered ? input.ranking_mode : undefined,
6746
7043
  single_program_catalog_pass: true,
6747
7044
  recent_change_window_days: input.include_recent_changes ? input.recent_target_days : undefined,
6748
7045
  recent_change_page_size: input.include_recent_changes ? input.recent_changes_page_size : undefined
6749
7046
  }),
7047
+ scan: readObject(programPayload.scan),
6750
7048
  result_guidance: {
6751
7049
  final_shortlist: true,
6752
7050
  follow_up_calls_required: false,
6753
- instruction: "Use these matches as the final shortlist. Exact bounded target samples and recent additions are already included; call get_program_targets only if the user explicitly requests an exhaustive scope dump for one selected program."
7051
+ coverage_incomplete: coverageIncomplete,
7052
+ instruction: coverageIncomplete
7053
+ ? "Use the bounded matches and report warnings and incomplete coverage. Preserve the user's periods, thresholds and platform scope; do not infer supported platforms from a sampled recent-change feed."
7054
+ : "Use these matches as the final shortlist. Exact bounded target samples and recent additions are already included; call get_program_targets only if the user explicitly requests an exhaustive scope dump for one selected program."
6754
7055
  },
6755
7056
  matches,
6756
7057
  meta: {
@@ -6763,7 +7064,8 @@ async function findHuntTargets(client, config, input) {
6763
7064
  model_round_trips_expected: 1,
6764
7065
  parallel_recent_change_fetch: recentChangeQueries.length > 0,
6765
7066
  bulk_resource_links_suppressed: true,
6766
- partial_success: errors.length > 0
7067
+ partial_success: errors.length > 0,
7068
+ phase_timing: phaseTiming
6767
7069
  }
6768
7070
  });
6769
7071
  }
@@ -7459,10 +7761,288 @@ function uniqueProgramsFromRepositoryMatches(matches) {
7459
7761
  }
7460
7762
  return [...programs.values()];
7461
7763
  }
7462
- async function findPrograms(client, config, input) {
7764
+ /** Filters the MCP applies to program records after retrieval. */
7765
+ function localCatalogFilters(input) {
7766
+ const keys = ["exclude_platforms", "scope_tags", "target_types", "language_tags", "web3", "exclude_web3",
7767
+ "min_bounty_min", "min_bounty_max", "max_bounty_max", "require_bounty", "max_public_report_count",
7768
+ "require_known_report_count", "include_unknown_report_count", "exclude_ctf", "exclude_mobile_only",
7769
+ "exclude_non_web", "has_api_scope", "fresh_launch_days", "min_eligible_targets", "min_total_targets",
7770
+ "min_added_24h", "min_added_7d", "free_submission_only", "competition_risks"].filter(key => {
7771
+ const value = input[key];
7772
+ return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false;
7773
+ });
7774
+ if (Number(input.min_wildcards) > 0)
7775
+ keys.push("min_wildcards");
7776
+ return keys;
7777
+ }
7778
+ function legacyCatalogFilters(input) {
7779
+ return input.include_target_samples === true ? [...localCatalogFilters(input), "include_target_samples"] : localCatalogFilters(input);
7780
+ }
7781
+ function supportsCatalogFilters(client, endpoint) {
7782
+ return client.capabilitySnapshot().status === "negotiated"
7783
+ && readObject(client.endpointCapability(endpoint)?.catalog_filters)?.enabled === true;
7784
+ }
7785
+ function assertCatalogQuerySupported(client, endpoint, input) {
7786
+ if (!["include", "exclude", "only"].includes(String(input.vdp_mode ?? "exclude"))) {
7787
+ 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.");
7788
+ }
7789
+ const capability = client.endpointCapability(endpoint);
7790
+ if (!supportsCatalogFilters(client, endpoint)) {
7791
+ 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.");
7792
+ }
7793
+ if (requiresActivityQuery(input) && readObject(capability?.activity_filters)?.enabled !== true) {
7794
+ throw new Error("Activity catalog filters are unavailable on this API deployment. Ordinary program discovery remains available.");
7795
+ }
7796
+ const kind = input.program_kind ?? (input.contest_like ? "contest" : undefined);
7797
+ if (kind && !readArray(capability?.program_kind_filter).includes(kind)) {
7798
+ throw new Error(`This API deployment does not support program_kind=${String(kind)} on ${endpoint}; the filter was not sent or discarded.`);
7799
+ }
7800
+ }
7801
+ function hasActivityCatalogInput(input) {
7802
+ return Object.keys(activityCatalogInput).some((key) => key === "sort_by"
7803
+ ? activityCatalogSortSchema.safeParse(input[key]).success
7804
+ : input[key] !== undefined);
7805
+ }
7806
+ /** Validates and builds one server-ordered catalog query; nothing is sent. */
7807
+ function serverCatalogQuery(client, input) {
7808
+ validateActivityCatalogInput(input);
7809
+ const levels = Array.isArray(input.opportunity_levels) ? input.opportunity_levels : [];
7810
+ if (levels.length > 1)
7811
+ throw new Error("Combined Activity queries accept at most one opportunity tier; separate tier pages cannot form a global ranking.");
7812
+ if (input.sort_by !== undefined && !activityCatalogSortSchema.safeParse(input.sort_by).success) {
7813
+ throw new Error("Combined Activity queries require an enabled server sort_by. Omit it for updated descending; legacy sampled sorts cannot rank these results.");
7814
+ }
7815
+ if (input.contest_like && input.program_kind && input.program_kind !== "contest")
7816
+ throw new Error("contest_like conflicts with program_kind.");
7817
+ const endpoint = levels.length ? "opportunities" : "programs";
7818
+ assertCatalogQuerySupported(client, endpoint, input);
7819
+ assertStableSnapshotSupported(client, endpoint, typeof input.snapshot === "string" ? input.snapshot : undefined);
7820
+ const signals = Object.fromEntries(Object.keys(activityCatalogInput).filter((key) => input[key] !== undefined).map((key) => [key, input[key]]));
7821
+ return {
7822
+ endpoint, level: levels[0], maxPageSize: client.endpointNumber(endpoint, "max_page_size", MAX_PROGRAMS_PER_SEARCH),
7823
+ query: { ...signals, search: input.search, platforms: input.platforms, tags: input.tags,
7824
+ program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined), updated_since: input.updated_since,
7825
+ vdp_mode: input.vdp_mode, snapshot: input.snapshot }
7826
+ };
7827
+ }
7828
+ function readServerCatalogPage(client, catalog, page, pageSize, snapshot) {
7829
+ const query = toQuery({ ...catalog.query, snapshot, page, page_size: pageSize });
7830
+ return catalog.level ? client.getOpportunities(catalog.level, query) : client.listPrograms(query);
7831
+ }
7832
+ async function findServerCatalogPrograms(client, config, input) {
7833
+ // One exact server page: page and snapshot continue it. Local filters use the
7834
+ // ordered scan below instead.
7835
+ const catalog = serverCatalogQuery(client, input);
7836
+ const pageSize = Number(input.max_results) || 10;
7837
+ if (pageSize > catalog.maxPageSize)
7838
+ throw new Error(`Combined Activity pages support max_results up to ${catalog.maxPageSize}; use page and the returned stable snapshot to continue.`);
7839
+ const api = await readServerCatalogPage(client, catalog, Number(input.page ?? 1), pageSize, input.snapshot);
7840
+ const data = readObject(api.data);
7841
+ const mode = input.output_mode === "full" ? "full" : "compact";
7842
+ return withApiMetadata(api, {
7843
+ programs: readArray(data?.programs).map((program) => formatProgram(sanitizeProgram(program, config.webBaseUrl), mode)),
7844
+ ranking_scope: { mode: "server", sort_by: input.sort_by ?? "updated", sort_direction: input.sort_direction ?? "desc" },
7845
+ meta: sanitizeJson(data?.meta)
7846
+ });
7847
+ }
7848
+ // Pages requested together while scanning. Activity catalog reads share the
7849
+ // two-request changes pool, so a wider window would only queue.
7850
+ const CATALOG_SCAN_PREFETCH = 2;
7851
+ function encodeScanCursor(cursor) {
7852
+ return Buffer.from(JSON.stringify(cursor)).toString("base64url");
7853
+ }
7854
+ function decodeScanCursor(value, fingerprint, pageSize) {
7855
+ let cursor;
7856
+ try {
7857
+ cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
7858
+ }
7859
+ catch {
7860
+ cursor = undefined;
7861
+ }
7862
+ if (!cursor || cursor.v !== 1 || !Number.isInteger(cursor.p) || cursor.p < 1 || !Number.isInteger(cursor.k) || cursor.k < 0) {
7863
+ throw new Error("Invalid scan cursor. Use scan.next_cursor from the previous find_programs result.");
7864
+ }
7865
+ if (cursor.f !== fingerprint || cursor.s !== pageSize) {
7866
+ throw new Error("This scan cursor belongs to different filters. Repeat the previous filters exactly, or start again without cursor.");
7867
+ }
7868
+ return cursor;
7869
+ }
7870
+ /**
7871
+ * Reads server-ordered pages and keeps the programs that also pass the local
7872
+ * filters, until enough match or the page/request budget is spent. Filtering
7873
+ * never reorders, so the result is a prefix of the server's global order.
7874
+ */
7875
+ async function scanServerCatalog(client, config, input, local, limits) {
7876
+ const catalog = serverCatalogQuery(client, input);
7877
+ const pageSize = catalog.maxPageSize;
7878
+ const localFilters = localCatalogFilters(input);
7879
+ const fingerprint = createHash("sha256")
7880
+ .update(JSON.stringify([catalog.endpoint, catalog.level, toQuery({ ...catalog.query, snapshot: undefined }), localFilters.map((key) => [key, input[key]])]))
7881
+ .digest("base64url").slice(0, 16);
7882
+ const cursorValue = typeof input.cursor === "string" ? input.cursor : undefined;
7883
+ if (cursorValue && input.page !== undefined)
7884
+ throw new Error("Use either cursor or page, not both.");
7885
+ const start = cursorValue ? decodeScanCursor(cursorValue, fingerprint, pageSize)
7886
+ : { v: 1, p: Number(input.page ?? 1), k: 0, s: pageSize, f: fingerprint, a: undefined, t: undefined };
7887
+ const sourceRequests = [];
7888
+ const errors = [];
7889
+ let matches = [];
7890
+ let seen = new Set();
7891
+ let nextPage = start.p;
7892
+ let skip = start.k;
7893
+ let version = start.a;
7894
+ let token = start.t ?? input.snapshot;
7895
+ let totalPages;
7896
+ let serverTotal;
7897
+ let rowsScanned = 0;
7898
+ let pagesRead = 0;
7899
+ let restarted = false;
7900
+ let exhausted = false;
7901
+ let resume;
7902
+ scan: while (matches.length < limits.maxResults) {
7903
+ const pagesLeft = totalPages === undefined ? Number.POSITIVE_INFINITY : totalPages - nextPage + 1;
7904
+ const allowance = Math.min(limits.maxPages - pagesRead, limits.budget.remaining, pagesLeft);
7905
+ if (allowance <= 0)
7906
+ break;
7907
+ // Speculate only from an observed match rate, never before the first page.
7908
+ const rate = rowsScanned > 0 ? Math.max(matches.length / rowsScanned, 0.05) : 1;
7909
+ const needed = pagesRead === 0 ? 1 : Math.ceil((limits.maxResults - matches.length) / (rate * pageSize));
7910
+ const batch = Math.max(1, Math.min(allowance, needed, CATALOG_SCAN_PREFETCH));
7911
+ const pageToken = token;
7912
+ const batchStart = nextPage;
7913
+ const pages = await Promise.allSettled(Array.from({ length: batch }, (_, index) => batchStart + index)
7914
+ .map(async (page) => ({ page, api: await readServerCatalogPage(client, catalog, page, pageSize, pageToken) })));
7915
+ // Count all admitted reads, including a failed page or a prefetched page
7916
+ // beyond a gap; only the contiguous successful prefix enters the ranking.
7917
+ for (const result of pages) {
7918
+ const noRequest = result.status === "fulfilled" ? result.value.api.cached || result.value.api.coalesced
7919
+ : result.reason instanceof BBRadarApiError && (result.reason.cached || result.reason.coalesced);
7920
+ limits.budget.remaining -= noRequest ? 0 : 1;
7921
+ }
7922
+ for (const [index, result] of pages.entries()) {
7923
+ if (result.status === "rejected") {
7924
+ if (!limits.allowPartialTimeout || pagesRead === 0 || !(result.reason instanceof BBRadarApiError) || result.reason.status !== 408)
7925
+ throw result.reason;
7926
+ errors.push(apiOperationError(`program catalog page ${batchStart + index}`, result.reason));
7927
+ break scan;
7928
+ }
7929
+ const { page, api } = result.value;
7930
+ pagesRead += 1;
7931
+ sourceRequests.push({ source: catalog.level ? `opportunities:${catalog.level}` : "programs", page, request_id: api.requestId,
7932
+ upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
7933
+ const data = readObject(api.data);
7934
+ const meta = readObject(data?.meta);
7935
+ const pageVersion = stringField(meta ?? {}, "activity_snapshot_id");
7936
+ if (version && pageVersion && pageVersion !== version) {
7937
+ // Never mix rows from two Activity versions. Within one call, restart
7938
+ // once on the new version; a continued scan must be restarted by the caller.
7939
+ if (cursorValue || restarted) {
7940
+ throw new Error("The Activity version changed while scanning. Rerun the same query without cursor.");
7941
+ }
7942
+ restarted = true;
7943
+ matches = [];
7944
+ seen = new Set();
7945
+ rowsScanned = 0;
7946
+ nextPage = start.p;
7947
+ skip = start.k;
7948
+ totalPages = undefined;
7949
+ version = pageVersion;
7950
+ continue scan;
7951
+ }
7952
+ version ??= pageVersion;
7953
+ totalPages = readNumber(meta?.total_pages) ?? totalPages;
7954
+ serverTotal = readNumber(meta?.total) ?? serverTotal;
7955
+ if (token !== undefined && typeof meta?.snapshot === "string")
7956
+ token = meta.snapshot;
7957
+ const rows = readArray(data?.programs);
7958
+ const lastPage = rows.length < pageSize || (totalPages !== undefined && page >= totalPages);
7959
+ for (let index = skip; index < rows.length; index += 1) {
7960
+ rowsScanned += 1;
7961
+ const candidate = toProgramCandidate(rows[index], config.webBaseUrl);
7962
+ const id = stringField(candidate.program, "id") ?? `${page}:${index}`;
7963
+ if (seen.has(id) || !programCandidateMatches(candidate, local))
7964
+ continue;
7965
+ seen.add(id);
7966
+ matches.push(candidate);
7967
+ if (matches.length >= limits.maxResults) {
7968
+ resume = index + 1 < rows.length ? { p: page, k: index + 1 } : lastPage ? undefined : { p: page + 1, k: 0 };
7969
+ exhausted = resume === undefined;
7970
+ break scan;
7971
+ }
7972
+ }
7973
+ skip = 0;
7974
+ if (lastPage) {
7975
+ exhausted = true;
7976
+ break scan;
7977
+ }
7978
+ nextPage = page + 1;
7979
+ }
7980
+ }
7981
+ if (!exhausted && !resume)
7982
+ resume = { p: nextPage, k: skip };
7983
+ return {
7984
+ candidates: matches,
7985
+ sourceRequests,
7986
+ errors,
7987
+ scan: stripUndefined({
7988
+ order: "Server order; local filters only remove programs.",
7989
+ sort_by: input.sort_by ?? "updated",
7990
+ sort_direction: input.sort_direction ?? "desc",
7991
+ local_filters: localFilters.length > 0 ? localFilters : undefined,
7992
+ server_matches: serverTotal,
7993
+ rows_scanned: rowsScanned,
7994
+ pages_read: pagesRead,
7995
+ complete: exhausted,
7996
+ next_cursor: resume
7997
+ ? encodeScanCursor({ v: 1, p: resume.p, k: resume.k, s: pageSize, f: fingerprint, a: version, t: typeof token === "string" ? token : undefined })
7998
+ : undefined,
7999
+ activity_snapshot_id: version,
8000
+ restarted_on_new_activity_version: restarted || undefined,
8001
+ deadline_reached: errors.length > 0 || undefined
8002
+ })
8003
+ };
8004
+ }
8005
+ /** find_programs with server Activity filters plus local filters or target samples. */
8006
+ async function findFilteredServerPrograms(client, config, input, local, maxResults = local.max_results, allowPartialTimeout = false) {
8007
+ const normalized = normalizeFindProgramsInput(local);
8008
+ const warnings = normalized.warnings;
8009
+ local = normalized.input;
8010
+ const budget = { initial: local.upstream_request_budget, remaining: local.upstream_request_budget };
8011
+ const result = await scanServerCatalog(client, config, input, local, { maxResults, maxPages: local.max_pages, budget, allowPartialTimeout });
8012
+ if (result.errors.length > 0)
8013
+ warnings.push("Program discovery reached its time budget; ranking covers only the completed server-ordered prefix.");
8014
+ if (result.scan.complete !== true) {
8015
+ warnings.push("More server pages remain; scan.next_cursor continues in the same order.");
8016
+ }
8017
+ const samplePrograms = Math.min(local.target_sample_programs, result.candidates.length, Math.max(0, budget.remaining));
8018
+ const targetSamples = local.include_target_samples && samplePrograms > 0
8019
+ ? await collectTargetSamples(client, result.candidates, { ...local, target_sample_programs: samplePrograms }, budget)
8020
+ : {};
8021
+ const outputMode = local.output_mode ?? "compact";
8022
+ return stripUndefined({
8023
+ request_id: randomUUID(),
8024
+ fetched_at: new Date().toISOString(),
8025
+ source_requests: result.sourceRequests,
8026
+ errors: result.errors.length > 0 ? result.errors : undefined,
8027
+ warnings: warnings.length > 0 ? warnings : undefined,
8028
+ upstream_budget: { used: budget.initial - budget.remaining, remaining: budget.remaining },
8029
+ ranking_scope: { mode: "server_filtered", sort_by: result.scan.sort_by, sort_direction: result.scan.sort_direction, complete: result.scan.complete },
8030
+ scan: result.scan,
8031
+ programs: result.candidates.map((candidate, index) => candidateOutput(candidate, index, targetSamples, outputMode)),
8032
+ meta: { returned: result.candidates.length, server_matches: result.scan.server_matches }
8033
+ });
8034
+ }
8035
+ function programHasNoSubmissionFee(program) {
8036
+ // Mirrors the API: no detected cost, or a detected zero with no maximum.
8037
+ const cost = readObject(program.submission_cost);
8038
+ const amount = numberField(cost, "amount");
8039
+ return amount === undefined || (amount === 0 && (numberField(cost, "maximum_amount") ?? 0) === 0);
8040
+ }
8041
+ async function findPrograms(client, config, input, execution = {}) {
7463
8042
  const normalized = normalizeFindProgramsInput(input);
7464
8043
  const warnings = normalized.warnings;
7465
8044
  input = normalized.input;
8045
+ const errors = [];
7466
8046
  const budget = {
7467
8047
  initial: input.upstream_request_budget,
7468
8048
  remaining: input.upstream_request_budget
@@ -7475,14 +8055,17 @@ async function findPrograms(client, config, input) {
7475
8055
  ...(input.min_wildcards > 0 ? ["wildcard"] : [])
7476
8056
  ]);
7477
8057
  const collected = await collectPrograms(client, {
8058
+ search: input.search,
7478
8059
  platforms: input.platforms,
7479
8060
  tags: upstreamTags,
7480
8061
  program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined),
8062
+ vdp_mode: execution.excludeVdpUpstream && input.vdp_mode === "exclude" ? "exclude" : undefined,
7481
8063
  updated_since: input.updated_since,
7482
8064
  opportunity_levels: input.opportunity_levels,
7483
8065
  max_pages: input.max_pages,
7484
8066
  budget,
7485
- warnings
8067
+ warnings,
8068
+ partialTimeoutErrors: execution.allowPartialTimeout ? errors : undefined
7486
8069
  });
7487
8070
  const filteredCandidates = collected.programs
7488
8071
  .map((program) => toProgramCandidate(program, config.webBaseUrl))
@@ -7506,6 +8089,7 @@ async function findPrograms(client, config, input) {
7506
8089
  source_requests: collected.sourceRequests,
7507
8090
  fetched_at: new Date().toISOString(),
7508
8091
  warnings: warnings.length > 0 ? warnings : undefined,
8092
+ errors: errors.length > 0 ? errors : undefined,
7509
8093
  filters: stripUndefined({
7510
8094
  platforms: input.platforms.length > 0 ? input.platforms : undefined,
7511
8095
  exclude_platforms: input.exclude_platforms && input.exclude_platforms.length > 0 ? input.exclude_platforms : undefined,
@@ -7534,6 +8118,8 @@ async function findPrograms(client, config, input) {
7534
8118
  min_total_targets: input.min_total_targets,
7535
8119
  min_added_24h: input.min_added_24h,
7536
8120
  min_added_7d: input.min_added_7d,
8121
+ free_submission_only: input.free_submission_only || undefined,
8122
+ competition_risks: input.competition_risks?.length ? input.competition_risks : undefined,
7537
8123
  sort_by: input.sort_by,
7538
8124
  output_mode: outputMode
7539
8125
  }),
@@ -7546,7 +8132,7 @@ async function findPrograms(client, config, input) {
7546
8132
  platform_filter: input.platforms.length > 0 ? input.platforms : "all_platforms",
7547
8133
  max_pages_scanned_per_source: input.max_pages,
7548
8134
  upstream_total_pages: collected.totalPagesBySource,
7549
- possibly_incomplete: isRankingPossiblyIncomplete(collected.totalPagesBySource, input.max_pages)
8135
+ possibly_incomplete: errors.length > 0 || isRankingPossiblyIncomplete(collected.totalPagesBySource, input.max_pages)
7550
8136
  },
7551
8137
  programs,
7552
8138
  meta: {
@@ -7951,20 +8537,39 @@ async function collectProgramSource(client, source, sourceIndex, options) {
7951
8537
  async function fetchProgramPage(client, source, sourceIndex, page, options) {
7952
8538
  const runWithSlot = options.runWithProgramPageSlot ?? runImmediately;
7953
8539
  return runWithSlot(async () => {
8540
+ const endpoint = source.kind === "programs" ? "programs" : "opportunities";
8541
+ const vdpModes = readArray(readObject(client.endpointCapability(endpoint)?.catalog_filters)?.vdp_mode);
7954
8542
  const query = toQuery({
7955
8543
  search: options.search,
7956
8544
  platforms: options.platforms,
7957
8545
  tags: options.tags,
7958
8546
  updated_since: options.updated_since,
7959
8547
  program_kind: source.kind === "programs" ? options.program_kind : undefined,
8548
+ // This removes only known VDPs; unknown classifications with positive
8549
+ // rewards still reach the local filters. Older APIs keep local exclusion.
8550
+ vdp_mode: supportsCatalogFilters(client, endpoint) && vdpModes.includes("exclude") ? options.vdp_mode : undefined,
7960
8551
  page,
7961
- page_size: MAX_PROGRAMS_PER_SEARCH
8552
+ page_size: Math.min(MAX_PROGRAMS_PER_SEARCH, client.endpointNumber(endpoint, "max_page_size", MAX_PROGRAMS_PER_SEARCH))
7962
8553
  });
7963
8554
  if (!tryConsumeBudget(options.budget)) {
7964
8555
  addUniqueWarning(options.warnings, "Upstream request budget was exhausted before all requested program pages could be fetched.");
7965
8556
  return undefined;
7966
8557
  }
7967
- const api = source.kind === "opportunity" ? await client.getOpportunities(source.level, query) : await client.listPrograms(query);
8558
+ let api;
8559
+ try {
8560
+ api = source.kind === "opportunity" ? await client.getOpportunities(source.level, query) : await client.listPrograms(query);
8561
+ }
8562
+ catch (error) {
8563
+ // Hunts can retain completed catalog pages. Ordinary discovery keeps its
8564
+ // existing failure contract, and a failed first page is never an empty hit.
8565
+ if (page === 1 || !options.partialTimeoutErrors || !(error instanceof BBRadarApiError) || error.status !== 408)
8566
+ throw error;
8567
+ if (error.cached || error.coalesced)
8568
+ options.budget.remaining += 1;
8569
+ options.partialTimeoutErrors.push(apiOperationError(`program catalog page ${page}`, error));
8570
+ addUniqueWarning(options.warnings, "Program discovery reached its time budget; ranking covers only completed catalog pages.");
8571
+ return undefined;
8572
+ }
7968
8573
  refundBudgetIfNoUpstreamRequest(options.budget, api);
7969
8574
  const data = readObject(api.data);
7970
8575
  const meta = readObject(data?.meta);
@@ -8046,7 +8651,7 @@ function programCandidateMatches(candidate, filters) {
8046
8651
  return false;
8047
8652
  }
8048
8653
  const programStatus = normalizeTag(stringField(candidate.program, "program_status") ?? "");
8049
- if (readBoolean(candidate.program.is_active) === false || programStatus === "paused") {
8654
+ if (!filters.skip_status_check && (readBoolean(candidate.program.is_active) === false || programStatus === "paused")) {
8050
8655
  return false;
8051
8656
  }
8052
8657
  if (filters.platforms.length > 0 && !filters.platforms.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
@@ -8139,6 +8744,15 @@ function programCandidateMatches(candidate, filters) {
8139
8744
  if (filters.min_added_7d !== undefined && (candidate.added7d ?? 0) < filters.min_added_7d) {
8140
8745
  return false;
8141
8746
  }
8747
+ if (filters.free_submission_only && !programHasNoSubmissionFee(candidate.program)) {
8748
+ return false;
8749
+ }
8750
+ if ((filters.competition_risks?.length ?? 0) > 0) {
8751
+ const risk = normalizeTag(stringField(candidate.program, "competition_risk") ?? "unknown");
8752
+ if (!filters.competition_risks.some((value) => normalizeTag(value) === risk)) {
8753
+ return false;
8754
+ }
8755
+ }
8142
8756
  return true;
8143
8757
  }
8144
8758
  function compareProgramCandidates(left, right, sortBy) {
@@ -8179,6 +8793,8 @@ function compareProgramCandidates(left, right, sortBy) {
8179
8793
  right.lastUpdatedTime - left.lastUpdatedTime);
8180
8794
  }
8181
8795
  function candidateMatchesVdpMode(candidate, vdpMode) {
8796
+ if (vdpMode === "only")
8797
+ return stringField(candidate.program, "program_type") === "vdp";
8182
8798
  if (vdpMode === "exclude") {
8183
8799
  return !candidateLooksLikeVdp(candidate);
8184
8800
  }
@@ -8775,6 +9391,7 @@ function compactToolText(payload, isError) {
8775
9391
  }));
8776
9392
  }
8777
9393
  const meta = readObject(payload.meta);
9394
+ const coverageIncomplete = readObject(payload.result_guidance)?.coverage_incomplete === true;
8778
9395
  const returned = numberField(meta, "returned") ??
8779
9396
  [
8780
9397
  readArray(payload.programs).length,
@@ -8786,6 +9403,12 @@ function compactToolText(payload, isError) {
8786
9403
  request_id: payload.request_id,
8787
9404
  summary: "Full tool result is available in structuredContent.",
8788
9405
  returned,
9406
+ partial_success: meta?.partial_success === true ? true : undefined,
9407
+ coverage_incomplete: coverageIncomplete ? true : undefined,
9408
+ errors: meta?.partial_success === true ? readArray(payload.errors).map((error) => {
9409
+ const value = readObject(error);
9410
+ return stripUndefined({ operation: value?.operation, status: value?.status });
9411
+ }) : undefined,
8789
9412
  mcp_timing: payload.mcp_timing
8790
9413
  }));
8791
9414
  }
@@ -8839,6 +9462,54 @@ function assertProFeature(client, endpoint, flag) {
8839
9462
  throw new Error(`${endpoint} is advertised but ${flag}=false on this API deployment.`);
8840
9463
  }
8841
9464
  }
9465
+ function assertActivityTabInputs(tab, args) {
9466
+ const allowed = new Set([...ACTIVITY_SHARED_PARAMS, ...ACTIVITY_TAB_PARAMS[tab]]);
9467
+ const misplaced = Object.entries(args)
9468
+ .filter(([key, value]) => !allowed.has(key) && value !== undefined && value !== false)
9469
+ .map(([key]) => key);
9470
+ if (misplaced.length > 0) {
9471
+ 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.`);
9472
+ }
9473
+ if (typeof args.period === "string" && !ACTIVITY_COUNT_TABS.has(tab) && !ACTIVITY_EVENT_PERIODS.includes(args.period)) {
9474
+ throw new Error(`tab=${tab} uses rolling 7d, 30d or 90d periods.`);
9475
+ }
9476
+ }
9477
+ /** One ranking page without the capabilities document the API repeats on every page. */
9478
+ function activityRankingPage(api, includeMap) {
9479
+ const page = proPage(api, "rows");
9480
+ const capabilities = readObject(page.capabilities);
9481
+ delete page.capabilities;
9482
+ // With no enabled default view the API answers sort=null and no rows; keep
9483
+ // only this tab's views so the caller can choose an explicit one.
9484
+ if (page.sort === null && readArray(page.rows).length === 0 && capabilities) {
9485
+ const views = readArray(readArray(capabilities.tabs).map(readObject).find((tab) => tab?.id === page.tab)?.views);
9486
+ page.available_views = views;
9487
+ }
9488
+ return omitMapPoints(page, "points", includeMap);
9489
+ }
9490
+ function omitMapPoints(payload, field, include) {
9491
+ if (include || !Array.isArray(payload[field]))
9492
+ return payload;
9493
+ const { [field]: points, ...rest } = payload;
9494
+ return { ...rest, map_points_omitted: points.length };
9495
+ }
9496
+ function assertActivitySnapshotSelector(client, endpoint, snapshot) {
9497
+ // Older deployments ignore unknown parameters silently; never let a binding
9498
+ // the caller relies on be dropped without saying so.
9499
+ if (snapshot === undefined || client.capabilitySnapshot().status !== "negotiated")
9500
+ return;
9501
+ if (client.endpointCapability(endpoint)?.snapshot_parameter !== "snapshot") {
9502
+ throw new Error(`The negotiated BBRadar API does not advertise Activity version binding for ${endpoint}. Omit snapshot or upgrade the API deployment.`);
9503
+ }
9504
+ }
9505
+ function assertActivityTierFilter(client, tiers) {
9506
+ // An API without the filter answers 422; say why locally and send nothing.
9507
+ if (!Array.isArray(tiers) || tiers.length === 0 || client.capabilitySnapshot().status !== "negotiated")
9508
+ return;
9509
+ if (!Array.isArray(client.endpointCapability("activity_rankings")?.opportunity_tiers)) {
9510
+ throw new Error("The negotiated BBRadar API does not advertise Activity opportunity tier filtering. Omit opportunity_tiers or upgrade the API deployment.");
9511
+ }
9512
+ }
8842
9513
  function assertHistoryCapture(client) {
8843
9514
  if (client.capabilitySnapshot().status === "negotiated" &&
8844
9515
  client.endpointCapability("history_changes")?.history_capture_enabled === false) {
@@ -8880,6 +9551,12 @@ function errorPayload(error, decision, toolName, durationMs) {
8880
9551
  };
8881
9552
  }
8882
9553
  function recoveryForGone(error, toolName) {
9554
+ if (toolName === "get_activity_change_detail") {
9555
+ 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." };
9556
+ }
9557
+ if (toolName.startsWith("get_activity_") || toolName === "get_program_payouts") {
9558
+ 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." };
9559
+ }
8883
9560
  const detail = typeof error.detail === "string" ? error.detail.toLowerCase() : error.message.toLowerCase();
8884
9561
  if (detail.includes("retained analytics") || detail.includes("precede")) {
8885
9562
  return { action: "choose_supported_interval", message: "Choose dates inside the retained analytics window." };