@bbradar/mcp 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.3.0 — Activity and payout views (2026-10-12)
4
+
5
+ - Added five Activity tools to the default 30-tool `core` profile: `get_activity_capabilities`, `get_activity_snapshot`, `get_activity_rankings`, `get_program_payouts`, and `get_activity_change_detail`. Rankings cover payouts, Value, scope, rewards, updates, reports, response, status and all changes. The `full` profile also exposes nine per-view aliases.
6
+ - Each ranking reads one bounded server page. Dynamic capabilities describe eligible period, basis, precision, sort and direction combinations; unsupported explicit views remain errors without period substitution.
7
+ - Rankings, capabilities and program payouts accept a published Activity `snapshot` binding. Snapshot IDs, opaque cursors, frozen cutoffs and 410 recovery guidance keep reads on one version. Activity event detail requires the originating page's snapshot.
8
+ - Added Value maps and label-specific shortlists with frozen percentiles, server-owned signals and explanations, and same-platform peer maps. Map points are returned with `include_map: true`; otherwise `map_points_omitted` reports their count. Ranking pages omit repeated capabilities.
9
+ - Preserved monetary decimal strings, payout bounds, zero/null distinctions, report populations, response definitions, source timestamps and authoritative activity summaries. Payout output schemas include the API's version identity and series fields.
10
+ - Added `opportunity_tiers` to rankings and every per-view alias. The any-of filter supports `elite`, `hot`, `strong`, and `potential`, uses tiers frozen into the Activity version, and is guarded by API capability negotiation.
11
+ - `find_programs` and `find_hunt_targets` combine server-side payout, scope, reward, update, report, response and status predicates with local discovery filters. Pages retain server order; `scan.complete` and `scan.next_cursor` report coverage and continue at the exact next row without mixing versions. Use these tools for combined Activity queries; `search_programs` and `get_opportunities` retain their catalog inputs.
12
+ - Negotiated catalog and Activity filter guards preserve canonical VDP modes and opportunity-kind filtering. Older or disabled APIs cannot silently drop explicit criteria. `find_hunt_targets` rejects unknown inputs, and each Activity tab rejects inputs belonging to another tab.
13
+ - Activity and combined catalog reads share the changes quota and two-request concurrency pool. Private responses are never retained in reusable caches. Activity and chronological failure circuits are isolated from healthy catalog and ranking reads.
14
+ - Reduced discovery overhead with open diagnostic envelope records, while preserving structured results and full-profile tools.
15
+ - Updated `@modelcontextprotocol/sdk` to 1.31.0 and refreshed patched transitive dependencies.
16
+ - Added fixture coverage for every Activity view, discovery response validation, exact identities, cursor and snapshot forwarding, no-store behavior, combined scans, capability guards and concurrency. Package-install smoke checks now exercise Activity and payouts; the deadline regression test accepts scheduling-dependent request counts within the shared budget.
17
+
3
18
  ## 0.2.6 — expanded Pro API
4
19
 
5
20
  - Added seven direct tools for semantic History, exact program activity, and daily analytics, using one data request per page and lazy change detail.
package/README.md CHANGED
@@ -135,6 +135,177 @@ Repository results preserve the API's comparison completeness/method/ancestry ev
135
135
 
136
136
  `search_programs`, `search_targets`, `get_recent_changes`, and `get_repository_changes` accept the API's optional stable `snapshot` token. Send `snapshot: "start"` on page 1 and reuse the returned `meta.snapshot` with identical filters until `meta.snapshot_expires_at`. Snapshot responses bypass the local TTL cache, and an expired snapshot 410 is not mistaken for an expired change-feed cursor. Snapshot creation receives a 60-second minimum request timeout because it performs consistency work and can be materially slower; the fast `find_hunt_targets` workflow intentionally keeps live indexed paging and the ordinary configured timeout.
137
137
 
