@bbradar/mcp 0.3.1 → 0.3.3

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
@@ -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.1";
10
+ const SERVER_VERSION = "0.3.3";
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.31.0";
49
+ const SDK_VERSION = "1.32.2";
50
50
  const CORE_TOOL_NAMES = new Set([
51
51
  "search_programs",
52
52
  "get_program",
@@ -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"],
@@ -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 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.";
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(),
@@ -517,8 +525,8 @@ export function createBbradarServer(client, config) {
517
525
  const repositoryChangesPageSizeSchema = z.number().int().min(1).max(maxRepositoryChangesPageSize).default(Math.min(50, maxRepositoryChangesPageSize));
518
526
  const acceptedTargetLimitSchema = z.number().int().min(1).max(maxProgramTargetsPageSize);
519
527
  const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
520
- const historyOutput = { ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() };
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() };
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() });
522
530
  // Published Activity versions are 64 lowercase hex characters. "start" and
523
531
  // catalog snapshot tokens are different contracts and are rejected here.
524
532
  const activitySnapshotSchema = z.string().regex(/^[0-9a-f]{64}$/, "Use a snapshot_id returned by Activity.")
@@ -537,7 +545,7 @@ export function createBbradarServer(client, config) {
537
545
  program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
538
546
  search: z.string().min(1).max(200).optional(),
539
547
  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."),
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."),
541
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."),
542
550
  launched_within: z.enum(["7d", "30d", "90d"]).optional(),
543
551
  category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
@@ -547,19 +555,23 @@ export function createBbradarServer(client, config) {
547
555
  cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(25),
548
556
  snapshot: activitySnapshotSchema.optional()
549
557
  };
550
- const activityOutput = {
558
+ const activityOutput = apiMirrorOutput({
551
559
  ...apiEnvelopeOutputShape(), rows: z.array(jsonRecordSchema()).optional(), points: z.array(jsonRecordSchema()).optional(),
552
560
  tab: z.string().optional(), period: z.string().nullable().optional(),
553
561
  basis: z.string().nullable().optional(), precision: z.string().nullable().optional(),
554
562
  sort: z.string().nullable().optional(), direction: z.string().optional(), meta: jsonRecordSchema().optional(),
555
563
  cohort_median: jsonRecordSchema().nullable().optional(), available_views: z.array(jsonRecordSchema()).optional(),
556
- map_points_omitted: z.number().int().optional()
557
- };
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
+ });
558
570
  registerTool("get_activity_capabilities", {
559
571
  title: "Get Available Activity Views",
560
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.",
561
573
  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() },
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() }),
563
575
  annotations: readOnlyAnnotations
564
576
  }, (args) => runTool("get_activity_capabilities", config, rateLimiter, async () => {
565
577
  assertProFeature(client, "activity_capabilities", "enabled");
@@ -570,7 +582,7 @@ export function createBbradarServer(client, config) {
570
582
  title: "Get Current Activity Version",
571
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.",
572
584
  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() },
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() }),
574
586
  annotations: readOnlyAnnotations
575
587
  }, () => runTool("get_activity_snapshot", config, rateLimiter, async () => {
576
588
  assertProFeature(client, "activity_snapshot", "enabled");
@@ -583,11 +595,11 @@ export function createBbradarServer(client, config) {
583
595
  }));
584
596
  registerTool("get_program_payouts", {
585
597
  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.",
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.",
587
599
  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
600
+ // Lists the API response fields, including its version identity: a client that
589
601
  // 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() },
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() }),
591
603
  annotations: readOnlyAnnotations
