@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 +15 -0
- package/README.md +188 -3
- package/benchmark/pro-benchmark.mjs +26 -4
- package/dist/activityContract.d.ts +2 -0
- package/dist/activityContract.js +15 -0
- package/dist/activityContract.js.map +1 -0
- package/dist/bbradarClient.d.ts +5 -0
- package/dist/bbradarClient.js +54 -13
- package/dist/bbradarClient.js.map +1 -1
- package/dist/sanitize.js +3 -0
- package/dist/sanitize.js.map +1 -1
- package/dist/server.js +619 -60
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
|
|
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,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"}
|
package/dist/bbradarClient.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/bbradarClient.js
CHANGED
|
@@ -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,
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1092
|
-
|
|
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") {
|