138
+ ### Activity and payouts
139
+
140
+ Use `get_activity_capabilities` before choosing a ranking. Its versioned views give
141
+ the enabled metric, period, payout basis, precision, sort and direction together.
142
+ Each ranking requires validated values from at least three platforms before it is
143
+ enabled. User filters do not re-run that gate, so filtering a supported ranking to
144
+ one platform is valid. A 422 means the requested combination is unavailable; never
145
+ silently replace its period or basis.
146
+
147
+ | Tool | Purpose |
148
+ | --- | --- |
149
+ | `get_activity_capabilities` | Enabled views, directions, label filters and listing readiness |
150
+ | `get_activity_snapshot` | Identity of the current published Activity version; no rows |
151
+ | `get_program_payouts` | One exact program's monetary, received-report and response facts |
152
+ | `get_activity_rankings` | One server-ranked page of any Activity view, selected with `tab` |
153
+ | `get_activity_change_detail` | One bounded page of an exact Activity event's before/after items |
154
+
155
+ `get_activity_rankings` takes `tab`: `payouts` (reported or tracked, the two bases
156
+ kept separate), `value` (frozen Value map and label-specific shortlists),
157
+ `scope`, `rewards`, `updates`, `reports`, `response`, `status` (lifecycle events;
158
+ first seen is not a launch date) or `all_changes` (catalog events, including
159
+ eligible events for departed programs). Each tab accepts only its own inputs:
160
+ `basis`/`precision` for payouts, `category` for updates and all changes,
161
+ `change_type` for status, `launched_within` for reports and response, and
162
+ `report_change_min`/`report_change_max` for reports. Anything else fails before a
163
+ request instead of reaching a different ranking. The `full` profile also keeps
164
+ single-view aliases (`get_activity_payouts`, `get_activity_value`,
165
+ `get_activity_scope`, `get_activity_rewards`, `get_activity_updates`,
166
+ `get_activity_reports`, `get_activity_response`, `get_activity_status` and
167
+ `get_activity_changes`) with the same request path.
168
+
169
+ Every tab also accepts `opportunity_tiers` (any of `elite`, `hot`, `strong`,
170
+ `potential`): the catalog opportunity tier frozen into the Activity version, so a
171
+ ranking can be narrowed to, for example, Elite and Hot programs.
172
+ `get_activity_capabilities` lists the tiers present in `facets.opportunity_tiers`.
173
+ An API that does not advertise the filter is told so before any request.
174
+
175
+ Ranking pages omit the capabilities document the API repeats on every page; read it
176
+ once with `get_activity_capabilities`. When a tab has no enabled default view the
177
+ empty page carries that tab's `available_views` instead.
178
+
179
+ `tab: "value"` has no period input. With `include_map: true` its `points` cover the
180
+ full filtered map population from the same snapshot (up to 5,000); by default the
181
+ map is replaced by `map_points_omitted` with its count, because thousands of points
182
+ rarely help a text answer. `rows` are a separately paginated shortlist. Percentiles stay frozen within their original platform cohorts after
183
+ filtering. Label-specific shortlists use their own criteria, so a small-cohort
184
+ “No payouts seen” program can appear without map percentiles. Keep its received
185
+ count and Tracked/Reported basis together. Explicit unsupported combinations are
186
+ rejected rather than replaced.
187
+
188
+ Rankings and `get_program_payouts` return server-owned `signals`, `labels` and
189
+ `label_details`, including explanations and destinations. Program detail also
190
+ returns `value_points` for same-platform peers when called with `include_map: true`
191
+ (otherwise `map_points_omitted` gives their count). “Last payout seen” describes an
192
+ observed counter increase, never a payment date. “Pays near top” is the platform's
193
+ top-quarter rule: retain the actual amount/percentage in the explanation. Processed
194
+ vs received can exceed 100% and does not measure response speed. Never recalculate
195
+ these signals or percentiles over one returned page.
196
+
197
+ For Activity event detail, supply the event ID and the source page's
198
+ `meta.snapshot_id` as `snapshot`. Keep that snapshot and page size with any detail
199
+ cursor. Legacy `get_history_change_detail` retains its existing History contract.
200
+
201
+ The ranking tools share `GET /v1/pro/activity/rankings`, fetch one bounded server
202
+ page and return the frozen `meta.as_of`, snapshot ID and opaque `next_cursor`.
203
+ On Activity 410, restart the same ranking without its cursor. For event details,
204
+ refresh the originating Activity page and reuse its new `meta.snapshot_id`.
205
+ Activity ranking tools do not accept `snapshot: "start"` or numeric `after_id`.
206
+ Activity is published in versions about every 30 seconds. `get_activity_snapshot`
207
+ reports the current version; pass its `snapshot_id` as `snapshot` to read
208
+ capabilities, a first page or program payouts from that exact version. Starting a
209
+ read on a version requires at least five minutes of remaining lifetime; an expired
210
+ or withdrawn version returns 410, after which you restart without the binding.
211
+ Never combine rows from different versions. `as_of` is when BBRadar froze its
212
+ calculation, not when a provider paid or updated anything.
213
+ Reuse the same filters, ordering and limit with that cursor. On expiry, restart the
214
+ query; do not merge different snapshots. No browser-like crawling, provider reads
215
+ or client-side global ranking is performed. Reads use the existing shared quota
216
+ and two-request concurrency pool, including per-program payout detail. Private
217
+ results and dynamic capabilities are never retained in reusable response caches.
218
+
219
+ For example, after confirming the combination is enabled, call
220
+ `get_activity_rankings` with
221
+ `{"tab":"payouts","period":"30d","basis":"tracked","precision":"exact","sort":"paid","direction":"desc","limit":25}`
222
+ for the largest measured increases in the **last 30 days**. The periods are rolling
223
+ windows, not calendar months. Resolve "lately" consistently for high and low payout
224
+ queries: prefer an enabled tracked 30-day comparison, or name a separately ranked
225
+ reported 90-day comparison. An explicit 7- or 30-day request never falls back to a
226
+ 90-day or lifetime amount.
227
+
228
+ With `precision: "proven_bounds"`, descending means **Highest reported minimum**
229
+ using each value's lower bound; ascending means **Lowest reported maximum** using
230
+ its upper bound. Read `sort_directions` because these two orders qualify
231
+ independently. Keep `>`, `<`, interval notation, currency and observation dates in
232
+ the answer. A first baseline is not a payout, a counter increase is not a payment
233
+ date, and a hidden or ambiguous value is not zero.
234
+
235
+ Reports and resolved counters support `7d`, `30d`, `90d`, `all_time`, and API-only
236
+ `24h` where enabled. All-time uses the fresh lifetime counter; it has no Previous
237
+ or Change. Finite report comparisons retain the same basis within each program;
238
+ report volume does not establish researcher competition. Scope, Rewards, Updates,
239
+ Status and All changes support only `7d`, `30d` and `90d`. Response counts retain
240
+ the provider's term; time to bounty measures award timing, not settlement. Label
241
+ filters must come from current capabilities, and unsupported labels remain errors.
242
+
243
+ Use exact `platform:handle` identities for `get_program_payouts`; slash-containing
244
+ handles and literal percent signs are preserved. `null`, zero, bounds, report
245
+ definitions and source timestamps remain intact in structured output. Human
246
+ summaries should show the available numbers and units, keeping internal collection
247
+ and completeness metadata in the structured result.
248
+
249
+ `find_programs` and `find_hunt_targets` accept combined Activity predicates
250
+ (`search_programs` and `get_opportunities` reject them; use `find_programs`, with one
251
+ `opportunity_levels` entry for a tier). The API applies them together before
252
+ pagination, preserving one global ordering and one stable snapshot. For example, use
253
+ `{"payout_period":"30d","payout_metric":"payout_increase","payout_min":1000,"reports_period":"7d","reports_max":10,"sort_by":"payout_amount","sort_direction":"desc"}`
254
+ to combine measured payouts in the last 30 days with report activity in the last
255
+ 7 days. Add `snapshot: "start"` on the first page and reuse `meta.snapshot` with
256
+ identical filters. The default order remains `updated` descending. Bounds sorts
257
+ require `proven_bounds`: `payout_lower_bound` descends and `payout_upper_bound`
258
+ ascends. `payout_positive: false` requires an exact observed zero; omit it when
259
+ both zero and positive payers should qualify.
260
+
261
+ Combined Activity queries require negotiated `activity_filters.enabled=true`.
262
+ Catalog-only program type, canonical VDP mode and `updated` ordering use the
263
+ separately advertised `catalog_filters` contract, so ordinary discovery remains
264
+ available when Activity is disabled. Server-ranked VDP choices are `include`,
265
+ `exclude` and `only`; legacy heuristic `only_likely` / `only_known_no_bounty`
266
+ choices remain in sampled discovery and are never silently mapped to a different
267
+ server predicate. Opportunity kind filters require advertised support and run
268
+ before pagination. Legacy search plus local-only discovery filters remains a
269
+ sampled workflow and is never labelled as a complete server ranking.
270
+
271
+ The combined inputs cover scope movement, reward changes, active days and change
272
+ counts, report levels and change, resolved counts, response timings, lifecycle
273
+ events, canonical launch cohorts and advertised activity labels. Give each
274
+ period-based signal its own period. `no_increase` and `lower` payout activity use
275
+ tracked 30-day values. All-time report comparisons, unsupported precision or
276
+ coverage states, automatic `recent` cohorts, `resolution_gap`, and
277
+ `opportunity_score` ordering are unavailable in this release; explicit unsupported
278
+ requests fail instead of silently substituting another metric.
279
+
280
+ With combined inputs alone, `find_programs` reads exactly one server page;
281
+ `max_results` sets its size and `page` continues it. Combined inputs also work
282
+ together with local filters such as `exclude_web3`, `min_wildcards`,
283
+ `min_eligible_targets`, bounty and report-count limits, `competition_risks` and
284
+ `free_submission_only` (no detected submission fee or deposit), plus target samples.
285
+ The MCP then reads full server pages in the server's order and the local filters
286
+ only remove programs, so the result is still a prefix of the global order. It reads
287
+ one page first and prefetches at most two more at a time when the observed match
288
+ rate shows they are needed. `scan.complete` says whether the whole server result
289
+ was examined; otherwise `scan.next_cursor` continues at the exact next row with the
290
+ same filters. A scan never mixes Activity versions: within one call it restarts
291
+ once on the new version, and a cursor from an older version fails with a restart
292
+ instruction.
293
+
294
+ For example, "a program with recent payouts, low competition, new scope, wide Web2
295
+ scope and no submission fee" is one `find_programs` call:
296
+ `{"payout_period":"30d","payout_positive":true,"scope_period":"30d","scope_added_min":1,"exclude_web3":true,"min_wildcards":1,"competition_risks":["Low"],"free_submission_only":true}`.
297
+ `find_hunt_targets` accepts the same Activity inputs (with `surface_mode: "web2"`
298
+ and `free_submission_only`) and returns ranked finalists with exact target samples.
299
+ Activity-filtered catalog reads count toward the API's shared changes quota, so the
300
+ MCP routes them through the same two-request concurrency pool as rankings and
301
+ change feeds.
302
+
303
+ It accepts at most one opportunity tier, and sampled sorts such as
304
+ `best_hunt_value` stay in legacy discovery, which still works when no combined
305
+ inputs are supplied. Do not form combined results by intersecting separately
306
+ paginated tools. VDP mode is applied on the server for combined queries,
307
+ preserving unknown reward types under `exclude`.
308
+
138
309
  ### Pro History and analytics
