@bbradar/mcp 0.2.5 → 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 ADDED
@@ -0,0 +1,28 @@
1
+ # Release notes
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
+
18
+ ## 0.2.6 — expanded Pro API
19
+
20
+ - Added seven direct tools for semantic History, exact program activity, and daily analytics, using one data request per page and lazy change detail.
21
+ - Added `program_kind` filtering and source-classified contest discovery. Program projections now retain schedule precision, lifecycle state, fee metadata, Self-Hosted verification, and archive/catalog identity.
22
+ - Preserved the distinct pagination models: numeric target/repository bookmarks, stable snapshot tokens, and five-minute opaque History/analytics cursors. Snapshot continuation now advances pages without mixing tokens.
23
+ - Routed new reads through the existing shared changes quota and two-request concurrency pool. Added a 100-request queue ceiling and a total request deadline that includes queue time.
24
+ - Connected MCP cancellation and a total tool deadline to HTTP requests, including compatibility with Node 20.0.0. Cancelling one caller preserves an in-flight request needed by another caller; unused work is aborted without opening the circuit breaker.
25
+ - Preserved explicit unknown fee, report-count, and scoring values in compact results, and kept case-sensitive program IDs distinct in fallback caches.
26
+ - Scoped persisted numeric feed checkpoints to the API destination and credential. Existing 0.2.5 persisted streams need one explicit `after_id` seed after upgrading.
27
+ - Honored upstream `private` and `no-store` on success and error caches, including stale refreshes. Private results are not kept in local continuation or export resources.
28
+ - Added deterministic route, schema, privacy, package-installation, and benchmark coverage.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @bbradar/mcp
2
2
 
