@bbradar/mcp 0.3.0 → 0.3.2

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,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { requiresActivityQuery } from "./activityContract.js";
2
+ import { requiresActivityQuery, validateActivityCatalogInput } from "./activityContract.js";
3
3
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { z } from "zod";
5
5
  import { BBRadarApiError } from "./bbradarClient.js";
@@ -7,7 +7,7 @@ import { NumericCursorStore } from "./cursorStore.js";
7
7
  import { stableStringify, stripUndefined } from "./json.js";
8
8
  import { FixedWindowRateLimiter } from "./rateLimit.js";
9
9
  import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeProPayload, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
10
- const SERVER_VERSION = "0.3.0";
10
+ const SERVER_VERSION = "0.3.2";
11
11
  const MAX_PROGRAMS_PER_SEARCH = 100;
12
12
  const MAX_TARGET_EXPORT = 500;
13
13
  const MAX_RECENT_CHANGES = 100;
@@ -46,7 +46,7 @@ const MAX_IN_MEMORY_FEED_CURSORS = 500;
46
46
  const MAX_RESOLVE_SEARCH_QUERIES = 4;
47
47
  const FAST_RESOLVE_SEARCH_QUERIES = 2;
48
48
  const MAX_DIRECT_HANDLE_RESOLUTION_REQUESTS = 96;
49
- const SDK_VERSION = "1.29.0";
49
+ const SDK_VERSION = "1.32.2";
50
50
  const CORE_TOOL_NAMES = new Set([
51
51
  "search_programs",
52
52
  "get_program",
@@ -257,11 +257,11 @@ const stableSnapshotSchema = z
257
257
  .string()
258
258
  .trim()
259
259
  .min(1)
260
- .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.")
261
261
  .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
262
262
  message: "Snapshot tokens cannot contain control characters."
263
263
  })
264
- .describe("Optional stable-page token. Send 'start' on page 1, then reuse meta.snapshot with identical filters. Snapshot creation is consistency-first and can be slower; omit it for the fastest live result.");
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.");
265
265
  const opaqueCursorSchema = z.string().min(1).max(4096).describe("Opaque five-minute cursor. Reuse with identical filters and page size; restart the query on expiry.");
266
266
  const analyticsDateSchema = () => z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use an inclusive UTC YYYY-MM-DD date.");
267
267
  const proLimitSchema = z.number().int().min(1).max(100);