139
310
 
140
311
  The `2026-09-24` API contract maps to these direct MCP tools. Each call reads one page unless the caller explicitly supplies a cursor for the next page. Change detail is fetched only when requested.
@@ -179,7 +350,7 @@ For cacheable responses, large target and change collections return at most 50 i
179
350
 
180
351
  ## Performance Tuning
181
352
 
182
- The defaults expose the 25-tool core surface and compact text results while enabling bounded concurrency, in-flight request coalescing, endpoint circuit breakers, and response caching only when upstream cache policy permits it. The Pro deployment currently sends `private, no-store`, so its responses are not placed in the reusable response cache or stale-while-revalidate cache. Set the tool profile to `full` when all specialized tools are needed.
353
+ The defaults expose the 30-tool core surface and compact text results while enabling bounded concurrency, in-flight request coalescing, endpoint circuit breakers, and response caching only when upstream cache policy permits it. The Pro deployment currently sends `private, no-store`, so its responses are not placed in the reusable response cache or stale-while-revalidate cache. Set the tool profile to `full` when all specialized tools are needed.
183
354
 
184
355
  | Environment variable | Default | Purpose |
185
356
  | --- | ---: | --- |
@@ -189,7 +360,7 @@ The defaults expose the 25-tool core surface and compact text results while enab
189
360
  | `BBRADAR_MCP_MAX_CONCURRENT_REQUESTS` | `6` | Maximum simultaneous upstream BBRadar API requests. |