592
604
  }, (args) => runTool("get_program_payouts", config, rateLimiter, async () => {
593
605
  assertProFeature(client, "program_payouts", "enabled");
@@ -606,7 +618,7 @@ export function createBbradarServer(client, config) {
606
618
  };
607
619
  registerTool("get_activity_rankings", {
608
620
  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.",
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.",
610
622
  inputSchema: z.object(activityParams).strict(),
611
623
  outputSchema: activityOutput,
612
624
  annotations: readOnlyAnnotations
@@ -617,14 +629,14 @@ export function createBbradarServer(client, config) {
617
629
  // Single-view aliases for the full profile; each schema exposes only the
618
630
  // inputs its view accepts and shares the ranking tool's request path.
619
631
  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.",
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.",
622
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.",
623
- 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).",
624
636
  updates: "Rank active UTC days and recorded change groups over rolling periods. Baselines and internal corrections are excluded.",
625
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.",
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.",
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.",
628
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."
629
641
  };
630
642
  for (const tab of ACTIVITY_TABS) {
@@ -643,7 +655,7 @@ export function createBbradarServer(client, config) {
643
655
  title: "Get Activity Change Detail",
644
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.",
645
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(),
646
- 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
647
659
  }, (args) => runTool("get_activity_change_detail", config, rateLimiter, async () => {
648
660
  assertProFeature(client, "activity_event_detail", "enabled");
649
661
  return proPage(await client.getActivityChangeDetail(args.event_id, toQuery({ snapshot: args.snapshot, cursor: args.cursor, limit: args.limit })), "items");
@@ -668,7 +680,7 @@ export function createBbradarServer(client, config) {
668
680
  title: "Get History Change Detail",
669
681
  description: "Fetch one semantic History change's paginated before/after items only when needed.",
670
682
  inputSchema: { change_id: z.string().min(1).max(128), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) },
671
- 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
672
684
  }, (args) => runTool("get_history_change_detail", config, rateLimiter, async () => {
673
685
  assertProFeature(client, "history_change_detail");
674
686
  assertHistoryCapture(client);
@@ -688,7 +700,7 @@ export function createBbradarServer(client, config) {
688
700
  title: "Get Exact Program Activity",
689
701
  description: "Read the authoritative activity_summary for one exact program ID.",
690
702
  inputSchema: { program_id: programIdSchema },
691
- 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
692
704
  }, (args) => runTool("get_program_activity", config, rateLimiter, async () => {
693
705
  assertProFeature(client, "program_activity");
694
706
  return proPage(await client.getProgramActivity(args.program_id), "activity_summary");
@@ -795,7 +807,7 @@ export function createBbradarServer(client, config) {
795
807
  }));
796
808
  registerTool("get_program", {
797
809
  title: "Get BBRadar Program",
798
- 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.",
799
811
  inputSchema: {
800
812
  program_id: programIdentifierSchema
801
813
  },
@@ -1440,7 +1452,8 @@ export function createBbradarServer(client, config) {
1440
1452
  min_added_7d: z.number().int().min(0).optional(),
1441
1453
  max_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1442
1454
  max_results: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(10),
1443
- 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(),
1444
1457
  include_target_samples: z.boolean().default(false),
1445
1458
  target_sample_programs: z.number().int().min(0).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
1446
1459
  target_sample_size: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(5),
@@ -2398,6 +2411,7 @@ function bbradarGuide(topic, profile, client) {
2398
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." },
2399
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." },
2400
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." },
2401
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." },
2402
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." },
2403
2417
  { user_goal: "exact recorded activity for one program", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
@@ -2547,6 +2561,7 @@ function coreBbradarGuide(topic, client) {
2547
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." },
2548
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" } },
2549
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." },
2550
2565
  { user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch change detail only when needed." },
2551
2566
  { user_goal: "most active programs", first_tool: "get_analytics_program_activity", arguments: {} },
2552
2567
  { user_goal: "exact recorded activity", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
@@ -2818,7 +2833,7 @@ async function runProgramIdTool(client, config, requestedProgramId, callback, in
2818
2833
  return withProgramIdFallbackMetadata(payload, cachedFallback);
2819
2834
  }
2820
2835
  catch (cachedFallbackError) {
2821
- if (!isProgramNotFoundError(cachedFallbackError) || !indexFallbackCallback) {
2836
+ if (!allowsProgramIdFallback(cachedFallbackError) || !indexFallbackCallback) {
2822
2837
  throw cachedFallbackError;
2823
2838
  }
2824
2839
  const indexFallback = createProgramIndexFallbackFromStaleResolution(cachedFallback, requestedProgramId, cachedFallbackError) ??
@@ -2840,7 +2855,7 @@ async function runProgramIdTool(client, config, requestedProgramId, callback, in
2840
2855
  return withProgramIdFallbackMetadata(payload, fallback);
2841
2856
  }
2842
2857
  catch (fallbackError) {
2843
- if (!isProgramNotFoundError(fallbackError) || !indexFallbackCallback) {
2858
+ if (!allowsProgramIdFallback(fallbackError) || !indexFallbackCallback) {
2844
2859
  throw fallbackError;
2845
2860
  }
2846
2861
  const indexFallback = createProgramIndexFallbackFromStaleResolution(fallback, requestedProgramId, fallbackError) ??
@@ -2868,7 +2883,7 @@ async function runProgramIdTool(client, config, requestedProgramId, callback, in
2868
2883
  return await callback(requestedProgramId);
2869
2884
  }
2870
2885
  catch (error) {
2871
- if (!isProgramNotFoundError(error)) {
2886
+ if (!allowsProgramIdFallback(error)) {
2872
2887
  throw error;
2873
2888
  }
2874
2889
  const fallback = await resolveStaleProgramId(client, config, requestedProgramId, error);
@@ -2890,7 +2905,7 @@ async function runProgramIdTool(client, config, requestedProgramId, callback, in
2890
2905
  return withProgramIdFallbackMetadata(payload, fallback);
2891
2906
  }
2892
2907
  catch (fallbackError) {
2893
- if (!isProgramNotFoundError(fallbackError) || !indexFallbackCallback) {
2908
+ if (!allowsProgramIdFallback(fallbackError) || !indexFallbackCallback) {
2894
2909
  throw fallbackError;
2895
2910
  }
2896
2911
  const indexFallback = createProgramIndexFallbackFromStaleResolution(fallback, requestedProgramId, fallbackError) ??
@@ -5142,7 +5157,20 @@ async function getProgramFromTargetsOnlyFallback(client, programId, programError
5142
5157
  async function fetchProgramDetailsAndTargets(client, config, programId) {
5143
5158
  const [programResult, targetsResult] = await Promise.allSettled([client.getProgram(programId), client.getProgramTargets(programId)]);
5144
5159
  if (targetsResult.status === "rejected") {
5145
- throw targetsResult.reason;
5160
+ const reason = targetsResult.reason;
5161
+ if (programResult.status === "fulfilled" && isProgramNotFoundError(reason)) {
5162
+ // Detail confirmed this exact program; only its target list is unavailable.
5163
+ throw new ConfirmedProgramTargetsError({
5164
+ message: `${programId} exists, but its target list is not available (${reason.message}). A finished contest keeps program detail only: read it with get_program.`,
5165
+ requestId: reason.requestId,
5166
+ upstreamRequestId: reason.upstreamRequestId,
5167
+ status: reason.status,
5168
+ detail: reason.detail,
5169
+ errors: reason.errors,
5170
+ cause: reason
5171
+ });
5172
+ }
5173
+ throw reason;
5146
5174
  }
5147
5175
  const targetsApi = targetsResult.value;
5148
5176
  const targetSourceRequest = {
@@ -6292,6 +6320,7 @@ function compactProgram(program) {
6292
6320
  competition_risk: stringField(program, "competition_risk"),
6293
6321
  competition_risk_reason: stringField(program, "competition_risk_reason"),
6294
6322
  submission_cost: program.submission_cost,
6323
+ contest_awards: program.contest_awards,
6295
6324
  payout_summary: program.payout_summary,
6296
6325
  activity: program.activity,
6297
6326
  activity_summary: program.activity_summary,
@@ -7825,6 +7854,17 @@ function serverCatalogQuery(client, input) {
7825
7854
  vdp_mode: input.vdp_mode, snapshot: input.snapshot }
7826
7855
  };
7827
7856
  }
7857
+ // Durations rank fastest first when no direction is given, as the API does. The
7858
+ // direction the server reports it applied wins, so an older API is labelled
7859
+ // correctly too.
7860
+ const ASCENDING_FIRST_SORTS = new Set(["first_response", "time_to_bounty"]);
7861
+ function appliedSortDirection(input, served) {
7862
+ if (served === "asc" || served === "desc")
7863
+ return served;
7864
+ if (input.sort_direction === "asc" || input.sort_direction === "desc")
7865
+ return input.sort_direction;
7866
+ return ASCENDING_FIRST_SORTS.has(String(input.sort_by ?? "updated")) ? "asc" : "desc";
7867
+ }
7828
7868
  function readServerCatalogPage(client, catalog, page, pageSize, snapshot) {
7829
7869
  const query = toQuery({ ...catalog.query, snapshot, page, page_size: pageSize });
7830
7870
  return catalog.level ? client.getOpportunities(catalog.level, query) : client.listPrograms(query);
@@ -7841,7 +7881,7 @@ async function findServerCatalogPrograms(client, config, input) {
7841
7881
  const mode = input.output_mode === "full" ? "full" : "compact";
7842
7882
  return withApiMetadata(api, {
7843
7883
  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" },
7884
+ ranking_scope: { mode: "server", sort_by: input.sort_by ?? "updated", sort_direction: appliedSortDirection(input, readObject(data?.meta)?.sort_direction) },
7845
7885
  meta: sanitizeJson(data?.meta)
7846
7886
  });
7847
7887
  }
@@ -7894,6 +7934,7 @@ async function scanServerCatalog(client, config, input, local, limits) {
7894
7934
  let token = start.t ?? input.snapshot;
7895
7935
  let totalPages;
7896
7936
  let serverTotal;
7937
+ let servedDirection;
7897
7938
  let rowsScanned = 0;
7898
7939
  let pagesRead = 0;
7899
7940
  let restarted = false;
@@ -7952,6 +7993,7 @@ async function scanServerCatalog(client, config, input, local, limits) {
7952
7993
  version ??= pageVersion;
7953
7994
  totalPages = readNumber(meta?.total_pages) ?? totalPages;
7954
7995
  serverTotal = readNumber(meta?.total) ?? serverTotal;
7996
+ servedDirection = meta?.sort_direction ?? servedDirection;
7955
7997
  if (token !== undefined && typeof meta?.snapshot === "string")
7956
7998
  token = meta.snapshot;
7957
7999
  const rows = readArray(data?.programs);
@@ -7987,7 +8029,7 @@ async function scanServerCatalog(client, config, input, local, limits) {
7987
8029
  scan: stripUndefined({
7988
8030
  order: "Server order; local filters only remove programs.",
7989
8031
  sort_by: input.sort_by ?? "updated",
7990
- sort_direction: input.sort_direction ?? "desc",
8032
+ sort_direction: appliedSortDirection(input, servedDirection),
7991
8033
  local_filters: localFilters.length > 0 ? localFilters : undefined,
7992
8034
  server_matches: serverTotal,
7993
8035
  rows_scanned: rowsScanned,
@@ -8906,6 +8948,16 @@ function addUniqueWarning(warnings, warning) {
8906
8948
  function isProgramNotFoundError(error) {
8907
8949
  return error instanceof BBRadarApiError && error.status === 404;
8908
8950
  }
8951
+ /**
8952
+ * A target read failed for a program whose exact detail read succeeded. Detail confirmed
8953
+ * the identity, so the failure belongs to that program's targets (a finished contest keeps
8954
+ * detail only) and never makes its ID a stale-ID candidate for a same-handle program.
8955
+ */
8956
+ class ConfirmedProgramTargetsError extends BBRadarApiError {
8957
+ }
8958
+ function allowsProgramIdFallback(error) {
8959
+ return isProgramNotFoundError(error) && !(error instanceof ConfirmedProgramTargetsError);
8960
+ }
8909
8961
  function runImmediately(callback) {
8910
8962
  return callback();
8911
8963
  }
@@ -9055,7 +9107,9 @@ Summarize the program scope:
9055
9107
  title: "Prepare Passive Recon Plan",
9056
9108
  description: "Create a passive-only plan from BBRadar program and target data. Use only when planning is explicitly requested.",
9057
9109
  argsSchema: {
9058
- program_id: programIdentifierSchema.optional(),
9110
+ // Zod 4 keeps a description on the schema it was set on, and prompt
9111
+ // arguments read the outer optional, so describe it again here.
9112
+ program_id: programIdentifierSchema.optional().describe(PROGRAM_IDENTIFIER_DESCRIPTION),
9059
9113
  focus: z.string().optional().describe("Optional focus area such as APIs, domains, mobile, or cloud.")
9060
9114
  }
9061
9115
  }, (args) => promptResult(`Prepare a passive-only plan from BBRadar data. Program id JSON string, if present: ${promptJson(args.program_id ?? "")}.