3
- Local STDIO MCP server for [bbradar.io](https://bbradar.io) Pro subscribers. It lets MCP clients query [bbradar.io](https://bbradar.io) platform and Self-Hosted program profiles, active targets, target-level opportunity intelligence, recent target changes, and repository-change events through a local npm package.
3
+ Local STDIO MCP server for [bbradar.io](https://bbradar.io) Pro subscribers. It exposes program and target discovery, semantic History, exact activity, daily analytics, and repository-change events through a local npm package.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -115,6 +115,7 @@ Most tools that accept `program_id` also accept a human program name or handle.
115
115
  Self-Hosted programs use IDs such as `Self-Hosted:program-slug`. They are available through program search, exact program detail, reverse target search, program-target tools, and the normal `find_hunt_targets` ranking path. Their `data_capabilities` field identifies unavailable source signals; the MCP server does not invent opportunity scores, public report counts, target-change history, submission costs, repository changes, or numeric competition evidence for that catalog. A Self-Hosted-only hunt skips the unsupported target-change feed and derives recent-target evidence from current inventory timestamps. A Self-Hosted-only repository hunt returns immediately with guidance instead of spending upstream requests on an unsupported feed; mixed repository hunts omit Self-Hosted and continue with supported platforms.
116
116
 
117
117
  `search_programs` accepts a `search` value and sends it to the API for indexed server-side filtering. Name resolution uses the same path automatically, avoiding broad page scans for exact or partial program names.
118
+ Use `program_kind: "bounty"`, `"contest"`, or `"challenge"` with `search_programs` or `find_programs` for source-classified program filtering. `find_web3_contests` uses this classification instead of inferring contests from a name or platform.
118
119
  The program catalog now includes confirmed paused platform programs. Raw search preserves `program_status` and `is_active`; ranked discovery excludes paused programs so retained scope and historical rewards cannot be presented as huntable. Exact paused-program reads remain available when explicitly requested.
119
120
 
120
121
  The full tool profile also exposes `search_targets`, a one-request low-level wrapper for reverse search and paginated current target inventory. It supports optional `q`, all API match modes, platform/tag filters, eligibility controls, pages up to the negotiated 500-row limit, and compact or full target output.
@@ -122,7 +123,7 @@ The full tool profile also exposes `search_targets`, a one-request low-level wra
122
123
  `find_programs_by_target` defaults to flexible target matching. It accepts bare domains, full URLs, wildcard scopes, target names, package/repo-style identifiers, or short phrases like `known api target`.
123
124
  It uses the Pro API's direct reverse-target-search route first, including for Self-Hosted targets, and only falls back to sampled target lists when an older API deployment does not expose that route.
124
125
 
125
- `export_targets` uses the bounded bearer `POST /api/v1/pro/targets/export` route directly. JSON exports preserve `program_id` on every target; CSV previews are capped in the tool result while the complete payload remains available for ten minutes through the returned `bbradar://exports` resource. The local resource store is capped at 25 entries and 64 MiB in aggregate, and CSV formula/control-byte neutralization is applied independently of the API.
126
+ `export_targets` uses the bounded bearer `POST /api/v1/pro/targets/export` route directly. JSON exports preserve `program_id` on every target. When the API marks a response private or `no-store`, the complete sanitized export is returned only in the current structured tool result; it is not saved to a reusable resource. For responses that permit storage, the complete payload remains available for ten minutes through `bbradar://exports`. The local resource store is capped at 25 entries and 64 MiB in aggregate, and CSV formula/control-byte neutralization is applied independently of the API.
126
127
 
127
128
  `get_program_targets` forwards text, target type, eligibility, minimum Target Opportunity Score, Target Opportunity Label, and competition-risk filters to the API. Compact output retains the score, label, competition risk, and Why This Target; full output also includes the score breakdown and reason fields.
128
129
  When no local scope filtering is needed, offsets begin at the corresponding upstream
@@ -134,6 +135,199 @@ Repository results preserve the API's comparison completeness/method/ancestry ev
134
135
 
135
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.
136
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
+
309
+ ### Pro History and analytics
310
+
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.
312
+
313
+ | Pro API route | MCP tool | Page default |
314
+ | --- | --- | ---: |
315
+ | `GET /v1/pro/history/changes` | `get_history_changes` | 50 |
316
+ | `GET /v1/pro/history/changes/{change_id}` | `get_history_change_detail` | 50 |
317
+ | `GET /v1/pro/history/programs/{program_id}` | `get_program_history` | 50 |
318
+ | `GET /v1/pro/activity/programs/{program_id}` | `get_program_activity` | one summary |
319
+ | `GET /v1/pro/analytics/platforms/{platform}/series` | `get_analytics_platform_series` | 100 days |
320
+ | `GET /v1/pro/analytics/programs/activity` | `get_analytics_program_activity` | 50 programs |
321
+ | `GET /v1/pro/analytics/programs/{program_id}` | `get_analytics_program_detail` | 100 days |
322
+
323
+ History defaults to 90 days; `days: 0` requests all retained History for an exact program. Use the scoped `get_program_history` route for `Self-Hosted:slug` IDs. A History response contains `coverage`, `tracking_since`, `observed_at`, `published_at`, and `provider_revision`; an empty `changes` array with `capture_enabled: false` means unavailable capture rather than a confirmed absence of changes. `catalog_id` identifies a Self-Hosted catalog record for later catalog reads, while `id` preserves the archive identity. Program IDs with slashes, literal percent signs, and case distinctions are preserved.
324
+
325
+ Analytics dates are inclusive UTC `YYYY-MM-DD`. Omit both dates for the API's latest 90-day interval. The daily retention window is 36 calendar months. The category ranking supports `all`, `scope`, `rewards`, `rules`, `schedule`, `costs`, and `lifecycle`; repository metrics remain unsupported. Inspect `model_version`, `generation`, `projection_pending`, `provisional`, `unsupported_metrics`, and observation coverage. A carried inventory state is not a fresh source verification, and `null` is not measured zero. Use `get_analytics_program_activity` for server-side activity ranking; the program-list API has no activity sort. Other client-side rankings remain bounded samples, not complete catalog sorts.
326
+
327
+ For example, call `get_program_history` with `{ "program_id": "Self-Hosted:program-slug", "days": 90, "limit": 50 }`, then pass `meta.next_cursor` back with the same arguments for the next page. Call `get_history_change_detail` with a returned change ID only when before/after items are needed. `get_program_activity` reads the exact `activity_summary`; `summarize_program_activity` keeps its target-change samples explicitly separate from semantic History.
328
+
329
+ History and analytics cursors are signed, opaque, and expire after five minutes. Reuse them with the same filters and page size; never persist them as numeric feed checkpoints. On 410, restart an expired History or analytics query, start a new snapshot for an expired snapshot token, reconcile inventory for a retained numeric-feed gap, or choose supported analytics dates when outside retention. Do not combine pages from different snapshots or analytics generations. The target-change feed describes latest retained state and is not a complete immutable event journal.
330
+
137
331
  If a cursor predates retained API history, the MCP refreshes the relevant current
138
332
  target or repository-change inventory within the same page/time bounds. It advances to a new
139
333
  baseline only after the bounded inventory completes and unchanged feed probes prove
@@ -152,11 +346,11 @@ inventory ends. Missing public scope is treated as out of scope.
152
346
 
153
347
  Cursors are memory-only by default. Set `BBRADAR_MCP_CURSOR_STATE_PATH` to opt into restart-safe cursor persistence. The state file contains only endpoint-prefixed SHA-256 filter hashes and non-negative numeric IDs—never raw filters, API responses, program or target records, credentials, or authorization headers.
154
348
 
155
- Large target and change collections return at most 50 inline records by default. The remainder is held in a bounded, ten-minute in-memory continuation and exposed through a `bbradar://continuations` resource link. This keeps model context small without losing access to the complete bounded result.
349
+ For cacheable responses, large target and change collections return at most 50 inline records by default; the remainder is held in a bounded, ten-minute continuation resource. Private or `no-store` pages stay in the current structured result and are never put in a reusable continuation. Compact tool text keeps model context small.
156
350
 
157
351
  ## Performance Tuning
158
352
 
159
- The defaults expose the 18-tool core surface and compact text results while enabling bounded concurrency, positive response caching, in-flight request coalescing, stale-while-revalidate program metadata, endpoint circuit breakers, and short negative caching for repeated 404/405 lookups. 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.
160
354
 
161
355
  | Environment variable | Default | Purpose |
162
356
  | --- | ---: | --- |
@@ -164,9 +358,9 @@ The defaults expose the 18-tool core surface and compact text results while enab
164
358
  | `BBRADAR_MCP_NEGATIVE_CACHE_TTL_SECONDS` | `15` | Repeated 404/405 cache lifetime. Set to `0` to disable. |
165
359
  | `BBRADAR_MCP_CACHE_MAX_ENTRIES` | `500` | Maximum entries in each local response cache. |
166
360
  | `BBRADAR_MCP_MAX_CONCURRENT_REQUESTS` | `6` | Maximum simultaneous upstream BBRadar API requests. |
167
- | `BBRADAR_MCP_REQUEST_TIMEOUT_SECONDS` | `30` | Timeout for each active upstream request; queue time is excluded. |
361
+ | `BBRADAR_MCP_REQUEST_TIMEOUT_SECONDS` | `30` | Total upstream request deadline, including queue time. |
168
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. |
169
- | `BBRADAR_MCP_TOOL_PROFILE` | `core` | Compact 18-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. |
170
364
  | `BBRADAR_MCP_PROGRAM_STALE_WHILE_REVALIDATE_SECONDS` | `300` | Maximum stale window for exact program metadata while one background refresh runs. |
171
365
  | `BBRADAR_MCP_CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `3` | Consecutive retryable endpoint failures before its circuit opens. |
172
366
  | `BBRADAR_MCP_CIRCUIT_BREAKER_COOLDOWN_SECONDS` | `30` | Endpoint circuit cooldown before a new request is admitted. |
@@ -175,21 +369,29 @@ The defaults expose the 18-tool core surface and compact text results while enab
175
369
  | `BBRADAR_MCP_CONTINUATION_TTL_SECONDS` | `600` | Lifetime of in-memory continuation results. |
176
370
  | `BBRADAR_MCP_ALLOW_INSECURE_HTTP` | `false` | Explicitly allow an HTTP API base URL outside loopback for a trusted private development network. Never enable for internet traffic. |
177
371
 
178
- No performance variables are required. Capability-derived page limits and snapshot support, caching, request coalescing, server-side name resolution, reverse-target search, early stopping, bounded concurrency, aggregate-call parallelism, a 64 KiB request-body ceiling, a 16 MiB upstream response ceiling, automatic HTTP compression handling, connection reuse, and upstream quota protection are always active. Unsupported capability-gated tools are removed from discovery. Target responses expose upstream pagination state so an AI client does not mistake a capped page for a complete inventory.
372
+ No performance variables are required. Capability-derived page limits and snapshot support, cache-policy-aware caching, request coalescing, server-side name resolution, reverse-target search, early stopping, bounded concurrency and queues, aggregate-call parallelism, a 64 KiB request-body ceiling, a 16 MiB upstream response ceiling, automatic HTTP compression handling, connection reuse, and upstream quota protection are always active. Target responses expose upstream pagination state so an AI client does not mistake a capped page for a complete inventory.
179
373
 
180
- The client learns the API's `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers per endpoint group. Once an observed group reaches zero, it blocks additional requests to that group locally until reset instead of repeatedly hitting the API, while unrelated endpoint groups remain usable. Target and repository changes share one quota state and one conservative two-request concurrency pool. A rolling 60-second guard also enforces the documented shared key-and-IP ceiling across uncached upstream requests. Real `423`, `429`, and `503` responses honor `Retry-After` as a local cooldown, capped at ten minutes so a malformed upstream header cannot lock the MCP process indefinitely. Requests are not automatically retried, so one MCP action cannot amplify an admission or rate-limit response into more API calls. The global upstream concurrency default is six and bulk exports are capped at two; cursor page walking is strictly sequential.
374
+ The client learns the API's `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers per endpoint group. Once an observed group reaches zero, it blocks additional requests to that group locally until reset instead of repeatedly hitting the API, while unrelated endpoint groups remain usable. Target changes, repository changes, History, exact activity, and analytics share one quota state and one conservative two-request concurrency pool. A rolling 60-second guard also enforces the documented shared key-and-IP ceiling across uncached upstream requests. Real `423`, `429`, and `503` responses honor `Retry-After` as a local cooldown, capped at ten minutes so a malformed upstream header cannot lock the MCP process indefinitely. Requests are not automatically retried. The global upstream concurrency default is six, each queue is capped at 100, and bulk exports are capped at two.
181
375
 
182
376
  The bearer API destination must use HTTPS. Loopback HTTP remains available for local development; non-loopback HTTP requires the explicit `BBRADAR_MCP_ALLOW_INSECURE_HTTP=true` opt-in. API base URLs containing embedded credentials, query strings, fragments, or non-HTTP schemes are rejected before the key can be sent.
183
377
 
184
378
  Call `get_mcp_status` to inspect cache and stale-hit rates, circuit states, cursor persistence, concurrency, negotiated capabilities, upstream quotas, actual request counts, per-endpoint status totals, and timing.
185
379
 
186
- Run `npm run benchmark` for the deterministic local performance benchmark. It reports request and byte reductions for reverse target discovery, exact-program cache efficiency, and context reduction from bounded continuations.
380
+ Run `npm run benchmark` for deterministic local benchmarks. They report request and byte counts, model-context bytes, latency percentiles, and memory under representative History and analytics concurrency. Fixture timings are separate from live API latency.
381
+
382
+ Maintainers can compare the working tree against a committed revision with `npm run build` followed by `node --expose-gc benchmark/compare-revisions.mjs HEAD`. This uses the same fixtures and alternating samples for both revisions, reports discovery and result sizes, and measures repeated private reads separately from cacheable reads. It requires the repository and development dependencies.
383
+
384
+ Run `npm run test:package` to pack the release, install its production dependencies in a temporary directory, audit them, and exercise the packaged STDIO server. CI runs the checks on Node 20, 22, and 24.
385
+
386
+ MCP cancellation reaches active and queued HTTP work. Each tool shares one total deadline across its requests, using `BBRADAR_MCP_REQUEST_TIMEOUT_SECONDS`; snapshot creation keeps its 60-second minimum. Identical concurrent GETs can share one upstream request, and cancelling one caller preserves it while another caller still needs it.
387
+
388
+ Persisted numeric feed checkpoints are scoped to the API destination and credential. When upgrading from 0.2.5, seed each persisted stream once with `after_id` before using `resume_cursor`; old unscoped checkpoints are not reused.
187
389
 
188
390
  ### Flexible multi-signal discovery
189
391
 
190
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.
191
393
 
192
- `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.
193
395
 
194
396
  ```json
195
397
  {
@@ -234,6 +436,27 @@ This path uses one recent-changes request. Use `find_wildcard_programs` for a ra
234
436
  - `get_program_targets`
235
437
  - `get_recent_changes`
236
438
  - `get_repository_changes`
439
+ - `get_history_changes`
440
+ - `get_history_change_detail`
441
+ - `get_program_history`
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`
457
+ - `get_analytics_platform_series`
458
+ - `get_analytics_program_activity`
459
+ - `get_analytics_program_detail`
237
460
  - `find_hunt_targets`
238
461
  - `find_repository_hunt_targets`
239
462
  - `get_opportunities`
@@ -0,0 +1,164 @@
1
+ // Maintainer check: npm run build && node --expose-gc benchmark/compare-revisions.mjs [git-ref]
2
+ // Compiles a read-only source snapshot in a temporary directory; leaves the checkout untouched.
3
+ import assert from "node:assert/strict";
4
+ import { execFileSync } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import http from "node:http";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { performance } from "node:perf_hooks";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import ts from "typescript";
12
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
14
+ import { BBRadarClient } from "../dist/bbradarClient.js";
15
+ import { createBbradarServer } from "../dist/server.js";
16
+
17
+ const root = fileURLToPath(new URL("../", import.meta.url));
18
+ const ref = process.argv[2] ?? "HEAD";
19
+ const baselineCommit = execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { cwd: root, encoding: "utf8" }).trim();
20
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "bbradar-compare-"));
21
+ const counts = { requests: 0, bytes: 0, active: 0, peak: 0 };
22
+ const now = new Date().toISOString();
23
+ const programs = Array.from({ length: 100 }, (_, i) => ({
24
+ id: `Platform:program-${i}`, handle: `program-${i}`, name: `Program ${i}`, platform: "Platform",
25
+ program_kind: "bounty", is_active: true, scope_tags: ["domain", "api", "wildcard"],
26
+ added_at: now, last_updated: now, bounty_min: 100, bounty_max: 5000 + i * 100, report_count: i,
27
+ target_counts: { total: 20, in_scope: 20, eligible_for_bounty: 20, wildcard: 2 },
28
+ target_activity: { latest_change_at: now, added_24h: 1, added_7d: 2 }
29
+ }));
30
+ const targets = Array.from({ length: 20 }, (_, i) => ({
31
+ identifier: `api-${i}.example`, target_type: "domain", in_scope: true, eligible_for_bounty: true,
32
+ wildcard: i < 2, targetOpportunityScore: 75, targetOpportunityLabel: "Strong Target", competitionRisk: "Low",
33
+ scoreVersion: 3, evidenceCoverage: { source: "verified" }, targetScoreBreakdown: { scope: 30, freshness: 20 }
34
+ }));
35
+ let cacheControl = "private, no-store";
36
+ const api = http.createServer(async (req, res) => {
37
+ counts.requests += 1;
38
+ counts.active += 1;
39
+ counts.peak = Math.max(counts.peak, counts.active);
40
+ await new Promise((resolve) => setTimeout(resolve, 5));
41
+ const url = new URL(req.url, "http://fixture");
42
+ const endpoint = url.pathname.replace(/^\/api\/v1/, "");
43
+ let payload;
44
+ if (endpoint === "/pro/programs") payload = { programs: programs.slice(0, Number(url.searchParams.get("page_size") ?? 100)), meta: { total_pages: 1 } };
45
+ else if (endpoint.endsWith("/targets")) payload = { targets, meta: { total: 20, total_pages: 1 } };
46
+ else if (endpoint.startsWith("/pro/programs/")) payload = programs[0];
47
+ else if (endpoint === "/pro/targets/search") payload = { results: [{ program: programs[0], target: targets[0] }], meta: { has_more: false } };
48
+ else if (endpoint === "/pro/targets/changes") payload = { results: programs.slice(0, 10).map((program, i) => ({ id: i + 1, program, target: targets[i], change_type: "added", changed_at: now })), meta: { total_pages: 1, has_more: false } };
49
+ else { res.statusCode = 404; payload = { detail: "missing fixture route" }; }
50
+ const body = JSON.stringify(payload);
51
+ counts.bytes += Buffer.byteLength(body);
52
+ res.setHeader("content-type", "application/json");
53
+ res.setHeader("cache-control", cacheControl);
54
+ res.end(body);
55
+ counts.active -= 1;
56
+ });
57
+ const sessions = [];
58
+ const reset = () => { counts.requests = 0; counts.bytes = 0; counts.peak = 0; };
59
+ const round = (n) => Math.round(n * 100) / 100;
60
+ function percentile(values, p) { return [...values].sort((a, b) => a - b)[Math.ceil(values.length * p) - 1]; }
61
+
62
+ try {
63
+ fs.writeFileSync(path.join(temporary, "package.json"), '{"type":"module"}');
64
+ fs.symlinkSync(path.join(root, "node_modules"), path.join(temporary, "node_modules"), "junction");
65
+ const sources = execFileSync("git", ["ls-tree", "-r", "--name-only", baselineCommit, "src"], { cwd: root, encoding: "utf8" }).trim().split(/\r?\n/).filter((name) => name.endsWith(".ts"));
66
+ for (const source of sources) {
67
+ const input = execFileSync("git", ["show", `${baselineCommit}:${source}`], { cwd: root, encoding: "utf8" });
68
+ const output = ts.transpileModule(input, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText;
69
+ fs.writeFileSync(path.join(temporary, path.basename(source, ".ts") + ".js"), output);
70
+ }
71
+ const baselineClient = (await import(pathToFileURL(path.join(temporary, "bbradarClient.js")))).BBRadarClient;
72
+ const baselineServer = (await import(pathToFileURL(path.join(temporary, "server.js")))).createBbradarServer;
73
+ await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
74
+ const config = {
75
+ apiKey: "fixture", apiBaseUrl: `http://127.0.0.1:${api.address().port}/api/v1`, webBaseUrl: "https://bbradar.io",
76
+ cacheTtlMs: 0, negativeCacheTtlMs: 0, cacheMaxEntries: 500, maxConcurrentRequests: 6,
77
+ defaultRateLimitPerMinute: 10000, exportRateLimitPerMinute: 10000,
78
+ compactToolText: true, toolProfile: "core", requestTimeoutMs: 5000, validateOnStartup: false
79
+ };
80
+ async function connect(ClientType, createServer) {
81
+ const upstream = new ClientType(config);
82
+ const server = createServer(upstream, config);
83
+ const client = new Client({ name: "revision-benchmark", version: "1" });
84
+ const [a, b] = InMemoryTransport.createLinkedPair();
85
+ await Promise.all([server.connect(b), client.connect(a)]);
86
+ const session = { client, server, upstream };
87
+ sessions.push(session);
88
+ return session;
89
+ }
90
+ const pair = [await connect(baselineClient, baselineServer), await connect(BBRadarClient, createBbradarServer)];
91
+ const workflows = [
92
+ ["search_programs", { page_size: 20 }],
93
+ ["get_program_targets", { program_id: "Platform:program-0", limit: 20 }],
94
+ ["find_programs", { platforms: ["Platform"], max_pages: 1, max_results: 10, include_target_samples: false }],
95
+ ["get_recent_changes", { page_size: 10 }],
96
+ ["find_hunt_targets", { platforms: ["Platform"], max_program_pages: 1, max_results: 10, targets_per_result: 5, upstream_request_budget: 20 }]
97
+ ];
98
+ const report = {};
99
+ for (const [name, args] of workflows) {
100
+ // Warm code and connections, then alternate revision order to reduce order bias.
101
+ for (const session of pair) for (let i = 0; i < 3; i += 1) await session.client.callTool({ name, arguments: args });
102
+ const samples = [[], []];
103
+ for (let i = 0; i < 30; i += 1) for (const index of i % 2 ? [1, 0] : [0, 1]) {
104
+ reset();
105
+ const started = performance.now();
106
+ const result = await pair[index].client.callTool({ name, arguments: args });
107
+ assert.equal(result.isError, undefined, `${name} revision ${index}`);
108
+ samples[index].push({ ms: performance.now() - started, requests: counts.requests, upstream_bytes: counts.bytes,
109
+ model_text_bytes: Buffer.byteLength(result.content[0].text), structured_bytes: Buffer.byteLength(JSON.stringify(result.structuredContent)) });
110
+ }
111
+ const summaries = samples.map((rows) => ({
112
+ samples: rows.length, requests_per_call: rows[0].requests, upstream_bytes: rows[0].upstream_bytes,
113
+ model_text_bytes: rows[0].model_text_bytes, structured_bytes: rows[0].structured_bytes,
114
+ p50_ms: round(percentile(rows.map((x) => x.ms), .5)), p95_ms: round(percentile(rows.map((x) => x.ms), .95))
115
+ }));
116
+ assert.equal(summaries[0].requests_per_call, summaries[1].requests_per_call, `${name}: upstream request regression`);
117
+ report[name] = { baseline: summaries[0], candidate: summaries[1], p50_change_percent: round((summaries[1].p50_ms / summaries[0].p50_ms - 1) * 100) };
118
+ }
119
+ const repeated = {};
120
+ for (const policy of ["public, max-age=30", "private, no-store"]) {
121
+ cacheControl = policy;
122
+ repeated[policy] = [];
123
+ for (const ClientType of [baselineClient, BBRadarClient]) {
124
+ const upstream = new ClientType({ ...config, cacheTtlMs: 30000 });
125
+ reset();
126
+ const started = performance.now();
127
+ for (let i = 0; i < 20; i += 1) await upstream.getProgram("Platform:program-0");
128
+ repeated[policy].push({ calls: 20, requests: counts.requests, duration_ms: round(performance.now() - started) });
129
+ }
130
+ }
131
+ cacheControl = "private, no-store";
132
+ const concurrency = [];
133
+ for (const session of pair) {
134
+ global.gc?.();
135
+ const heap = process.memoryUsage().heapUsed;
136
+ reset();
137
+ const started = performance.now();
138
+ for (let batch = 0; batch < 10; batch += 1) {
139
+ const results = await Promise.all(Array.from({ length: 20 }, (_, i) => session.client.callTool({ name: "search_programs", arguments: { search: `fixture-${batch}-${i}`, page_size: 20 } })));
140
+ assert.ok(results.every((result) => !result.isError));
141
+ }
142
+ const peakHeapDelta = process.memoryUsage().heapUsed - heap;
143
+ const elapsed = performance.now() - started;
144
+ global.gc?.();
145
+ assert.equal(session.upstream.diagnostics().in_flight_requests, 0);
146
+ assert.equal(session.upstream.diagnostics().request_concurrency.active, 0);
147
+ concurrency.push({ calls: 200, requests: counts.requests, peak_active: counts.peak, duration_ms: round(elapsed), heap_delta_before_gc: peakHeapDelta, retained_heap_delta: process.memoryUsage().heapUsed - heap });
148
+ }
149
+ const discovery = [];
150
+ for (const session of pair) {
151
+ const listed = await session.client.listTools();
152
+ discovery.push({ tools: listed.tools.length, bytes: Buffer.byteLength(JSON.stringify(listed)) });
153
+ }
154
+ process.stdout.write(JSON.stringify({ baseline_commit: baselineCommit, fixture: true, simulated_latency_ms: 5,
155
+ comparison: "Same inputs; caches disabled for common workflows. Array entries are baseline, candidate.",
156
+ workflows: report, repeated_reads: repeated, concurrency, discovery }, null, 2) + "\n");
157
+ } finally {
158
+ for (const session of sessions) { await session.client.close(); await session.server.close(); }
159
+ api.close();
160
+ // Only remove the exact temporary directory created above, after unlinking its dependency junction.
161
+ if (path.dirname(temporary) !== path.resolve(os.tmpdir()) || !path.basename(temporary).startsWith("bbradar-compare-")) throw new Error("Unexpected temporary directory");
162
+ if (fs.existsSync(path.join(temporary, "node_modules"))) fs.unlinkSync(path.join(temporary, "node_modules"));
163
+ fs.rmSync(temporary, { recursive: true, force: true });
164
+ }