190
361
  | `BBRADAR_MCP_REQUEST_TIMEOUT_SECONDS` | `30` | Total upstream request deadline, including queue time. |
191
362
  | `BBRADAR_MCP_COMPACT_TOOL_TEXT` | `true` | Avoid duplicating full results for clients that consume MCP `structuredContent`. Set to `false` for legacy text-only clients. |
192
- | `BBRADAR_MCP_TOOL_PROFILE` | `core` | Compact 25-tool discovery surface. Set to `full` for every specialized tool. |
363
+ | `BBRADAR_MCP_TOOL_PROFILE` | `core` | Compact 30-tool discovery surface. Set to `full` for every specialized tool. |
193
364
  | `BBRADAR_MCP_PROGRAM_STALE_WHILE_REVALIDATE_SECONDS` | `300` | Maximum stale window for exact program metadata while one background refresh runs. |
194
365
  | `BBRADAR_MCP_CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `3` | Consecutive retryable endpoint failures before its circuit opens. |
195
366
  | `BBRADAR_MCP_CIRCUIT_BREAKER_COOLDOWN_SECONDS` | `30` | Endpoint circuit cooldown before a new request is admitted. |
@@ -220,7 +391,7 @@ Persisted numeric feed checkpoints are scoped to the API destination and credent
220
391
 
221
392
  Use `find_hunt_targets` as the default one-call path when a request combines good rewards, wide scope, Web2/Web3 constraints, competition, target quality, or recently added targets. It performs one catalog pass, fetches the bounded recent-additions feed in parallel, enriches only the finalists with exact in-scope bounty-eligible target samples, and returns a final compact ranking. Its result explicitly requires no follow-up calls; use `get_program_targets` only when the user asks for an exhaustive scope dump for one selected program.
222
393
 
223
- `find_hunt_targets` supports arbitrary platform inclusion/exclusion, scope/target/language filters, reward and breadth thresholds, Web2/Web3 modes, target-intelligence filters, freshness windows, and balanced/reward/scope/freshness/competition ranking. It suppresses bulk `bbradar://` resource links while preserving each program's public BBRadar and platform URLs, preventing large shortlists from flooding model context.
394
+ `find_hunt_targets` supports arbitrary platform inclusion/exclusion, scope/target/language filters, reward and breadth thresholds, Web2/Web3 modes, target-intelligence filters, freshness windows, `free_submission_only`, and balanced/reward/scope/freshness/competition ranking. Activity inputs such as `payout_period: "30d"` with `payout_positive: true`, or `scope_period: "30d"` with `scope_added_min: 1`, first narrow its candidates on the server in global order; the remaining filters and `ranking_mode` then apply locally, and `scan` reports how much of the server result was examined. Unknown inputs are rejected rather than ignored. It suppresses bulk `bbradar://` resource links while preserving each program's public BBRadar and platform URLs, preventing large shortlists from flooding model context.
224
395
 
