@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 +28 -0
- package/README.md +233 -10
- package/benchmark/compare-revisions.mjs +164 -0
- package/benchmark/pro-benchmark.mjs +106 -0
- 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 +21 -1
- package/dist/bbradarClient.js +263 -49
- package/dist/bbradarClient.js.map +1 -1
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.js +50 -13
- package/dist/sanitize.js.map +1 -1
- package/dist/server.js +853 -103
- package/dist/server.js.map +1 -1
- package/package.json +6 -4
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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` |
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
}
|