@@ -294,7 +294,7 @@ const ACTIVITY_SHARED_PARAMS = ["tab", "sort", "direction", "platforms", "tags",
294
294
  // a request so it cannot be silently ignored or reinterpreted.
295
295
  const ACTIVITY_TAB_PARAMS = {
296
296
  payouts: ["period", "basis", "precision"],
297
- value: ["include_map"],
297
+ value: ["period", "include_map"],
298
298
  scope: ["period"],
299
299
  rewards: ["period"],
300
300
  updates: ["period", "category"],
@@ -309,7 +309,7 @@ const activityEventPeriodSchema = () => z.enum(["7d", "30d", "90d"]);
309
309
  const activityCatalogNumberSchema = () => z.number().finite().min(0).optional();
310
310
  const activityCatalogInput = {
311
311
  program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
312
- payout_metric: z.enum(["payout_total", "payout_increase", "payout_native_90d"]).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
313
  payout_period: activityCountPeriodSchema().optional(),
314
314
  payout_precision: z.enum(["exact", "proven_bounds"]).optional(),
315
315
  payout_min: activityCatalogNumberSchema(), payout_max: activityCatalogNumberSchema(),
@@ -322,7 +322,7 @@ const activityCatalogInput = {
322
322
  reward_change: z.enum(["increased", "decreased"]).optional(), reward_change_min_pct: activityCatalogNumberSchema(),
323
323
  updates_period: activityEventPeriodSchema().optional(),
324
324
  active_days_min: activityCatalogNumberSchema(), changes_min: activityCatalogNumberSchema(),
325
- change_category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
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
326
  reports_period: activityCountPeriodSchema().optional(),
327
327
  reports_min: activityCatalogNumberSchema(), reports_max: activityCatalogNumberSchema(),
328
328
  reports_change: z.enum(["rising", "falling"]).optional(), reports_change_min_pct: activityCatalogNumberSchema(),
@@ -336,7 +336,7 @@ const activityCatalogInput = {
336
336
  activity_labels: z.array(z.string().min(1).max(64)).max(10).optional(),
337
337
  sort_by: activityCatalogSortSchema.optional(), sort_direction: z.enum(["asc", "desc"]).optional()
338
338
  };
339
- const activityCatalogGuidance = "Give each Activity signal its period (e.g. payout_period=30d with payout_positive=true; scope_period with scope_added_min) and use sort_by/sort_direction combinations enabled in get_activity_capabilities; default is updated descending. Tracked no_increase/lower require 30d. Nothing falls back to another period or precision.";
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, and an omitted direction for first_response or time_to_bounty means ascending (fastest first). 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
340
  const freeSubmissionOnlySchema = z.boolean().default(false).describe("Only programs with no detected submission fee or deposit.");
341
341
  const programIdSchema = z
342
342
  .string()
@@ -348,6 +348,7 @@ const programIdSchema = z
348
348
  message: "program_id cannot contain control characters."
349
349
  })
350
350
  .describe("platform:handle id.");
351
+ const PROGRAM_IDENTIFIER_DESCRIPTION = "BBRadar platform:handle program_id or a human program name/handle. Names are resolved automatically.";
351
352
  const programIdentifierSchema = z
352
353
  .string()
353
354
  .trim()
@@ -356,9 +357,9 @@ const programIdentifierSchema = z
356
357
  .refine((value) => !/[\u0000-\u001F\u007F]/.test(value), {
357
358
  message: "Program identifiers cannot contain control characters."
358
359
  })
359
- .describe("BBRadar platform:handle program_id or a human program name/handle. Names are resolved automatically.");
360
+ .describe(PROGRAM_IDENTIFIER_DESCRIPTION);
360
361
  function jsonRecordSchema() {
361
- return z.record(z.unknown());
362
+ return z.record(z.string(), z.unknown());
362
363
  }
363
364
  function programOutputSchema() {
364
365
  return jsonRecordSchema();
@@ -380,6 +381,13 @@ function apiEnvelopeOutputShape() {
380
381
  error: jsonRecordSchema().optional()
381
382
  };
382
383
  }
384
+ // Tools that return one Pro API payload as it is. The API adds response fields
385
+ // between MCP releases, and clients validate each structured result against the
386
+ // discovered schema: listed fields document the contract, while a new top-level
387
+ // field validates instead of failing every call (the API added Status facets on 2026-10-13).
388
+ function apiMirrorOutput(shape) {
389
+ return z.object(shape).passthrough();
390
+ }
383
391
  function programListOutputShape() {
384
392
  return {
385
393
  ...apiEnvelopeOutputShape(),
@@ -444,7 +452,13 @@ function createProfiledToolRegistrar(server, profile, client) {
444
452
  const extra = args[args.length - 1];
445
453
  return client.withRequestContext(extra.signal, () => callback(...args), readObject(args[0])?.snapshot === "start");
446
454
  });
447
- const registeredTool = server.registerTool(name, toolConfig, scopedCallback);
455
+ // Raw SDK shapes otherwise strip unknown arguments. Every advertised
456
+ // filter must be applied or rejected, including on the older shortcuts.
457
+ const inputSchema = toolConfig.inputSchema;
458
+ const strictConfig = inputSchema && !(inputSchema instanceof z.ZodType)
459
+ ? { ...toolConfig, inputSchema: z.object(inputSchema).strict() }
460
+ : toolConfig;
461
+ const registeredTool = server.registerTool(name, strictConfig, scopedCallback);
448
462
  const requiredEndpoint = TOOL_ENDPOINT_REQUIREMENTS[name];
449
463
  if ((profile === "core" && !CORE_TOOL_NAMES.has(name)) ||
450
464
  (requiredEndpoint !== undefined && !client.supportsEndpoint(requiredEndpoint))) {
@@ -462,7 +476,8 @@ export function createBbradarServer(client, config) {
462
476
  "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.",
463
477
  "Use find_programs for lower-level program-only discovery and find_programs_by_target for reverse target lookup.",
464
478
  "For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities, then get_activity_rankings with the matching tab for one ranking. For several conditions at once (e.g. recent payouts plus new scope, Web2-only, low competition, no submission fee) use one find_hunt_targets or find_programs call with Activity inputs and local filters together. Keep periods explicit; never intersect separately paginated rankings.",
465
- "Activity data is published in versions. get_activity_snapshot reports the current version; pass its snapshot_id as snapshot to bind capabilities and first pages to one version, and never mix rows from different versions.",
479
+ "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.",
480
+ "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.",
466
481
  "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.",
467
482
  "Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
468
483
  "Prefer compact/identifier outputs and disclose sampled ranking scope when present."
@@ -474,7 +489,8 @@ export function createBbradarServer(client, config) {
474
489
  "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.",
475
490
  "Use advanced tools only after the user's goal is clear or the guide points to them.",
476
491
  "For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities. Use get_activity_rankings (or its per-view alias) for one ranking, or one find_hunt_targets/find_programs call combining Activity inputs with Web2, scope, competition and fee filters. Keep server order and explicit periods; never re-rank a sampled catalog or intersect separate pages.",
477
- "Activity data is published in versions. get_activity_snapshot reports the current version; pass its snapshot_id as snapshot to bind capabilities and first pages to one version, and never mix rows from different versions.",
492
+ "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.",
493
+ "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.",
478
494
  "Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
479
495
  "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.",
480
496
  "Recently updated programs across platforms: find_recently_updated_programs. Do not infer a platform such as Immunefi unless the user explicitly names it.",
@@ -509,8 +525,8 @@ export function createBbradarServer(client, config) {
509
525
  const repositoryChangesPageSizeSchema = z.number().int().min(1).max(maxRepositoryChangesPageSize).default(Math.min(50, maxRepositoryChangesPageSize));
510
526
  const acceptedTargetLimitSchema = z.number().int().min(1).max(maxProgramTargetsPageSize);
511
527
  const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
512
- const historyOutput = { ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() };
513
- const analyticsOutput = { ...apiEnvelopeOutputShape(), platform: z.string().optional(), program: jsonRecordSchema().optional(), programs: z.array(jsonRecordSchema()).optional(), series: z.array(jsonRecordSchema()).optional(), observations: jsonRecordSchema().optional(), category: z.string().optional(), population: z.string().optional(), ranking_definition: z.string().optional(), inventory_definition: z.string().optional(), meta: jsonRecordSchema().optional() };
528
+ const historyOutput = apiMirrorOutput({ ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() });
529
+ const analyticsOutput = apiMirrorOutput({ ...apiEnvelopeOutputShape(), platform: z.string().optional(), program: jsonRecordSchema().optional(), programs: z.array(jsonRecordSchema()).optional(), series: z.array(jsonRecordSchema()).optional(), observations: jsonRecordSchema().optional(), category: z.string().optional(), population: z.string().optional(), ranking_definition: z.string().optional(), inventory_definition: z.string().optional(), meta: jsonRecordSchema().optional() });
514
530
  // Published Activity versions are 64 lowercase hex characters. "start" and
515
531
  // catalog snapshot tokens are different contracts and are rejected here.
516
532
  const activitySnapshotSchema = z.string().regex(/^[0-9a-f]{64}$/, "Use a snapshot_id returned by Activity.")
@@ -529,7 +545,7 @@ export function createBbradarServer(client, config) {
529
545
  program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
530
546
  search: z.string().min(1).max(200).optional(),
531
547
  current_status: z.enum(["active", "paused", "suspended", "disabled", "closed", "unavailable", "unknown"]).optional(),
532
- labels: z.array(z.string().min(1).max(64)).max(10).optional().describe("Only labels listed by get_activity_capabilities; others return 422."),
548
+ labels: z.array(z.string().min(1).max(64)).max(10).optional().describe("Only labels listed by get_activity_capabilities (for tab=value, the chosen period view's labels); others return 422."),
533
549
  opportunity_tiers: z.array(opportunityLevelSchema).min(1).max(4).optional().describe("Any of these catalog opportunity tiers, as frozen into the Activity version. get_activity_capabilities lists the tiers present in facets.opportunity_tiers; an API without the filter is rejected before any request."),
534
550
  launched_within: z.enum(["7d", "30d", "90d"]).optional(),
535
551
  category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
@@ -539,19 +555,23 @@ export function createBbradarServer(client, config) {
539
555
  cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(25),
540
556
  snapshot: activitySnapshotSchema.optional()
541
557
  };
542
- const activityOutput = {
558
+ const activityOutput = apiMirrorOutput({
543
559
  ...apiEnvelopeOutputShape(), rows: z.array(jsonRecordSchema()).optional(), points: z.array(jsonRecordSchema()).optional(),
544
560
  tab: z.string().optional(), period: z.string().nullable().optional(),
545
561
  basis: z.string().nullable().optional(), precision: z.string().nullable().optional(),
546
562
  sort: z.string().nullable().optional(), direction: z.string().optional(), meta: jsonRecordSchema().optional(),
547
563
  cohort_median: jsonRecordSchema().nullable().optional(), available_views: z.array(jsonRecordSchema()).optional(),
548
- map_points_omitted: z.number().int().optional()
549
- };
564
+ map_points_omitted: z.number().int().optional(),
565
+ // tab=value: the label shortlist applied, including a default landing list (empty = the map's programs).
566
+ labels: z.array(z.string()).optional(),
567
+ // tab=status: event counts per change_type and current_status, each under the other filters.
568
+ facets: jsonRecordSchema().optional()
569
+ });
550
570
  registerTool("get_activity_capabilities", {
551
571
  title: "Get Available Activity Views",
552
572
  description: "Read the exact enabled period, sort, direction, basis and precision combinations before choosing an Activity ranking. Availability is evaluated before user filters; do not infer combinations from independent enums. Honor each view's sort_directions and default_direction and the enabled label list. The snapshot block identifies the published version these capabilities belong to.",
553
573
  inputSchema: z.object({ snapshot: activitySnapshotSchema.optional() }).strict(),
554
- outputSchema: { ...apiEnvelopeOutputShape(), tabs: z.array(jsonRecordSchema()).optional(), version: z.union([z.string(), z.number()]).optional(), facets: jsonRecordSchema().optional(), labels: z.array(z.string()).optional(), listing: jsonRecordSchema().optional(), listing_payouts_enabled: z.boolean().optional(), derived_signals_enabled: z.boolean().optional(), snapshot: jsonRecordSchema().optional() },
574
+ outputSchema: apiMirrorOutput({ ...apiEnvelopeOutputShape(), tabs: z.array(jsonRecordSchema()).optional(), version: z.union([z.string(), z.number()]).optional(), facets: jsonRecordSchema().optional(), labels: z.array(z.string()).optional(), listing: jsonRecordSchema().optional(), listing_payouts_enabled: z.boolean().optional(), derived_signals_enabled: z.boolean().optional(), snapshot: jsonRecordSchema().optional() }),
555
575
  annotations: readOnlyAnnotations
556
576
  }, (args) => runTool("get_activity_capabilities", config, rateLimiter, async () => {
557
577
  assertProFeature(client, "activity_capabilities", "enabled");
@@ -560,9 +580,9 @@ export function createBbradarServer(client, config) {
560
580
  }));
561
581
  registerTool("get_activity_snapshot", {
562
582
  title: "Get Current Activity Version",
563
- description: "Read only the identity of the currently published Activity version: snapshot_id, frozen as_of cutoff, expires_at, policy_version and suggested poll_seconds. Use it to detect newer Activity data and pass snapshot_id as snapshot to bind capabilities, first pages and payouts to one version. as_of is when BBRadar froze its calculation, not a provider payment or update time.",
583
+ 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.",
564
584
  inputSchema: z.object({}).strict(),
565
- outputSchema: { ...apiEnvelopeOutputShape(), snapshot_id: z.string().optional(), as_of: z.string().optional(), expires_at: z.string().optional(), policy_version: z.string().optional(), poll_seconds: z.number().optional() },
585
+ outputSchema: apiMirrorOutput({ ...apiEnvelopeOutputShape(), snapshot_id: z.string().optional(), as_of: z.string().optional(), expires_at: z.string().optional(), policy_version: z.string().optional(), poll_seconds: z.number().optional() }),
566
586
  annotations: readOnlyAnnotations
567
587
  }, () => runTool("get_activity_snapshot", config, rateLimiter, async () => {
568
588
  assertProFeature(client, "activity_snapshot", "enabled");
@@ -575,11 +595,11 @@ export function createBbradarServer(client, config) {
575
595
  }));
576
596
  registerTool("get_program_payouts", {
577
597
  title: "Get Program Payouts and Response Facts",
578
- description: "Read reported totals, measured counter increases, source dates, precision, report and response facts, derived signals and label explanations for one exact platform:handle. Keep null separate from zero, bounds separate from exact amounts and unknown payment dates null. Payout timestamps are when increases were seen, never payment dates. No provider requests are made.",
598
+ description: "Read reported totals, measured counter increases, source dates, precision, report and response facts, derived signals and label explanations for one exact platform:handle. facts also carry each platform's own published figures (paid_average, paid_maximum, last_paid_on, HackerOne per-severity averages and report counts, triage/resolution times, response rate, researcher counts), each with its measure, unit, period and source_label; keep each definition and never compare them across platforms as one measure. 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. value_points (include_map) are same-platform peers in the Value period named by value_period. No provider requests are made.",
579
599
  inputSchema: z.object({ program_id: programIdSchema, snapshot: activitySnapshotSchema.optional(), include_map: includeMapSchema }).strict(),
580
- // Mirrors the API response, including its version identity: a client that
600
+ // Lists the API response fields, including its version identity: a client that
581
601
  // discovered this schema validates every structured result against it.
582
- outputSchema: { ...apiEnvelopeOutputShape(), program_id: z.string().optional(), program: jsonRecordSchema().optional(), summary: jsonRecordSchema().optional(), as_of: z.string().optional(), snapshot_id: z.string().optional(), expires_at: z.string().nullable().optional(), facts: z.array(jsonRecordSchema()).optional(), windows: jsonRecordSchema().optional(), series: z.array(jsonRecordSchema()).optional(), payout_summary: jsonRecordSchema().nullable().optional(), views: z.array(jsonRecordSchema()).optional(), reports: jsonRecordSchema().optional(), response: jsonRecordSchema().optional(), last_increase_at: z.string().nullable().optional(), signals: jsonRecordSchema().optional(), label_details: z.array(jsonRecordSchema()).optional(), labels: z.array(z.string()).optional(), value_points: z.array(jsonRecordSchema()).optional(), map_points_omitted: z.number().int().optional(), meta: jsonRecordSchema().optional() },
602
+ outputSchema: apiMirrorOutput({ ...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(), value_period: z.string().nullable().optional(), map_points_omitted: z.number().int().optional(), meta: jsonRecordSchema().optional() }),
583
603
  annotations: readOnlyAnnotations
584
604
  }, (args) => runTool("get_program_payouts", config, rateLimiter, async () => {
585
605
  assertProFeature(client, "program_payouts", "enabled");
@@ -598,7 +618,7 @@ export function createBbradarServer(client, config) {
598
618
  };
599
619
  registerTool("get_activity_rankings", {
600
620
  title: "Get Activity Ranking",
601
- description: "One globally ranked server page of an Activity view, frozen at meta.as_of. Read get_activity_capabilities first: explicit period/basis/precision/sort/direction never fall back, and each tab only accepts its own inputs. tab: payouts (reported and tracked bases stay separate; proven_bounds desc = highest reported minimum, asc = lowest reported maximum, never an exact ranking; source totals can lag); value (fixed lifetime generosity and 90-day volume percentiles, no period; one label gives its shortlist); scope (recorded additions/removals/net, not inventory differences); rewards (net reward-range change, unknown % stays null); updates (active UTC days, change groups); reports (received counts; volume is not competition; all_time is the lifetime counter); response (resolved counts, last resolution, response time; time to bounty is award timing); status (first_seen/paused/resumed/left_listing/returned events); all_changes (chronological events; get_activity_change_detail for before/after). Event tabs use rolling 7d/30d/90d, not calendar months. launched_within applies to reports/response. Payout increases are observed counters, not payment dates. Reuse cursors only with identical inputs. For several signals at once, or with Web2/scope/competition/fee filters, use find_programs or find_hunt_targets.",
621
+ 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; average_paid is the platform's own current average payment, a range for HackerOne); value (advertised period 7d/30d/90d of report volume against lifetime payout per processed report; each row's value holds that period's count and percentile; Pays near top, No payouts seen and processed_received are 90d only; without period, labels or sort mean 90d and a bare read opens the advertised default, whose shortlist the page's labels name; one label gives its shortlist); scope (recorded additions/removals/net, not inventory differences); rewards (net reward-range change comparing native maxima, unknown % stays null; minimum_usd/maximum_usd are display conversions); 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 with each provider's own label and window; time to bounty is award timing; durations default to fastest first); status (first_seen/paused/resumed/left_listing/returned events; facets count events per change_type and current_status); all_changes (chronological events; get_activity_change_detail for before/after). Event tabs use rolling 7d/30d/90d, not calendar months. launched_within applies to reports/response. Payout increases are observed counters, not payment dates. Reuse cursors only with identical inputs. For several signals at once, or with Web2/scope/competition/fee filters, use find_programs or find_hunt_targets.",
602
622
  inputSchema: z.object(activityParams).strict(),
603
623
  outputSchema: activityOutput,
604
624
  annotations: readOnlyAnnotations
@@ -609,14 +629,14 @@ export function createBbradarServer(client, config) {
609
629
  // Single-view aliases for the full profile; each schema exposes only the
610
630
  // inputs its view accepts and shares the ranking tool's request path.
611
631
  const activityDescriptions = {
612
- value: "Discover programs with frozen within-platform value and report-volume percentiles. Generosity is lifetime; volume is 90 days. No period or launch-cohort input applies. Use a label-specific shortlist: Pays near top orders by realization, No payouts seen by received_90d (raw count with its basis; small cohorts remain eligible). Comparative ratio sorts use server percentiles; never recompute after filtering. Preserve label_details explanations and destinations. A counter increase is observed activity, not an individual payment. Map points are returned only with include_map.",
613
- payouts: "Rank payouts using the same server calculation as BBRadar. Last 7 days and last 30 days are rolling windows. Explicit periods never fall back. For high or low payouts lately, use the same enabled tracked-30-day cohort, or separately label a native-90-day cohort. Reported and tracked bases stay separate. With proven_bounds, descending is Highest reported minimum using lower bounds; ascending is Lowest reported maximum using upper bounds. Neither claims an exact ranking. Source totals can be delayed, including Immunefi.",
632
+ value: "Discover programs with frozen within-platform value and report-volume percentiles. Generosity is lifetime payout per processed report; volume covers the selected period: 7d, 30d or 90d, each only when get_activity_capabilities advertises it. Each row's value holds that period's received count, basis and percentile; signals keep the lifetime and 90-day figures. Without period, naming labels or sort means 90d, and a bare read opens the advertised default, which may be a shortlist named in the page's labels. No launch-cohort input applies. High value, Popular and Low per report exist per period; Pays near top (orders by realization), No payouts seen (orders by received_90d, raw count with its basis; small cohorts remain eligible) and processed_received are 90d only. 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.",
633
+ 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. Rows also carry average_paid, the platform's own current average payment with its source_label, period, native currency and USD conversion (HackerOne publishes a 45th-55th percentile range); it is the same in every period, and sort=average_paid ranks it where advertised.",
614
634
  scope: "Rank recorded scope additions, removals, net movement or largest update; not differences between inventory totals. Periods are rolling 7, 30 or 90 days, not calendar months.",
615
- rewards: "Rank comparable net reward-range changes over rolling 7, 30 or 90 days. Unknown change percentages remain null.",
635
+ rewards: "Rank comparable net reward-range changes over rolling 7, 30 or 90 days. The change percentage compares native maxima; unknown percentages remain null. before/after keep native amounts and add minimum_usd/maximum_usd at the version's current quotes for display (null without a quote).",
616
636
  updates: "Rank active UTC days and recorded change groups over rolling periods. Baselines and internal corrections are excluded.",
617
637
  reports: "Rank received report counts. Each program uses measured lifetime-counter growth where available, otherwise its supported native period. All-time is the current lifetime counter. Previous and change compare like-for-like. Report volume is not competition. Launch cohorts use the canonical launch date; amounts are not reports since launch.",
618
- response: "Rank resolved counter growth or current lifetime counters, last resolution, response duration or time to bounty. Keep the provider's definition: time to bounty is award timing, not payment settlement. Period applies only to resolved counts.",
619
- status: "Read distinct First seen, Paused, Resumed, Left listing and Returned events. Event type and current status are separate filters; never merge events from one program.",
638
+ response: "Rank resolved counter growth or current lifetime counters, last resolution, response duration or time to bounty. Each cell keeps its provider's definition, label and window (for example HackerOne first response over 90 days, YesWeHack's 6-month band, Bugcrowd's 75% validated time); first_response_precision says whether a time is exact, a band bound or approximate. Time to bounty is award timing, not payment settlement. Durations sort fastest first unless a direction is given. Period applies only to resolved counts; use only advertised periods.",
639
+ 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. facets count events per change_type and current_status for the period and filters, each counted without its own selection; offer only options with counts.",
620
640
  all_changes: "Read chronological semantic program changes with event IDs. Fetch get_activity_change_detail lazily for before/after items, including eligible events for programs that left the catalog. Separate events stay separate."
621
641
  };
622
642
  for (const tab of ACTIVITY_TABS) {
@@ -635,7 +655,7 @@ export function createBbradarServer(client, config) {
635
655
  title: "Get Activity Change Detail",
636
656
  description: "Read one bounded page of recorded before/after items for an exact Activity event ID in its source snapshot. Pass meta.snapshot_id from the Activity page. Retained eligible events may describe programs that have left the catalog. Reuse an opaque cursor only with the same event, snapshot and limit.",
637
657
  inputSchema: z.object({ event_id: z.string().min(1).max(128), snapshot: activitySnapshotSchema.describe("meta.snapshot_id from the Activity page that supplied this event."), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) }).strict(),
638
- outputSchema: { ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
658
+ outputSchema: apiMirrorOutput({ ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }), annotations: readOnlyAnnotations
639
659
  }, (args) => runTool("get_activity_change_detail", config, rateLimiter, async () => {
640
660
  assertProFeature(client, "activity_event_detail", "enabled");
641
661
  return proPage(await client.getActivityChangeDetail(args.event_id, toQuery({ snapshot: args.snapshot, cursor: args.cursor, limit: args.limit })), "items");
@@ -660,7 +680,7 @@ export function createBbradarServer(client, config) {
660
680
  title: "Get History Change Detail",
661
681
  description: "Fetch one semantic History change's paginated before/after items only when needed.",
662
682
  inputSchema: { change_id: z.string().min(1).max(128), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) },
663
- outputSchema: { ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
683
+ outputSchema: apiMirrorOutput({ ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }), annotations: readOnlyAnnotations
664
684
  }, (args) => runTool("get_history_change_detail", config, rateLimiter, async () => {
665
685
  assertProFeature(client, "history_change_detail");
666
686
  assertHistoryCapture(client);
@@ -680,7 +700,7 @@ export function createBbradarServer(client, config) {
680
700
  title: "Get Exact Program Activity",
681
701
  description: "Read the authoritative activity_summary for one exact program ID.",
682
702
  inputSchema: { program_id: programIdSchema },
683
- outputSchema: { ...apiEnvelopeOutputShape(), program_id: z.string().optional(), activity_summary: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
703
+ outputSchema: apiMirrorOutput({ ...apiEnvelopeOutputShape(), program_id: z.string().optional(), activity_summary: jsonRecordSchema().optional() }), annotations: readOnlyAnnotations
684
704
  }, (args) => runTool("get_program_activity", config, rateLimiter, async () => {
685
705
  assertProFeature(client, "program_activity");
686
706
  return proPage(await client.getProgramActivity(args.program_id), "activity_summary");
@@ -787,7 +807,7 @@ export function createBbradarServer(client, config) {
787
807
  }));
788
808
  registerTool("get_program", {
789
809
  title: "Get BBRadar Program",
790
- description: "Get one program by platform:handle program_id, handle, or human program name. Names are resolved automatically when unambiguous.",
810
+ description: "Get one program by platform:handle program_id, handle, or human program name. Names are resolved automatically when unambiguous. A contest or Huntr challenge adds contest_awards: its final published award allocations (awarded_total and pool_amount as decimal strings in the contest's own currency token; currency null when the platform names none, as Huntr; never USD or a payment record), or null before a final result. A finished contest with a final result stays readable here by exact platform:handle, without an opportunity tag, although discovery and name resolution exclude ended contests.",
791
811
  inputSchema: {
792
812
  program_id: programIdentifierSchema
793
813
  },
@@ -918,6 +938,7 @@ export function createBbradarServer(client, config) {
918
938
  program_id: programIdentifierSchema,
919
939
  search: searchTextSchema.optional(),
920
940
  target_type: filterValueSchema().optional(),
941
+ language_tags: languageTagListSchema().describe("Keep targets with any requested language tag. Applied while paging; repeat with the returned next_offset."),
921
942
  min_target_score: z.number().int().min(0).max(100).optional(),
922
943
  target_opportunity_label: targetOpportunityLabelSchema.optional(),
923
944
  competition_risk: competitionRiskSchema.optional(),
@@ -1195,7 +1216,7 @@ export function createBbradarServer(client, config) {
1195
1216
  include_ineligible: z.boolean().default(false).describe("Include ineligible."),
1196
1217
  recent_changes_page_size: recentChangesPageSizeSchema.default(Math.min(MAX_RECENT_CHANGES, maxTargetChangesPageSize)),
1197
1218
  max_results: z.number().int().min(1).max(MAX_RECENT_CHANGES).default(10),
1198
- 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."),
1219
+ 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."),
1199
1220
  full_target_list_mode: targetListModeSchema.default("identifiers"),
1200
1221
  full_target_limit: z.number().int().min(1).max(MAX_TARGETS_PER_PROGRAM).default(MAX_TARGETS_PER_PROGRAM),
1201
1222
  full_list_include_out_of_scope: z.boolean().default(false),
@@ -1431,7 +1452,8 @@ export function createBbradarServer(client, config) {
1431
1452
  min_added_7d: z.number().int().min(0).optional(),
1432
1453
  max_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1433
1454
  max_results: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(10),
1434
- sort_by: z.union([programSortSchema.removeDefault(), activityCatalogSortSchema]).optional(),
1455
+ // One flat enum, as clients have always seen it; a union would advertise anyOf.
1456
+ sort_by: z.enum([...programSortSchema.removeDefault().options, ...activityCatalogSortSchema.options]).optional(),
1435
1457
  include_target_samples: z.boolean().default(false),
1436
1458
  target_sample_programs: z.number().int().min(0).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1437
1459
  target_sample_size: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(5),
@@ -2389,6 +2411,7 @@ function bbradarGuide(topic, profile, client) {
2389
2411
  { user_goal: "program payouts, recent reports, response times or Activity rankings", first_tool: "get_activity_capabilities", arguments: {}, then: "Choose an enabled period, sort and direction, then call get_activity_rankings with that tab for one server-ranked page. Do not approximate by paging or re-ranking the catalog." },
2390
2412
  { user_goal: "programs with recent payouts and new scope that also match Web2, competition or fee criteria", first_tool: "find_hunt_targets", arguments: { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", free_submission_only: true, ranking_mode: "lowest_competition" }, then: "Use find_programs with the same Activity inputs and competition_risks for program-only results; continue with scan.next_cursor." },
2391
2413
  { user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
2414
+ { user_goal: "final award results of a finished contest or Huntr challenge", first_tool: "get_program", arguments: { program_id: "Platform:handle" }, then: "Read contest_awards: awarded_total and pool_amount are decimal strings in the contest's own token (currency null when the platform names none); null means no final result yet." },
2392
2415
  { user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch get_history_change_detail only for a selected change." },
2393
2416
  { user_goal: "most active programs in a date range", first_tool: "get_analytics_program_activity", arguments: {}, then: "Use this server-side ranking rather than sampling the program catalog." },
2394
2417
  { user_goal: "exact recorded activity for one program", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
@@ -2502,6 +2525,7 @@ function bbradarGuide(topic, profile, client) {
2502
2525
  decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
2503
2526
  (topic === "start" || guideEntryMatchesTopic(topic, entry))),
2504
2527
  filters,
2528
+ activity_catalog: activityCatalogGuidance,
2505
2529
  advanced_tools: advancedTools,
2506
2530
  examples
2507
2531
  };
@@ -2537,6 +2561,7 @@ function coreBbradarGuide(topic, client) {
2537
2561
  { user_goal: "program payouts, recent reports, response times or Activity rankings", first_tool: "get_activity_capabilities", arguments: {}, then: "Read supported view combinations and call get_activity_rankings with that tab. Use get_activity_change_detail lazily for a selected Activity event." },
2538
2562
  { user_goal: "programs with recent payouts and new scope that also match Web2, competition or fee criteria", first_tool: "find_hunt_targets", arguments: { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", free_submission_only: true, ranking_mode: "lowest_competition" } },
2539
2563
  { user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
2564
+ { user_goal: "final award results of a finished contest or Huntr challenge", first_tool: "get_program", arguments: { program_id: "Platform:handle" }, then: "Read contest_awards in the contest's own token; null means no final result yet." },
2540
2565
  { user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch change detail only when needed." },
2541
2566
  { user_goal: "most active programs", first_tool: "get_analytics_program_activity", arguments: {} },
2542
2567
  { user_goal: "exact recorded activity", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
@@ -2580,7 +2605,8 @@ function coreBbradarGuide(topic, client) {
2580
2605
  ],
2581
2606
  decision_tree: decisionTree.filter((entry) => toolAvailableForContract(client, String(entry.first_tool)) &&
2582
2607
  (topic === "start" || guideEntryMatchesTopic(topic, entry))),
2583
- available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name))
2608
+ available_tools: [...CORE_TOOL_NAMES].filter((name) => toolAvailableForContract(client, name)),
2609
+ activity_catalog: activityCatalogGuidance
2584
2610
  };
2585
2611
  }
2586
2612
  function toolAvailableForContract(client, toolName) {
@@ -3590,29 +3616,14 @@ async function runResolvedProgramAction(client, config, programId, input) {
3590
3616
  });
3591
3617
  }
3592
3618
  if (input.action === "targets") {
3593
- const api = await client.getProgramTargets(programId);
3594
- const data = readObject(api.data);
3595
- const rawTargets = readArray(data?.targets);
3596
- const sanitizedTargets = rawTargets
3597
- .map(sanitizeTarget)
3598
- .filter((target) => targetHasAllowedScope(target, {
3619
+ return getProgramTargetsPayload(client, {
3620
+ target_type: input.target_type,
3621
+ language_tags: input.language_tags,
3599
3622
  include_out_of_scope: input.include_out_of_scope,
3600
3623
  include_ineligible: input.include_ineligible,
3601
- strict_scope_filter: true
3602
- }));
3603
- const targets = sanitizedTargets.slice(0, input.max_targets);
3604
- return withApiMetadata(api, {
3605
- program_id: programId,
3606
- targets: formatTargetList(targets, input.target_list_mode),
3607
- meta: {
3608
- limit: input.max_targets,
3609
- output_mode: input.target_list_mode,
3610
- total_active_targets: rawTargets.length,
3611
- total_after_filters: sanitizedTargets.length,
3612
- returned: targets.length,
3613
- has_more: sanitizedTargets.length > targets.length
3614
- }
3615
- });
3624
+ strict_scope_filter: true,
3625
+ offset: 0, limit: input.max_targets, output_mode: input.target_list_mode
3626
+ }, programId);
3616
3627
  }
3617
3628
  return checkProgramNewTargets(client, config, {
3618
3629
  program_id: programId,
@@ -4738,30 +4749,18 @@ async function getLatestAddedTargets(client, config, input) {
4738
4749
  .map((change) => readObject(change.target))
4739
4750
  .filter((target) => target !== undefined);
4740
4751
  let fullTargets = [];
4741
- let totalActiveTargets;
4742
- let totalAfterFilters;
4752
+ let fullTargetsMeta;
4743
4753
  if (input.include_full_target_list && programId) {
4744
- const targetsApi = await client.getProgramTargets(programId);
4745
- const targetsData = readObject(targetsApi.data);
4746
- const rawTargets = readArray(targetsData?.targets);
4747
- fullTargets = rawTargets
4748
- .map(sanitizeTarget)
4749
- .filter((target) => targetHasAllowedScope(target, {
4754
+ const payload = await getProgramTargetsPayload(client, {
4750
4755
  include_out_of_scope: input.full_list_include_out_of_scope,
4751
4756
  include_ineligible: input.full_list_include_ineligible,
4752
- strict_scope_filter: false
4753
- }));
4754
- totalActiveTargets = rawTargets.length;
4755
- totalAfterFilters = fullTargets.length;
4756
- sourceRequests.push({
4757
- source: "program_targets",
4758
- program_id: programId,
4759
- request_id: targetsApi.requestId,
4760
- upstream_request_id: targetsApi.upstreamRequestId,
4761
- ...apiSourceMetadata(targetsApi)
4762
- });
4757
+ strict_scope_filter: false,
4758
+ offset: 0, limit: input.full_target_limit, output_mode: input.full_target_list_mode
4759
+ }, programId);
4760
+ fullTargets = readArray(payload.targets);
4761
+ fullTargetsMeta = readObject(payload.meta);
4762
+ sourceRequests.push(...readArray(payload.source_requests).map(readObject).filter((request) => request !== undefined));
4763
4763
  }
4764
- const limitedFullTargets = fullTargets.slice(0, input.full_target_limit);
4765
4764
  return stripUndefined({
4766
4765
  request_id: randomUUID(),
4767
4766
  source_requests: sourceRequests,
@@ -4770,17 +4769,19 @@ async function getLatestAddedTargets(client, config, input) {
4770
4769
  program,
4771
4770
  matches,
4772
4771
  new_targets: formatTargetList(latestNewTargets, input.full_target_list_mode),
4773
- full_targets: input.include_full_target_list ? formatTargetList(limitedFullTargets, input.full_target_list_mode) : undefined,
4772
+ full_targets: input.include_full_target_list ? fullTargets : undefined,
4774
4773
  meta: stripUndefined({
4775
4774
  recent_changes_scanned: changes.length,
4776
4775
  matching_changes_scanned: matchingChanges.length,
4777
4776
  matches_returned: matches.length,
4778
4777
  matches_has_more: matches.length < matchingChanges.length,
4779
4778
  new_targets_returned: formatTargetList(latestNewTargets, input.full_target_list_mode).length,
4780
- full_targets_total_active: totalActiveTargets,
4781
- full_targets_total_after_filters: totalAfterFilters,
4782
- full_targets_returned: input.include_full_target_list ? formatTargetList(limitedFullTargets, input.full_target_list_mode).length : undefined,
4783
- full_targets_has_more: input.include_full_target_list ? limitedFullTargets.length < fullTargets.length : undefined
4779
+ full_targets_total_active: fullTargetsMeta?.total_active_targets,
4780
+ full_targets_total_after_filters: fullTargetsMeta?.total_after_filters,
4781
+ full_targets_returned: input.include_full_target_list ? fullTargets.length : undefined,
4782
+ full_targets_has_more: fullTargetsMeta?.has_more,
4783
+ full_targets_next_offset: fullTargetsMeta?.next_offset,
4784
+ full_targets_possibly_incomplete: fullTargetsMeta?.possibly_incomplete
4784
4785
  })
4785
4786
  });
4786
4787
  }
@@ -4818,15 +4819,21 @@ function latestAddedProgramMatches(change, input, platformFilters) {
4818
4819
  return true;
4819
4820
  }
4820
4821
  async function getProgramTargetsPayload(client, input, programId) {
4821
- const pageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
4822
+ const maxPageSize = client.endpointNumber("program_targets", "max_page_size", MAX_TARGETS_PER_PROGRAM);
4822
4823
  // `eligibility=eligible` is an API-level in-scope bounty-eligibility filter,
4823
4824
  // so the common eligible-only path needs no second local scope pass.
4824
- const directOffsetPaging = !input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope);
4825
+ const languageTags = new Set((input.language_tags ?? []).map(normalizeTag));
4826
+ const directOffsetPaging = languageTags.size === 0 && (!input.include_ineligible || (!input.strict_scope_filter && input.include_out_of_scope));
4827
+ // Bound direct reads to the requested prefix. Local post-filters still need
4828
+ // full pages to find sparse matches. Offsets remain filtered row offsets,
4829
+ // independent of the page size used by an earlier call.
4830
+ const pageSize = directOffsetPaging ? Math.min(maxPageSize, input.offset + input.limit) : maxPageSize;
4825
4831
  const collected = [];
4826
4832
  let matchedSeen = directOffsetPaging ? Math.floor(input.offset / pageSize) * pageSize : 0;
4827
4833
  let page = directOffsetPaging ? Math.floor(input.offset / pageSize) + 1 : 1;
4828
4834
  const firstPage = page;
4829
4835
  let api;
4836
+ const sourceRequests = [];
4830
4837
  let upstreamMeta;
4831
4838
  let upstreamHasMore = false;
4832
4839
  do {
@@ -4840,11 +4847,15 @@ async function getProgramTargetsPayload(client, input, programId) {
4840
4847
  page,
4841
4848
  page_size: pageSize
4842
4849
  });
4850
+ sourceRequests.push({ source: "program_targets", program_id: programId, page,
4851
+ request_id: api.requestId, upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
4843
4852
  const data = readObject(api.data);
4844
4853
  upstreamMeta = readObject(data?.meta);
4845
4854
  for (const target of readArray(data?.targets).map(sanitizeTarget)) {
4846
4855
  if (!targetHasAllowedScope(target, input))
4847
4856
  continue;
4857
+ if (languageTags.size > 0 && !arraysIntersectNormalized(languageTags, readStringArrayField(target, "language_tags")))
4858
+ continue;
4848
4859
  if (matchedSeen >= input.offset && collected.length < input.limit)
4849
4860
  collected.push(target);
4850
4861
  matchedSeen += 1;
@@ -4862,8 +4873,10 @@ async function getProgramTargetsPayload(client, input, programId) {
4862
4873
  throw new Error("No program target response was collected.");
4863
4874
  }
4864
4875
  const targets = collected;
4876
+ const hasMore = upstreamHasMore || matchedSeen > input.offset + targets.length;
4865
4877
  return withApiMetadata(api, {
4866
4878
  program_id: programId,
4879
+ source_requests: sourceRequests,
4867
4880
  targets: formatTargetList(targets, input.output_mode),
4868
4881
  meta: {
4869
4882
  offset: input.offset,
@@ -4872,7 +4885,8 @@ async function getProgramTargetsPayload(client, input, programId) {
4872
4885
  total_active_targets: readNumber(upstreamMeta?.total) ?? matchedSeen,
4873
4886
  total_after_filters: upstreamHasMore ? undefined : matchedSeen,
4874
4887
  returned: targets.length,
4875
- has_more: upstreamHasMore || matchedSeen > input.offset + targets.length,
4888
+ has_more: hasMore,
4889
+ next_offset: hasMore ? input.offset + targets.length : undefined,
4876
4890
  upstream_has_more: upstreamHasMore,
4877
4891
  possibly_incomplete: upstreamHasMore,
4878
4892
  upstream_pages_fetched: page - firstPage,
@@ -4885,9 +4899,16 @@ async function getProgramTargetsPayload(client, input, programId) {
4885
4899
  });
4886
4900
  }
4887
4901
  async function getProgramTargetsExportFallbackPayload(client, input, programId) {
4902
+ if (input.search || input.target_type || input.min_target_score !== undefined
4903
+ || input.target_opportunity_label || input.competition_risk) {
4904
+ 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.");
4905
+ }
4888
4906
  const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, programId);
4889
- const sanitizedTargets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, input));
4907
+ const languageTags = new Set((input.language_tags ?? []).map(normalizeTag));
4908
+ const sanitizedTargets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, input)
4909
+ && (languageTags.size === 0 || arraysIntersectNormalized(languageTags, readStringArrayField(target, "language_tags"))));
4890
4910
  const targets = sanitizedTargets.slice(input.offset, input.offset + input.limit);
4911
+ const hasMore = input.offset + targets.length < sanitizedTargets.length;
4891
4912
  return stripUndefined({
4892
4913
  request_id: exportLookup.requestId,
4893
4914
  upstream_request_id: exportLookup.upstreamRequestId,
@@ -4901,10 +4922,10 @@ async function getProgramTargetsExportFallbackPayload(client, input, programId)
4901
4922
  offset: input.offset,
4902
4923
  limit: input.limit,
4903
4924
  output_mode: input.output_mode,
4904
- total_active_targets: exportLookup.rawTargets.length,
4905
- total_after_filters: sanitizedTargets.length,
4925
+ ...fallbackTargetCoverage(exportLookup, sanitizedTargets.length),
4906
4926
  returned: targets.length,
4907
- has_more: input.offset + input.limit < sanitizedTargets.length,
4927
+ has_more: hasMore || exportLookup.coverage?.possibly_incomplete === true,
4928
+ next_offset: hasMore ? input.offset + targets.length : undefined,
4908
4929
  target_source: exportLookup.source,
4909
4930
  target_list_unavailable: exportLookup.unavailable,
4910
4931
  fallback_error: exportLookup.error
@@ -4933,6 +4954,7 @@ async function fetchProgramTargetsWithEndpointFallback(client, programId) {
4933
4954
  })),
4934
4955
  source: "program_targets",
4935
4956
  rawTargets,
4957
+ coverage: targetPageCoverage(data, rawTargets.length, rawTargets.length),
4936
4958
  sourceRequests: [
4937
4959
  {
4938
4960
  source: "program_targets",
@@ -5032,14 +5054,21 @@ function cacheTtlMsFromDiagnostics(cache) {
5032
5054
  }
5033
5055
  async function fetchProgramTargetsFromExportFallback(client, programId) {
5034
5056
  try {
5057
+ const limit = Math.min(MAX_TARGET_EXPORT, client.endpointNumber("target_export", "max_targets", MAX_TARGET_EXPORT));
5035
5058
  const api = await client.exportTargets({
5036
5059
  program_ids: [programId],
5037
5060
  include_out_of_scope: true,
5038
5061
  include_ineligible: true,
5039
5062
  format: "json",
5040
- limit: MAX_TARGET_EXPORT
5063
+ limit
5041
5064
  });
5042
5065
  const rawTargets = readExportTargetRows(api.data).map((row) => sanitizeTarget(readObject(row)?.target ?? row));
5066
+ const data = readObject(api.data);
5067
+ const coverage = targetPageCoverage({ meta: {
5068
+ ...readObject(data?.meta),
5069
+ // A full capped export is not proof that the inventory ends here.
5070
+ has_more: readObject(data?.meta)?.has_more === true || rawTargets.length >= limit
5071
+ } }, rawTargets.length, rawTargets.length);
5043
5072
  return {
5044
5073
  requestId: api.requestId,
5045
5074
  upstreamRequestId: api.upstreamRequestId,
@@ -5053,6 +5082,7 @@ async function fetchProgramTargetsFromExportFallback(client, programId) {
5053
5082
  })),
5054
5083
  source: "target_export",
5055
5084
  rawTargets,
5085
+ coverage,
5056
5086
  sourceRequests: [
5057
5087
  {
5058
5088
  source: "target_export",
@@ -5313,8 +5343,7 @@ async function getProgramScopeSummary(client, config, input) {
5313
5343
  program: formatProgram(lookup.program, "compact"),
5314
5344
  scope: buildTargetScopeSummary(targets, input.target_list_mode, input.group_limit, input.include_out_of_scope, input.include_ineligible),
5315
5345
  meta: {
5316
- total_active_targets: rawTargets.length,
5317
- total_after_filters: targets.length,
5346
+ ...targetPageCoverage(targetsData, rawTargets.length, targets.length),
5318
5347
  target_list_mode: input.target_list_mode,
5319
5348
  group_limit: input.group_limit,
5320
5349
  target_source: "program_targets",
@@ -5339,8 +5368,7 @@ async function getProgramScopeSummaryFromIndexFallback(client, indexFallback, in
5339
5368
  program: formatProgram(indexFallback.program, "compact"),
5340
5369
  scope: buildTargetScopeSummary(targets, input.target_list_mode, input.group_limit, input.include_out_of_scope, input.include_ineligible),
5341
5370
  meta: stripUndefined({
5342
- total_active_targets: exportLookup.rawTargets.length,
5343
- total_after_filters: targets.length,
5371
+ ...fallbackTargetCoverage(exportLookup, targets.length),
5344
5372
  target_list_mode: input.target_list_mode,
5345
5373
  group_limit: input.group_limit,
5346
5374
  target_source: exportLookup.source,
@@ -5376,8 +5404,7 @@ async function getProgramTargetBreakdown(client, config, input) {
5376
5404
  groups: readObject(scope.groups)
5377
5405
  },
5378
5406
  meta: {
5379
- total_active_targets: rawTargets.length,
5380
- total_after_filters: targets.length,
5407
+ ...targetPageCoverage(targetsData, rawTargets.length, targets.length),
5381
5408
  target_list_mode: input.target_list_mode,
5382
5409
  group_limit: input.group_limit,
5383
5410
  target_source: "program_targets",
@@ -5385,6 +5412,32 @@ async function getProgramTargetBreakdown(client, config, input) {
5385
5412
  }
5386
5413
  });
5387
5414
  }
5415
+ function targetPageCoverage(data, rows, filteredRows) {
5416
+ const meta = readObject(data?.meta);
5417
+ const total = numberField(meta, "total");
5418
+ const incomplete = meta?.has_more === true || (numberField(meta, "total_pages") ?? 1) > 1
5419
+ || (total !== undefined && total > rows);
5420
+ return stripUndefined({
5421
+ total_active_targets: total ?? (incomplete ? undefined : rows),
5422
+ total_after_filters: incomplete ? undefined : filteredRows,
5423
+ targets_scanned: rows,
5424
+ matching_targets_scanned: filteredRows,
5425
+ possibly_incomplete: incomplete,
5426
+ count_scope: incomplete ? "sampled_target_page" : "complete_target_inventory"
5427
+ });
5428
+ }
5429
+ function fallbackTargetCoverage(lookup, filteredRows) {
5430
+ const incomplete = lookup.unavailable || lookup.coverage?.possibly_incomplete === true;
5431
+ return stripUndefined({
5432
+ ...lookup.coverage,
5433
+ total_active_targets: lookup.unavailable ? undefined : lookup.coverage?.total_active_targets ?? (incomplete ? undefined : lookup.rawTargets.length),
5434
+ total_after_filters: incomplete ? undefined : filteredRows,
5435
+ targets_scanned: lookup.rawTargets.length,
5436
+ matching_targets_scanned: filteredRows,
5437
+ possibly_incomplete: incomplete,
5438
+ count_scope: lookup.unavailable ? "unavailable" : incomplete ? "sampled_target_page" : "complete_target_inventory"
5439
+ });
5440
+ }
5388
5441
  async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback, input) {
5389
5442
  const exportLookup = await fetchProgramTargetsWithEndpointFallback(client, input.program_id);
5390
5443
  const targets = exportLookup.rawTargets.filter((target) => targetHasAllowedScope(target, {
@@ -5411,8 +5464,7 @@ async function getProgramTargetBreakdownFromIndexFallback(client, indexFallback,
5411
5464
  groups: readObject(scope.groups)
5412
5465
  },
5413
5466
  meta: stripUndefined({
5414
- total_active_targets: exportLookup.rawTargets.length,
5415
- total_after_filters: targets.length,
5467
+ ...fallbackTargetCoverage(exportLookup, targets.length),
5416
5468
  target_list_mode: input.target_list_mode,
5417
5469
  group_limit: input.group_limit,
5418
5470
  target_source: exportLookup.source,
@@ -6255,6 +6307,7 @@ function compactProgram(program) {
6255
6307
  competition_risk: stringField(program, "competition_risk"),
6256
6308
  competition_risk_reason: stringField(program, "competition_risk_reason"),
6257
6309
  submission_cost: program.submission_cost,
6310
+ contest_awards: program.contest_awards,
6258
6311
  payout_summary: program.payout_summary,
6259
6312
  activity: program.activity,
6260
6313
  activity_summary: program.activity_summary,
@@ -6820,6 +6873,18 @@ function clampLimit(name, value, max, warnings) {
6820
6873
  // server order, so a larger pool lets ranking_mode choose among more of them.
6821
6874
  const HUNT_SERVER_POOL = 100;
6822
6875
  async function findHuntTargets(client, config, input, activity = {}) {
6876
+ const huntStartedAt = Date.now();
6877
+ const phaseTiming = {};
6878
+ const runStage = async (phase, timeoutMs, callback) => {
6879
+ const startedAt = Date.now();
6880
+ try {
6881
+ return await client.withRequestTimeout(timeoutMs, callback);
6882
+ }
6883
+ finally {
6884
+ phaseTiming[phase] = Date.now() - startedAt;
6885
+ }
6886
+ };
6887
+ validateActivityCatalogInput(activity);
6823
6888
  const warnings = [];
6824
6889
  const errors = [];
6825
6890
  const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
@@ -6885,22 +6950,28 @@ async function findHuntTargets(client, config, input, activity = {}) {
6885
6950
  warnings.push(`Recent target-change queries were reduced from ${desiredRecentChangeQueries.length} to ${recentChangeQueries.length} to preserve the upstream request budget.`);
6886
6951
  }
6887
6952
  const serverFiltered = hasActivityCatalogInput(activity);
6953
+ // Catalog discovery and optional changes share at most 60% of the tool
6954
+ // deadline. Keep at least 30% for exact targets and 10% for the response.
6888
6955
  const [programPayload, recentResult] = await Promise.all([
6889
- serverFiltered
6956
+ runStage("program_discovery_ms", Math.floor(config.requestTimeoutMs * 0.6), () => serverFiltered
6890
6957
  ? findFilteredServerPrograms(client, config, {
6891
6958
  ...programInput, ...activity, opportunity_levels: [], sort_by: activity.sort_by, sort_direction: activity.sort_direction,
6892
6959
  platforms: input.platforms, tags: input.tags, updated_since: input.updated_since, vdp_mode: "exclude", include_target_samples: false
6893
6960
  }, {
6894
6961
  ...programInput, platforms: [], tags: [], vdp_mode: "include", updated_since: undefined,
6895
6962
  max_pages: programRequestBudget, upstream_request_budget: programRequestBudget
6896
- }, HUNT_SERVER_POOL)
6897
- : findPrograms(client, config, programInput),
6963
+ }, HUNT_SERVER_POOL, true)
6964
+ : findPrograms(client, config, programInput, {
6965
+ allowPartialTimeout: true, excludeVdpUpstream: true
6966
+ })),
6898
6967
  recentChangesEnabled
6899
- ? fetchHuntRecentChanges(client, config, input, recentChangeQueries)
6968
+ ? runStage("recent_changes_ms", Math.floor(config.requestTimeoutMs * 0.3), () => fetchHuntRecentChanges(client, config, input, recentChangeQueries))
6900
6969
  : Promise.resolve({ changes: [], sourceRequests: [], requestsUsed: 0, errors: [] })
6901
6970
  ]);
6971
+ client.throwIfRequestAborted();
6902
6972
  const programWarnings = readArray(programPayload.warnings).filter((warning) => typeof warning === "string");
6903
6973
  warnings.push(...programWarnings);
6974
+ errors.push(...readArray(programPayload.errors).map(readObject).filter((error) => error !== undefined));
6904
6975
  errors.push(...recentResult.errors);
6905
6976
  const programBudget = readObject(programPayload.upstream_budget);
6906
6977
  const programRequestsUsed = readNumber(programBudget?.used) ?? 0;
@@ -6919,8 +6990,12 @@ async function findHuntTargets(client, config, input, activity = {}) {
6919
6990
  const finalistEntries = preliminaryRankedEntries
6920
6991
  .slice(0, sampledProgramCount)
6921
6992
  .map((entry) => entry.entry);
6922
- const targetResult = await collectHuntTargetSamples(client, finalistEntries, input);
6993
+ const targetResult = await runStage("target_sampling_ms", Math.floor(config.requestTimeoutMs * 0.9) - (Date.now() - huntStartedAt), () => collectHuntTargetSamples(client, finalistEntries, input));
6994
+ client.throwIfRequestAborted();
6923
6995
  errors.push(...targetResult.errors);
6996
+ if (errors.length > 0) {
6997
+ 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.");
6998
+ }
6924
6999
  if (sampledProgramCount < Math.min(input.max_results, programEntries.length)) {
6925
7000
  warnings.push(`The upstream request budget limited the final exact target shortlist to ${sampledProgramCount} of ${Math.min(input.max_results, programEntries.length)} requested results.`);
6926
7001
  }
@@ -6934,6 +7009,10 @@ async function findHuntTargets(client, config, input, activity = {}) {
6934
7009
  }));
6935
7010
  const targetRequestsUsed = targetResult.requestsUsed;
6936
7011
  const totalRequestsUsed = programRequestsUsed + recentResult.requestsUsed + targetRequestsUsed;
7012
+ const coverageIncomplete = errors.length > 0
7013
+ || readObject(programPayload.ranking_scope)?.possibly_incomplete === true
7014
+ || readObject(programPayload.scan)?.complete === false
7015
+ || sampledProgramCount < Math.min(input.max_results, programEntries.length);
6937
7016
  return stripUndefined({
6938
7017
  request_id: randomUUID(),
6939
7018
  fetched_at: new Date().toISOString(),
@@ -6985,7 +7064,10 @@ async function findHuntTargets(client, config, input, activity = {}) {
6985
7064
  result_guidance: {
6986
7065
  final_shortlist: true,
6987
7066
  follow_up_calls_required: false,
6988
- 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."
7067
+ coverage_incomplete: coverageIncomplete,
7068
+ instruction: coverageIncomplete
7069
+ ? "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."
7070
+ : "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."
6989
7071
  },
6990
7072
  matches,
6991
7073
  meta: {
@@ -6998,7 +7080,8 @@ async function findHuntTargets(client, config, input, activity = {}) {
6998
7080
  model_round_trips_expected: 1,
6999
7081
  parallel_recent_change_fetch: recentChangeQueries.length > 0,
7000
7082
  bulk_resource_links_suppressed: true,
7001
- partial_success: errors.length > 0
7083
+ partial_success: errors.length > 0,
7084
+ phase_timing: phaseTiming
7002
7085
  }
7003
7086
  });
7004
7087
  }
@@ -7738,6 +7821,7 @@ function hasActivityCatalogInput(input) {
7738
7821
  }
7739
7822
  /** Validates and builds one server-ordered catalog query; nothing is sent. */
7740
7823
  function serverCatalogQuery(client, input) {
7824
+ validateActivityCatalogInput(input);
7741
7825
  const levels = Array.isArray(input.opportunity_levels) ? input.opportunity_levels : [];
7742
7826
  if (levels.length > 1)
7743
7827
  throw new Error("Combined Activity queries accept at most one opportunity tier; separate tier pages cannot form a global ranking.");
@@ -7757,6 +7841,17 @@ function serverCatalogQuery(client, input) {
7757
7841
  vdp_mode: input.vdp_mode, snapshot: input.snapshot }
7758
7842
  };
7759
7843
  }
7844
+ // Durations rank fastest first when no direction is given, as the API does. The
7845
+ // direction the server reports it applied wins, so an older API is labelled
7846
+ // correctly too.
7847
+ const ASCENDING_FIRST_SORTS = new Set(["first_response", "time_to_bounty"]);
7848
+ function appliedSortDirection(input, served) {
7849
+ if (served === "asc" || served === "desc")
7850
+ return served;
7851
+ if (input.sort_direction === "asc" || input.sort_direction === "desc")
7852
+ return input.sort_direction;
7853
+ return ASCENDING_FIRST_SORTS.has(String(input.sort_by ?? "updated")) ? "asc" : "desc";
7854
+ }
7760
7855
  function readServerCatalogPage(client, catalog, page, pageSize, snapshot) {
7761
7856
  const query = toQuery({ ...catalog.query, snapshot, page, page_size: pageSize });
7762
7857
  return catalog.level ? client.getOpportunities(catalog.level, query) : client.listPrograms(query);
@@ -7773,7 +7868,7 @@ async function findServerCatalogPrograms(client, config, input) {
7773
7868
  const mode = input.output_mode === "full" ? "full" : "compact";
7774
7869
  return withApiMetadata(api, {
7775
7870
  programs: readArray(data?.programs).map((program) => formatProgram(sanitizeProgram(program, config.webBaseUrl), mode)),
7776
- ranking_scope: { mode: "server", sort_by: input.sort_by ?? "updated", sort_direction: input.sort_direction ?? "desc" },
7871
+ ranking_scope: { mode: "server", sort_by: input.sort_by ?? "updated", sort_direction: appliedSortDirection(input, readObject(data?.meta)?.sort_direction) },
7777
7872
  meta: sanitizeJson(data?.meta)
7778
7873
  });
7779
7874
  }
@@ -7817,6 +7912,7 @@ async function scanServerCatalog(client, config, input, local, limits) {
7817
7912
  const start = cursorValue ? decodeScanCursor(cursorValue, fingerprint, pageSize)
7818
7913
  : { v: 1, p: Number(input.page ?? 1), k: 0, s: pageSize, f: fingerprint, a: undefined, t: undefined };
7819
7914
  const sourceRequests = [];
7915
+ const errors = [];
7820
7916
  let matches = [];
7821
7917
  let seen = new Set();
7822
7918
  let nextPage = start.p;
@@ -7825,6 +7921,7 @@ async function scanServerCatalog(client, config, input, local, limits) {
7825
7921
  let token = start.t ?? input.snapshot;
7826
7922
  let totalPages;
7827
7923
  let serverTotal;
7924
+ let servedDirection;
7828
7925
  let rowsScanned = 0;
7829
7926
  let pagesRead = 0;
7830
7927
  let restarted = false;
@@ -7840,11 +7937,25 @@ async function scanServerCatalog(client, config, input, local, limits) {
7840
7937
  const needed = pagesRead === 0 ? 1 : Math.ceil((limits.maxResults - matches.length) / (rate * pageSize));
7841
7938
  const batch = Math.max(1, Math.min(allowance, needed, CATALOG_SCAN_PREFETCH));
7842
7939
  const pageToken = token;
7843
- const pages = await Promise.all(Array.from({ length: batch }, (_, index) => nextPage + index)
7940
+ const batchStart = nextPage;
7941
+ const pages = await Promise.allSettled(Array.from({ length: batch }, (_, index) => batchStart + index)
7844
7942
  .map(async (page) => ({ page, api: await readServerCatalogPage(client, catalog, page, pageSize, pageToken) })));
7845
- for (const { page, api } of pages) {
7943
+ // Count all admitted reads, including a failed page or a prefetched page
7944
+ // beyond a gap; only the contiguous successful prefix enters the ranking.
7945
+ for (const result of pages) {
7946
+ const noRequest = result.status === "fulfilled" ? result.value.api.cached || result.value.api.coalesced
7947
+ : result.reason instanceof BBRadarApiError && (result.reason.cached || result.reason.coalesced);
7948
+ limits.budget.remaining -= noRequest ? 0 : 1;
7949
+ }
7950
+ for (const [index, result] of pages.entries()) {
7951
+ if (result.status === "rejected") {
7952
+ if (!limits.allowPartialTimeout || pagesRead === 0 || !(result.reason instanceof BBRadarApiError) || result.reason.status !== 408)
7953
+ throw result.reason;
7954
+ errors.push(apiOperationError(`program catalog page ${batchStart + index}`, result.reason));
7955
+ break scan;
7956
+ }
7957
+ const { page, api } = result.value;
7846
7958
  pagesRead += 1;
7847
- limits.budget.remaining -= api.cached || api.coalesced ? 0 : 1;
7848
7959
  sourceRequests.push({ source: catalog.level ? `opportunities:${catalog.level}` : "programs", page, request_id: api.requestId,
7849
7960
  upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
7850
7961
  const data = readObject(api.data);
@@ -7869,6 +7980,7 @@ async function scanServerCatalog(client, config, input, local, limits) {
7869
7980
  version ??= pageVersion;
7870
7981
  totalPages = readNumber(meta?.total_pages) ?? totalPages;
7871
7982
  serverTotal = readNumber(meta?.total) ?? serverTotal;
7983
+ servedDirection = meta?.sort_direction ?? servedDirection;
7872
7984
  if (token !== undefined && typeof meta?.snapshot === "string")
7873
7985
  token = meta.snapshot;
7874
7986
  const rows = readArray(data?.programs);
@@ -7900,10 +8012,11 @@ async function scanServerCatalog(client, config, input, local, limits) {
7900
8012
  return {
7901
8013
  candidates: matches,
7902
8014
  sourceRequests,
8015
+ errors,
7903
8016
  scan: stripUndefined({
7904
8017
  order: "Server order; local filters only remove programs.",
7905
8018
  sort_by: input.sort_by ?? "updated",
7906
- sort_direction: input.sort_direction ?? "desc",
8019
+ sort_direction: appliedSortDirection(input, servedDirection),
7907
8020
  local_filters: localFilters.length > 0 ? localFilters : undefined,
7908
8021
  server_matches: serverTotal,
7909
8022
  rows_scanned: rowsScanned,
@@ -7913,17 +8026,20 @@ async function scanServerCatalog(client, config, input, local, limits) {
7913
8026
  ? encodeScanCursor({ v: 1, p: resume.p, k: resume.k, s: pageSize, f: fingerprint, a: version, t: typeof token === "string" ? token : undefined })
7914
8027
  : undefined,
7915
8028
  activity_snapshot_id: version,
7916
- restarted_on_new_activity_version: restarted || undefined
8029
+ restarted_on_new_activity_version: restarted || undefined,
8030
+ deadline_reached: errors.length > 0 || undefined
7917
8031
  })
7918
8032
  };
7919
8033
  }
7920
8034
  /** find_programs with server Activity filters plus local filters or target samples. */
7921
- async function findFilteredServerPrograms(client, config, input, local, maxResults = local.max_results) {
8035
+ async function findFilteredServerPrograms(client, config, input, local, maxResults = local.max_results, allowPartialTimeout = false) {
7922
8036
  const normalized = normalizeFindProgramsInput(local);
7923
8037
  const warnings = normalized.warnings;
7924
8038
  local = normalized.input;
7925
8039
  const budget = { initial: local.upstream_request_budget, remaining: local.upstream_request_budget };
7926
- const result = await scanServerCatalog(client, config, input, local, { maxResults, maxPages: local.max_pages, budget });
8040
+ const result = await scanServerCatalog(client, config, input, local, { maxResults, maxPages: local.max_pages, budget, allowPartialTimeout });
8041
+ if (result.errors.length > 0)
8042
+ warnings.push("Program discovery reached its time budget; ranking covers only the completed server-ordered prefix.");
7927
8043
  if (result.scan.complete !== true) {
7928
8044
  warnings.push("More server pages remain; scan.next_cursor continues in the same order.");
7929
8045
  }
@@ -7936,6 +8052,7 @@ async function findFilteredServerPrograms(client, config, input, local, maxResul
7936
8052
  request_id: randomUUID(),
7937
8053
  fetched_at: new Date().toISOString(),
7938
8054
  source_requests: result.sourceRequests,
8055
+ errors: result.errors.length > 0 ? result.errors : undefined,
7939
8056
  warnings: warnings.length > 0 ? warnings : undefined,
7940
8057
  upstream_budget: { used: budget.initial - budget.remaining, remaining: budget.remaining },
7941
8058
  ranking_scope: { mode: "server_filtered", sort_by: result.scan.sort_by, sort_direction: result.scan.sort_direction, complete: result.scan.complete },
@@ -7950,10 +8067,11 @@ function programHasNoSubmissionFee(program) {
7950
8067
  const amount = numberField(cost, "amount");
7951
8068
  return amount === undefined || (amount === 0 && (numberField(cost, "maximum_amount") ?? 0) === 0);
7952
8069
  }
7953
- async function findPrograms(client, config, input) {
8070
+ async function findPrograms(client, config, input, execution = {}) {
7954
8071
  const normalized = normalizeFindProgramsInput(input);
7955
8072
  const warnings = normalized.warnings;
7956
8073
  input = normalized.input;
8074
+ const errors = [];
7957
8075
  const budget = {
7958
8076
  initial: input.upstream_request_budget,
7959
8077
  remaining: input.upstream_request_budget
@@ -7970,11 +8088,13 @@ async function findPrograms(client, config, input) {
7970
8088
  platforms: input.platforms,
7971
8089
  tags: upstreamTags,
7972
8090
  program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined),
8091
+ vdp_mode: execution.excludeVdpUpstream && input.vdp_mode === "exclude" ? "exclude" : undefined,
7973
8092
  updated_since: input.updated_since,
7974
8093
  opportunity_levels: input.opportunity_levels,
7975
8094
  max_pages: input.max_pages,
7976
8095
  budget,
7977
- warnings
8096
+ warnings,
8097
+ partialTimeoutErrors: execution.allowPartialTimeout ? errors : undefined
7978
8098
  });
7979
8099
  const filteredCandidates = collected.programs
7980
8100
  .map((program) => toProgramCandidate(program, config.webBaseUrl))
@@ -7998,6 +8118,7 @@ async function findPrograms(client, config, input) {
7998
8118
  source_requests: collected.sourceRequests,
7999
8119
  fetched_at: new Date().toISOString(),
8000
8120
  warnings: warnings.length > 0 ? warnings : undefined,
8121
+ errors: errors.length > 0 ? errors : undefined,
8001
8122
  filters: stripUndefined({
8002
8123
  platforms: input.platforms.length > 0 ? input.platforms : undefined,
8003
8124
  exclude_platforms: input.exclude_platforms && input.exclude_platforms.length > 0 ? input.exclude_platforms : undefined,
@@ -8040,7 +8161,7 @@ async function findPrograms(client, config, input) {
8040
8161
  platform_filter: input.platforms.length > 0 ? input.platforms : "all_platforms",
8041
8162
  max_pages_scanned_per_source: input.max_pages,
8042
8163
  upstream_total_pages: collected.totalPagesBySource,
8043
- possibly_incomplete: isRankingPossiblyIncomplete(collected.totalPagesBySource, input.max_pages)
8164
+ possibly_incomplete: errors.length > 0 || isRankingPossiblyIncomplete(collected.totalPagesBySource, input.max_pages)
8044
8165
  },
8045
8166
  programs,
8046
8167
  meta: {
@@ -8445,20 +8566,39 @@ async function collectProgramSource(client, source, sourceIndex, options) {
8445
8566
  async function fetchProgramPage(client, source, sourceIndex, page, options) {
8446
8567
  const runWithSlot = options.runWithProgramPageSlot ?? runImmediately;
8447
8568
  return runWithSlot(async () => {
8569
+ const endpoint = source.kind === "programs" ? "programs" : "opportunities";
8570
+ const vdpModes = readArray(readObject(client.endpointCapability(endpoint)?.catalog_filters)?.vdp_mode);
8448
8571
  const query = toQuery({
8449
8572
  search: options.search,
8450
8573
  platforms: options.platforms,
8451
8574
  tags: options.tags,
8452
8575
  updated_since: options.updated_since,
8453
8576
  program_kind: source.kind === "programs" ? options.program_kind : undefined,
8577
+ // This removes only known VDPs; unknown classifications with positive
8578
+ // rewards still reach the local filters. Older APIs keep local exclusion.
8579
+ vdp_mode: supportsCatalogFilters(client, endpoint) && vdpModes.includes("exclude") ? options.vdp_mode : undefined,
8454
8580
  page,
8455
- page_size: MAX_PROGRAMS_PER_SEARCH
8581
+ page_size: Math.min(MAX_PROGRAMS_PER_SEARCH, client.endpointNumber(endpoint, "max_page_size", MAX_PROGRAMS_PER_SEARCH))
8456
8582
  });
8457
8583
  if (!tryConsumeBudget(options.budget)) {
8458
8584
  addUniqueWarning(options.warnings, "Upstream request budget was exhausted before all requested program pages could be fetched.");
8459
8585
  return undefined;
8460
8586
  }
8461
- const api = source.kind === "opportunity" ? await client.getOpportunities(source.level, query) : await client.listPrograms(query);
8587
+ let api;
8588
+ try {
8589
+ api = source.kind === "opportunity" ? await client.getOpportunities(source.level, query) : await client.listPrograms(query);
8590
+ }
8591
+ catch (error) {
8592
+ // Hunts can retain completed catalog pages. Ordinary discovery keeps its
8593
+ // existing failure contract, and a failed first page is never an empty hit.
8594
+ if (page === 1 || !options.partialTimeoutErrors || !(error instanceof BBRadarApiError) || error.status !== 408)
8595
+ throw error;
8596
+ if (error.cached || error.coalesced)
8597
+ options.budget.remaining += 1;
8598
+ options.partialTimeoutErrors.push(apiOperationError(`program catalog page ${page}`, error));
8599
+ addUniqueWarning(options.warnings, "Program discovery reached its time budget; ranking covers only completed catalog pages.");
8600
+ return undefined;
8601
+ }
8462
8602
  refundBudgetIfNoUpstreamRequest(options.budget, api);
8463
8603
  const data = readObject(api.data);
8464
8604
  const meta = readObject(data?.meta);
@@ -8944,7 +9084,9 @@ Summarize the program scope:
8944
9084
  title: "Prepare Passive Recon Plan",
8945
9085
  description: "Create a passive-only plan from BBRadar program and target data. Use only when planning is explicitly requested.",
8946
9086
  argsSchema: {
8947
- program_id: programIdentifierSchema.optional(),
9087
+ // Zod 4 keeps a description on the schema it was set on, and prompt
9088
+ // arguments read the outer optional, so describe it again here.
9089
+ program_id: programIdentifierSchema.optional().describe(PROGRAM_IDENTIFIER_DESCRIPTION),
8948
9090
  focus: z.string().optional().describe("Optional focus area such as APIs, domains, mobile, or cloud.")
8949
9091
  }
8950
9092
  }, (args) => promptResult(`Prepare a passive-only plan from BBRadar data. Program id JSON string, if present: ${promptJson(args.program_id ?? "")}.
@@ -9280,6 +9422,7 @@ function compactToolText(payload, isError) {
9280
9422
  }));
9281
9423
  }
9282
9424
  const meta = readObject(payload.meta);
9425
+ const coverageIncomplete = readObject(payload.result_guidance)?.coverage_incomplete === true;
9283
9426
  const returned = numberField(meta, "returned") ??
9284
9427
  [
9285
9428
  readArray(payload.programs).length,
@@ -9291,6 +9434,12 @@ function compactToolText(payload, isError) {
9291
9434
  request_id: payload.request_id,
9292
9435
  summary: "Full tool result is available in structuredContent.",
9293
9436
  returned,
9437
+ partial_success: meta?.partial_success === true ? true : undefined,
9438
+ coverage_incomplete: coverageIncomplete ? true : undefined,
9439
+ errors: meta?.partial_success === true ? readArray(payload.errors).map((error) => {
9440
+ const value = readObject(error);
9441
+ return stripUndefined({ operation: value?.operation, status: value?.status });
9442
+ }) : undefined,
9294
9443
  mcp_timing: payload.mcp_timing
9295
9444
  }));
9296
9445
  }