225
396
  ```json
226
397
  {
@@ -269,6 +440,20 @@ This path uses one recent-changes request. Use `find_wildcard_programs` for a ra
269
440
  - `get_history_change_detail`
270
441
  - `get_program_history`
271
442
  - `get_program_activity`
443
+ - `get_activity_capabilities`
444
+ - `get_activity_snapshot`
445
+ - `get_activity_rankings`
446
+ - `get_program_payouts`
447
+ - `get_activity_payouts`
448
+ - `get_activity_value`
449
+ - `get_activity_scope`
450
+ - `get_activity_rewards`
451
+ - `get_activity_updates`
452
+ - `get_activity_reports`
453
+ - `get_activity_response`
454
+ - `get_activity_status`
455
+ - `get_activity_changes`
456
+ - `get_activity_change_detail`
272
457
  - `get_analytics_platform_series`
273
458
  - `get_analytics_program_activity`
274
459
  - `get_analytics_program_detail`
@@ -12,14 +12,29 @@ const responses = {
12
12
  "/pro/activity/programs/HackerOne:bench": { program_id: "HackerOne:bench", activity_summary: { additions: 0, last_observed_at: null } },
13
13
  "/pro/analytics/programs/activity": { programs: historyRows.map((row) => ({ id: row.program.id, activity_days: 1 })), meta: { generation: 3, provisional: false, unsupported_metrics: ["repository_changes"] } }
14
14
  };
15
+ // Activity catalog: every fifth program passes the Web2, wildcard, competition
16
+ // and fee filters of the combined benchmark question.
17
+ const catalog = Array.from({ length: 250 }, (_, i) => ({ id: `HackerOne:p${i}`, platform: "HackerOne", handle: `p${i}`, name: `Program ${i}`, program_type: "paid", is_active: true,
18
+ bounty_min: 100, bounty_max: 5000, report_count: 10, competition_risk: i % 5 === 3 ? "High" : "Low",
19
+ scope_tags: i % 5 === 1 ? ["smart-contract"] : ["domain", "api", "wildcard"], submission_cost: i % 5 === 2 ? { amount: 25, maximum_amount: 25 } : null,
20
+ target_counts: { total: 30, in_scope: 30, eligible_for_bounty: 30, wildcard: i % 5 === 4 ? 0 : 3 }, payout_summary: { "30d": { amount: "1500", currency: "USD" } } }));
21
+ const catalogEndpoint = { enabled: true, max_page_size: 100, activity_filters: { enabled: true }, catalog_filters: { enabled: true } };
22
+ function catalogPage(url) {
23
+ const page = Number(url.searchParams.get("page") ?? 1), size = Number(url.searchParams.get("page_size") ?? 50);
24
+ return { programs: catalog.slice((page - 1) * size, page * size), meta: { page, page_size: size, total: catalog.length, total_pages: Math.ceil(catalog.length / size), activity_snapshot_id: "a".repeat(64) } };
25
+ }
15
26
  const api = http.createServer(async (req, res) => {
16
- const path = new URL(req.url, "http://fixture").pathname.replace(/^\/api\/v1/, "");
27
+ const url = new URL(req.url, "http://fixture");
28
+ const path = url.pathname.replace(/^\/api\/v1/, "");
17
29
  counters.requests += 1;
18
30
  counters.active += 1;
19
31
  counters.peak = Math.max(counters.peak, counters.active);
20
32
  await new Promise((resolve) => setTimeout(resolve, 5));
21
33
  const payload = path === "/pro/capabilities"
22
- ? { contract_version: "2026-09-24", endpoints: Object.fromEntries(["history_changes", "history_change_detail", "program_activity", "analytics_program_activity"].map((key) => [key, { enabled: true, history_capture_enabled: true }])) }
34
+ ? { contract_version: "2026-10-06", endpoints: { ...Object.fromEntries(["history_changes", "history_change_detail", "program_activity", "analytics_program_activity", "activity_rankings"].map((key) => [key, { enabled: true, history_capture_enabled: true }])), programs: catalogEndpoint, opportunities: catalogEndpoint, program_targets: { max_page_size: 500 } } }
35
+ : path === "/pro/programs" ? catalogPage(url)
36
+ : /^\/pro\/programs\/.+\/targets$/.test(path) ? { targets: [{ identifier: "api.example.test", target_type: "domain", in_scope: true, eligible_for_bounty: true }], meta: { total: 1, total_pages: 1 } }
37
+ : path === "/pro/activity/rankings" ? { tab: "payouts", period: "30d", sort: "paid", direction: "desc", rows: catalog.slice(0, 10).map((program) => ({ program, paid: program.payout_summary["30d"] })), capabilities: { tabs: [{ id: "payouts", views: [] }], facets: { tags: Array.from({ length: 300 }, (_, i) => `tag-${i}`) } }, meta: { total: 250, has_more: true, next_cursor: "next", snapshot_id: "a".repeat(64) } }
23
38
  : path.startsWith("/pro/history/changes/")
24
39
  ? { change: historyRows[0], items: [{ before: null, after: { count: 0 } }], meta: { has_more: false } }
25
40
  : responses[path] ?? { detail: "missing" };
@@ -75,10 +90,17 @@ try {
75
90
  const detail = await measure("get_history_change_detail", { change_id: "c0", limit: 1 });
76
91
  const activity = await measure("get_program_activity", { program_id: "HackerOne:bench" });
77
92
  const analytics = await measure("get_analytics_program_activity", { limit: 10 });
93
+ const activityRanking = await measure("get_activity_rankings", { tab: "payouts", period: "30d", limit: 10 });
94
+ // "Recent payouts, low competition, new scope, wide Web2 scope, no submission fee" in one call.
95
+ const question = { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, exclude_web3: true, min_wildcards: 1, competition_risks: ["Low"], free_submission_only: true };
96
+ const combinedQuestion = await measure("find_programs", { ...question, max_results: 10 });
97
+ const combinedHunt = await measure("find_hunt_targets", { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", min_wildcards: 1, free_submission_only: true, include_recent_changes: false, max_results: 5 });
78
98
  reset();
79
99
  const heapBefore = process.memoryUsage().heapUsed;
80
100
  const parallel = await Promise.all(Array.from({ length: 10 }, (_, i) => client.callTool({ name: "get_history_changes", arguments: { search: `bench${i}`, limit: 1 } })));
81
101
  const concurrency = { calls: parallel.length, upstream_requests: counters.requests, peak_upstream_requests: counters.peak, heap_delta_bytes: process.memoryUsage().heapUsed - heapBefore };
82
- if ([history, detail, activity, analytics].some((entry) => entry.upstream_requests !== 20) || concurrency.peak_upstream_requests > 2 || concurrency.upstream_requests !== 10) throw new Error("Pro request-count or shared-concurrency regression");
83
- process.stdout.write(JSON.stringify({ fixture: true, simulated_upstream_delay_ms: 5, direct_history: directHistory, estimated_mcp_overhead_p50_ms: Math.round((history.p50_ms - directHistory.p50_ms) * 100) / 100, history_lazy_detail: { eager_baseline_requests: 11, feed_only_requests: 1, requested_detail_requests: 2 }, tools: { history, detail, activity, analytics }, concurrency }, null, 2) + "\n");
102
+ if ([history, detail, activity, analytics, activityRanking, combinedQuestion].some((entry) => entry.upstream_requests !== 20) || concurrency.peak_upstream_requests > 2 || concurrency.upstream_requests !== 10) throw new Error("Pro request-count or shared-concurrency regression");
103
+ // The candidate pool's catalog pages plus one exact target sample per finalist.
104
+ if (combinedHunt.upstream_requests > 20 * (3 + 5)) throw new Error(`Activity hunt request-count regression: ${combinedHunt.upstream_requests}`);
105
+ process.stdout.write(JSON.stringify({ fixture: true, simulated_upstream_delay_ms: 5, direct_history: directHistory, estimated_mcp_overhead_p50_ms: Math.round((history.p50_ms - directHistory.p50_ms) * 100) / 100, history_lazy_detail: { eager_baseline_requests: 11, feed_only_requests: 1, requested_detail_requests: 2 }, tools: { history, detail, activity, analytics, activity_ranking: activityRanking, combined_question: combinedQuestion, combined_hunt: combinedHunt }, concurrency }, null, 2) + "\n");
84
106
  } finally { await client.close(); await server.close(); api.close(); }
@@ -0,0 +1,2 @@
1
+ /** Catalog metadata stays available when Activity computation is disabled. */
2
+ export declare function requiresActivityQuery(input?: Record<string, unknown>): boolean;
@@ -0,0 +1,15 @@
1
+ const ACTIVITY_FILTERS = new Set([
2
+ "payout_metric", "payout_period", "payout_currency", "payout_precision", "payout_min", "payout_max",
3
+ "payout_positive", "payout_activity", "scope_period", "scope_added_min", "scope_removed_min",
4
+ "scope_net_min", "scope_net_max", "reward_period", "reward_change", "reward_change_min_pct",
5
+ "updates_period", "active_days_min", "changes_min", "change_category", "reports_period",
6
+ "reports_min", "reports_max", "reports_change", "reports_change_min_pct", "launched_within",
7
+ "resolved_period", "resolved_min", "last_resolved_within", "first_response_max_hours",
8
+ "time_to_bounty_max_hours", "status_period", "status_change", "current_status", "activity_labels",
9
+ ]);
10
+ /** Catalog metadata stays available when Activity computation is disabled. */
11
+ export function requiresActivityQuery(input = {}) {
12
+ return Object.entries(input).some(([key, value]) => value !== undefined && value !== null
13
+ && (key === "sort_by" ? value !== "updated" : ACTIVITY_FILTERS.has(key)));
14
+ }
15
+ //# sourceMappingURL=activityContract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activityContract.js","sourceRoot":"","sources":["../src/activityContract.ts"],"names":[],"mappings":"AAAA,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC;IAC/B,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,YAAY,EAAE,YAAY;IACnG,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,iBAAiB,EAAE,mBAAmB;IAC5F,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB;IAC3F,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,iBAAiB,EAAE,gBAAgB;IACvF,aAAa,EAAE,aAAa,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,iBAAiB;IAC3F,iBAAiB,EAAE,cAAc,EAAE,sBAAsB,EAAE,0BAA0B;IACrF,0BAA0B,EAAE,eAAe,EAAE,eAAe,EAAE,gBAAgB,EAAE,iBAAiB;CAClG,CAAC,CAAC;AAEH,8EAA8E;AAC9E,MAAM,UAAU,qBAAqB,CAAC,QAAiC,EAAE;IACvE,OAAO,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;WACpF,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAC9E,CAAC"}
@@ -203,6 +203,11 @@ export declare class BBRadarClient {
203
203
  getHistoryChangeDetail(changeId: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
204
204
  getProgramHistory(programId: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
205
205
  getProgramActivity(programId: string): Promise<ApiResult<unknown>>;
206
+ getActivityCapabilities(query?: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
207
+ getActivitySnapshot(): Promise<ApiResult<unknown>>;
208
+ getActivityRankings(query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
209
+ getActivityChangeDetail(eventId: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
210
+ getProgramPayouts(programId: string, query?: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
206
211
  getAnalyticsPlatformSeries(platform: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
207
212
  getAnalyticsProgramActivity(query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
208
213
  getAnalyticsProgramDetail(programId: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { requiresActivityQuery } from "./activityContract.js";
2
3
  import { AsyncLocalStorage } from "node:async_hooks";
3
4
  import { TtlCache } from "./cache.js";
4
5
  import { stableStringify } from "./json.js";
@@ -356,7 +357,8 @@ export class BBRadarClient {
356
357
  getOpportunities(level, query) {
357
358
  return this.request({
358
359
  path: `/pro/opportunities/${encodeURIComponent(level)}`,
359
- query
360
+ query,
361
+ ...snapshotAwareReadControls(query, {}, true, this.config.requestTimeoutMs)
360
362
  });
361
363
  }
362
364
  getHistoryChanges(query) {
@@ -371,6 +373,21 @@ export class BBRadarClient {
371
373
  getProgramActivity(programId) {
372
374
  return this.request({ path: `/pro/activity/programs/${encodeProgramIdPath(programId)}`, cache: false });
373
375
  }
376
+ getActivityCapabilities(query = {}) {
377
+ return this.request({ path: "/pro/activity/capabilities", query, cache: false });
378
+ }
379
+ getActivitySnapshot() {
380
+ return this.request({ path: "/pro/activity/snapshot", cache: false });
381
+ }
382
+ getActivityRankings(query) {
383
+ return this.request({ path: "/pro/activity/rankings", query, cache: false });
384
+ }
385
+ getActivityChangeDetail(eventId, query) {
386
+ return this.request({ path: `/pro/activity/events/${encodeURIComponent(eventId)}`, query, cache: false });
387
+ }
388
+ getProgramPayouts(programId, query = {}) {
389
+ return this.request({ path: `/pro/payouts/programs/${encodeProgramIdPath(programId)}`, query, cache: false });
390
+ }
374
391
  getAnalyticsPlatformSeries(platform, query) {
375
392
  return this.request({ path: `/pro/analytics/platforms/${encodeURIComponent(platform)}/series`, query, cache: false });
376
393
  }
@@ -524,11 +541,12 @@ export class BBRadarClient {
524
541
  });
525
542
  }
526
543
  }
527
- return this.withRequestSlot(options.path, async () => {
528
- const rateLimitBucket = rateLimitEndpointGroup(options.path);
544
+ return this.withRequestSlot(options.path, options.query, async () => {
545
+ const rateLimitBucket = rateLimitEndpointGroup(options.path, options.query);
546
+ const circuitBucket = activityCircuitBucket(options.path, options.query) ?? rateLimitBucket;
529
547
  if (options.signal?.aborted)
530
548
  throw new BBRadarApiError({ message: "BBRadar API request was cancelled.", requestId, status: 408 });
531
- this.assertCircuitClosed(requestId, rateLimitBucket);
549
+ this.assertCircuitClosed(requestId, circuitBucket);
532
550
  this.reserveUpstreamRequest(requestId, rateLimitBucket);
533
551
  const startedAt = Date.now();
534
552
  const controller = new AbortController();
@@ -554,7 +572,7 @@ export class BBRadarClient {
554
572
  const response = await fetch(url, init);
555
573
  status = response.status;
556
574
  const noStore = /(?:^|,)\s*(?:no-store|no-cache|private)\b/i.test(response.headers.get("cache-control") ?? "");
557
- this.observeCircuitResponse(rateLimitBucket, response.status);
575
+ this.observeCircuitResponse(circuitBucket, response.status);
558
576
  this.observeUpstreamRateLimit(rateLimitBucket, response.headers, response.status);
559
577
  const upstreamRequestId = extractRequestId(response.headers);
560
578
  const data = await parseResponseBody(response);
@@ -593,10 +611,10 @@ export class BBRadarClient {
593
611
  if (options.signal?.aborted) {
594
612
  const timedOut = options.signal.reason instanceof Error && options.signal.reason.name === "TimeoutError";
595
613
  if (timedOut)
596
- this.observeCircuitFailure(rateLimitBucket);
614
+ this.observeCircuitFailure(circuitBucket);
597
615
  throw new BBRadarApiError({ message: timedOut ? "BBRadar API tool deadline exceeded." : "BBRadar API request was cancelled.", requestId, status: 408 });
598
616
  }
599
- this.observeCircuitFailure(rateLimitBucket);
617
+ this.observeCircuitFailure(circuitBucket);
600
618
  throw new BBRadarApiError({
601
619
  message: error instanceof Error && error.name === "AbortError" ? "BBRadar API request timed out." : "BBRadar API request failed.",
602
620
  requestId,
@@ -633,8 +651,8 @@ export class BBRadarClient {
633
651
  }
634
652
  });
635
653
  }
636
- async withRequestSlot(path, callback, deadline, requestId, signal) {
637
- const endpointGroup = concurrencyEndpointGroup(path);
654
+ async withRequestSlot(path, query, callback, deadline, requestId, signal) {
655
+ const endpointGroup = concurrencyEndpointGroup(path, query);
638
656
  if (endpointGroup === undefined) {
639
657
  return this.withGlobalRequestSlot(callback, deadline, requestId, signal);
640
658
  }
@@ -1073,6 +1091,10 @@ function metricEndpoint(path) {
1073
1091
  return "/pro/history/programs/:program_id";
1074
1092
  if (/^\/pro\/activity\/programs\/.+/.test(path))
1075
1093
  return "/pro/activity/programs/:program_id";
1094
+ if (/^\/pro\/activity\/events\/.+/.test(path))
1095
+ return "/pro/activity/events/:event_id";
1096
+ if (/^\/pro\/payouts\/programs\/.+/.test(path))
1097
+ return "/pro/payouts/programs/:program_id";
1076
1098
  if (/^\/pro\/analytics\/platforms\/.+\/series$/.test(path))
1077
1099
  return "/pro/analytics/platforms/:platform/series";
1078
1100
  if (/^\/pro\/analytics\/programs\/.+/.test(path) && path !== "/pro/analytics/programs/activity")
@@ -1088,8 +1110,26 @@ function metricEndpoint(path) {
1088
1110
  }
1089
1111
  return path;
1090
1112
  }
1091
- function rateLimitEndpointGroup(path) {
1092
- if (path === "/pro/targets/changes" || /^\/pro\/(?:history|activity|analytics)\//.test(path)) {
1113
+ // The API admits Activity-filtered catalog reads in its heavy "changes" group,
1114
+ // sharing that quota and in-flight limit with the change feeds.
1115
+ function isActivityCatalogRead(path, query = {}) {
1116
+ return (path === "/pro/programs" || path.startsWith("/pro/opportunities/")) && requiresActivityQuery(query);
1117
+ }
1118
+ function activityCircuitBucket(path, query = {}) {
1119
+ const activityCatalog = isActivityCatalogRead(path, query);
1120
+ // Chronological feeds can exceed their own projection budget while complete
1121
+ // rankings remain healthy. Keep this a finite group, never a caller-keyed map.
1122
+ if ((path === "/pro/activity/rankings" && (query.tab === "status" || query.tab === "all_changes"))
1123
+ || path.startsWith("/pro/activity/events/")
1124
+ || (activityCatalog && query.status_change !== undefined && query.status_change !== null)) {
1125
+ return "activity_chronological";
1126
+ }
1127
+ if (/^\/pro\/(?:activity|payouts)\//.test(path))
1128
+ return "activity_reads";
1129
+ return activityCatalog ? "activity_catalog" : undefined;
1130
+ }
1131
+ function rateLimitEndpointGroup(path, query) {
1132
+ if (path === "/pro/targets/changes" || /^\/pro\/(?:history|activity|analytics|payouts)\//.test(path) || isActivityCatalogRead(path, query)) {
1093
1133
  return "change_feeds";
1094
1134
  }
1095
1135
  if (path === "/pro/repositories/changes") {
@@ -1112,8 +1152,9 @@ function rateLimitEndpointGroup(path) {
1112
1152
  }
1113
1153
  return "other";
1114
1154
  }
1115
- function concurrencyEndpointGroup(path) {
1116
- if (path === "/pro/targets/changes" || path === "/pro/repositories/changes" || /^\/pro\/(?:history|activity|analytics)\//.test(path)) {
1155
+ function concurrencyEndpointGroup(path, query) {
1156
+ if (path === "/pro/targets/changes" || path === "/pro/repositories/changes" || /^\/pro\/(?:history|activity|analytics|payouts)\//.test(path)
1157
+ || isActivityCatalogRead(path, query)) {
1117
1158
  return "change_feeds";
1118
1159
  }
1119
1160
  if (path === "/pro/targets/export") {