@bbradar/mcp 0.2.5 → 0.2.6

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,13 @@
1
+ # Release notes
2
+
3
+ ## 0.2.6 — expanded Pro API
4
+
5
+ - Added seven direct tools for semantic History, exact program activity, and daily analytics, using one data request per page and lazy change detail.
6
+ - 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.
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - Preserved explicit unknown fee, report-count, and scoring values in compact results, and kept case-sensitive program IDs distinct in fallback caches.
11
+ - 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.
12
+ - 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.
13
+ - 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,28 @@ 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
+ ### Pro History and analytics
139
+
140
+ 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.
141
+
142
+ | Pro API route | MCP tool | Page default |
143
+ | --- | --- | ---: |
144
+ | `GET /v1/pro/history/changes` | `get_history_changes` | 50 |
145
+ | `GET /v1/pro/history/changes/{change_id}` | `get_history_change_detail` | 50 |
146
+ | `GET /v1/pro/history/programs/{program_id}` | `get_program_history` | 50 |
147
+ | `GET /v1/pro/activity/programs/{program_id}` | `get_program_activity` | one summary |
148
+ | `GET /v1/pro/analytics/platforms/{platform}/series` | `get_analytics_platform_series` | 100 days |
149
+ | `GET /v1/pro/analytics/programs/activity` | `get_analytics_program_activity` | 50 programs |
150
+ | `GET /v1/pro/analytics/programs/{program_id}` | `get_analytics_program_detail` | 100 days |
151
+
152
+ 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.
153
+
154
+ 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.
155
+
156
+ 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.
157
+
158
+ 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.
159
+
137
160
  If a cursor predates retained API history, the MCP refreshes the relevant current
138
161
  target or repository-change inventory within the same page/time bounds. It advances to a new
139
162
  baseline only after the bounded inventory completes and unchanged feed probes prove
@@ -152,11 +175,11 @@ inventory ends. Missing public scope is treated as out of scope.
152
175
 
153
176
  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
177
 
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.
178
+ 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
179
 
157
180
  ## Performance Tuning
158
181
 
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.
182
+ The defaults expose the 25-tool core surface and compact text results while enabling bounded concurrency, in-flight request coalescing, endpoint circuit breakers, and response caching only when upstream cache policy permits it. The Pro deployment currently sends `private, no-store`, so its responses are not placed in the reusable response cache or stale-while-revalidate cache. Set the tool profile to `full` when all specialized tools are needed.
160
183
 
161
184
  | Environment variable | Default | Purpose |
162
185
  | --- | ---: | --- |
@@ -164,9 +187,9 @@ The defaults expose the 18-tool core surface and compact text results while enab
164
187
  | `BBRADAR_MCP_NEGATIVE_CACHE_TTL_SECONDS` | `15` | Repeated 404/405 cache lifetime. Set to `0` to disable. |
165
188
  | `BBRADAR_MCP_CACHE_MAX_ENTRIES` | `500` | Maximum entries in each local response cache. |
166
189
  | `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. |
190
+ | `BBRADAR_MCP_REQUEST_TIMEOUT_SECONDS` | `30` | Total upstream request deadline, including queue time. |
168
191
  | `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. |
192
+ | `BBRADAR_MCP_TOOL_PROFILE` | `core` | Compact 25-tool discovery surface. Set to `full` for every specialized tool. |
170
193
  | `BBRADAR_MCP_PROGRAM_STALE_WHILE_REVALIDATE_SECONDS` | `300` | Maximum stale window for exact program metadata while one background refresh runs. |
171
194
  | `BBRADAR_MCP_CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `3` | Consecutive retryable endpoint failures before its circuit opens. |
172
195
  | `BBRADAR_MCP_CIRCUIT_BREAKER_COOLDOWN_SECONDS` | `30` | Endpoint circuit cooldown before a new request is admitted. |
@@ -175,15 +198,23 @@ The defaults expose the 18-tool core surface and compact text results while enab
175
198
  | `BBRADAR_MCP_CONTINUATION_TTL_SECONDS` | `600` | Lifetime of in-memory continuation results. |
176
199
  | `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
200
 
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.
201
+ 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
202
 
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.
203
+ 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
204
 
182
205
  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
206
 
184
207
  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
208
 
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.
209
+ 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.
210
+
211
+ 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.
212
+
213
+ 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.
214
+
215
+ 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.
216
+
217
+ 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
218
 
188
219
  ### Flexible multi-signal discovery
189
220
 
@@ -234,6 +265,13 @@ This path uses one recent-changes request. Use `find_wildcard_programs` for a ra
234
265
  - `get_program_targets`
235
266
  - `get_recent_changes`
236
267
  - `get_repository_changes`
268
+ - `get_history_changes`
269
+ - `get_history_change_detail`
270
+ - `get_program_history`
271
+ - `get_program_activity`
272
+ - `get_analytics_platform_series`
273
+ - `get_analytics_program_activity`
274
+ - `get_analytics_program_detail`
237
275
  - `find_hunt_targets`
238
276
  - `find_repository_hunt_targets`
239
277
  - `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
+ }
@@ -0,0 +1,84 @@
1
+ import http from "node:http";
2
+ import { performance } from "node:perf_hooks";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5
+ import { BBRadarClient } from "../dist/bbradarClient.js";
6
+ import { createBbradarServer } from "../dist/server.js";
7
+
8
+ const counters = { requests: 0, bytes: 0, active: 0, peak: 0 };
9
+ const historyRows = Array.from({ length: 10 }, (_, i) => ({ id: `c${i}`, program: { id: "HackerOne:bench" }, category: "scope", observed_at: "2026-09-24T00:00:00Z" }));
10
+ const responses = {
11
+ "/pro/history/changes": { changes: historyRows, coverage: { capture_enabled: true }, meta: { has_more: false, next_cursor: null } },
12
+ "/pro/activity/programs/HackerOne:bench": { program_id: "HackerOne:bench", activity_summary: { additions: 0, last_observed_at: null } },
13
+ "/pro/analytics/programs/activity": { programs: historyRows.map((row) => ({ id: row.program.id, activity_days: 1 })), meta: { generation: 3, provisional: false, unsupported_metrics: ["repository_changes"] } }
14
+ };
15
+ const api = http.createServer(async (req, res) => {
16
+ const path = new URL(req.url, "http://fixture").pathname.replace(/^\/api\/v1/, "");
17
+ counters.requests += 1;
18
+ counters.active += 1;
19
+ counters.peak = Math.max(counters.peak, counters.active);
20
+ await new Promise((resolve) => setTimeout(resolve, 5));
21
+ const payload = path === "/pro/capabilities"
22
+ ? { contract_version: "2026-09-24", endpoints: Object.fromEntries(["history_changes", "history_change_detail", "program_activity", "analytics_program_activity"].map((key) => [key, { enabled: true, history_capture_enabled: true }])) }
23
+ : path.startsWith("/pro/history/changes/")
24
+ ? { change: historyRows[0], items: [{ before: null, after: { count: 0 } }], meta: { has_more: false } }
25
+ : responses[path] ?? { detail: "missing" };
26
+ const body = JSON.stringify(payload);
27
+ counters.bytes += Buffer.byteLength(body);
28
+ res.setHeader("content-type", "application/json");
29
+ res.setHeader("cache-control", "private, no-store");
30
+ res.end(body);
31
+ counters.active -= 1;
32
+ });
33
+
34
+ await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
35
+ const base = `http://127.0.0.1:${api.address().port}/api/v1`;
36
+ const options = { apiKey: "benchmark", apiBaseUrl: base, webBaseUrl: "https://bbradar.io", cacheTtlMs: 30_000, negativeCacheTtlMs: 15_000, cacheMaxEntries: 50, defaultRateLimitPerMinute: 60, exportRateLimitPerMinute: 15, maxConcurrentRequests: 6, compactToolText: true, toolProfile: "core", requestTimeoutMs: 5_000, validateOnStartup: false };
37
+ const upstream = new BBRadarClient(options);
38
+ await upstream.negotiateCapabilities();
39
+ const server = createBbradarServer(upstream, options);
40
+ const client = new Client({ name: "pro-benchmark", version: "1" });
41
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
42
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
43
+
44
+ function reset() { counters.requests = 0; counters.bytes = 0; counters.peak = 0; }
45
+ function percentile(values, p) { const sorted = [...values].sort((a, b) => a - b); return Math.round(sorted[Math.min(sorted.length - 1, Math.ceil(p * sorted.length) - 1)] * 100) / 100; }
46
+ async function measure(name, args) {
47
+ reset();
48
+ const timings = [];
49
+ let result;
50
+ const heapBefore = process.memoryUsage().heapUsed;
51
+ for (let i = 0; i < 20; i += 1) {
52
+ const start = performance.now();
53
+ result = await client.callTool({ name, arguments: args });
54
+ timings.push(performance.now() - start);
55
+ if (result.isError) throw new Error(`${name} failed`);
56
+ }
57
+ return { calls: 20, upstream_requests: counters.requests, upstream_bytes: counters.bytes, model_text_bytes: Buffer.byteLength(result.content[0].text), structured_bytes: Buffer.byteLength(JSON.stringify(result.structuredContent)), p50_ms: percentile(timings, .5), p95_ms: percentile(timings, .95), heap_delta_bytes: process.memoryUsage().heapUsed - heapBefore };
58
+ }
59
+
60
+ async function measureDirect(path) {
61
+ reset();
62
+ const timings = [];
63
+ for (let i = 0; i < 20; i += 1) {
64
+ const start = performance.now();
65
+ const response = await fetch(base + path, { headers: { authorization: "Bearer benchmark" } });
66
+ await response.arrayBuffer();
67
+ timings.push(performance.now() - start);
68
+ }
69
+ return { calls: 20, upstream_requests: counters.requests, upstream_bytes: counters.bytes, p50_ms: percentile(timings, .5), p95_ms: percentile(timings, .95) };
70
+ }
71
+
72
+ try {
73
+ const directHistory = await measureDirect("/pro/history/changes?limit=10");
74
+ const history = await measure("get_history_changes", { limit: 10 });
75
+ const detail = await measure("get_history_change_detail", { change_id: "c0", limit: 1 });
76
+ const activity = await measure("get_program_activity", { program_id: "HackerOne:bench" });
77
+ const analytics = await measure("get_analytics_program_activity", { limit: 10 });
78
+ reset();
79
+ const heapBefore = process.memoryUsage().heapUsed;
80
+ const parallel = await Promise.all(Array.from({ length: 10 }, (_, i) => client.callTool({ name: "get_history_changes", arguments: { search: `bench${i}`, limit: 1 } })));
81
+ const concurrency = { calls: parallel.length, upstream_requests: counters.requests, peak_upstream_requests: counters.peak, heap_delta_bytes: process.memoryUsage().heapUsed - heapBefore };
82
+ if ([history, detail, activity, analytics].some((entry) => entry.upstream_requests !== 20) || concurrency.peak_upstream_requests > 2 || concurrency.upstream_requests !== 10) throw new Error("Pro request-count or shared-concurrency regression");
83
+ process.stdout.write(JSON.stringify({ fixture: true, simulated_upstream_delay_ms: 5, direct_history: directHistory, estimated_mcp_overhead_p50_ms: Math.round((history.p50_ms - directHistory.p50_ms) * 100) / 100, history_lazy_detail: { eager_baseline_requests: 11, feed_only_requests: 1, requested_detail_requests: 2 }, tools: { history, detail, activity, analytics }, concurrency }, null, 2) + "\n");
84
+ } finally { await client.close(); await server.close(); api.close(); }
@@ -9,8 +9,9 @@ export type ApiRequestOptions = {
9
9
  coalesce?: boolean;
10
10
  staleWhileRevalidateMs?: number;
11
11
  timeoutMs?: number;
12
+ signal?: AbortSignal;
12
13
  };
13
- export type ApiReadControls = Pick<ApiRequestOptions, "cache" | "coalesce" | "timeoutMs">;
14
+ export type ApiReadControls = Pick<ApiRequestOptions, "cache" | "coalesce" | "timeoutMs" | "signal">;
14
15
  export type ApiResult<T> = {
15
16
  data: T;
16
17
  requestId: string;
@@ -21,6 +22,7 @@ export type ApiResult<T> = {
21
22
  fetchedAt: string;
22
23
  cacheExpiresAt?: string | undefined;
23
24
  stale?: boolean | undefined;
25
+ noStore?: boolean | undefined;
24
26
  };
25
27
  type CapabilityNegotiationState = {
26
28
  status: "not_started" | "negotiated" | "legacy" | "failed";
@@ -37,6 +39,7 @@ export declare class BBRadarApiError extends Error {
37
39
  readonly cached: boolean;
38
40
  readonly coalesced: boolean;
39
41
  readonly cacheExpiresAt: string | undefined;
42
+ readonly cacheAllowed: boolean;
40
43
  constructor(params: {
41
44
  message: string;
42
45
  requestId: string;
@@ -47,6 +50,7 @@ export declare class BBRadarApiError extends Error {
47
50
  cached?: boolean | undefined;
48
51
  coalesced?: boolean | undefined;
49
52
  cacheExpiresAt?: string | undefined;
53
+ cacheAllowed?: boolean | undefined;
50
54
  cause?: unknown;
51
55
  });
52
56
  }
@@ -55,6 +59,8 @@ export declare class BBRadarClient {
55
59
  private readonly cache;
56
60
  private readonly negativeCache;
57
61
  private readonly inFlight;
62
+ private readonly sharedRequests;
63
+ private readonly requestContext;
58
64
  private readonly endpointMetrics;
59
65
  private readonly requestQueue;
60
66
  private readonly endpointRequestQueues;
@@ -67,6 +73,7 @@ export declare class BBRadarClient {
67
73
  private capabilityNegotiation;
68
74
  private capabilityState;
69
75
  constructor(config: ServerConfig);
76
+ withRequestContext<T>(signal: AbortSignal, callback: () => T | Promise<T>, snapshotStart?: boolean): Promise<T>;
70
77
  diagnostics(): {
71
78
  response_cache: {
72
79
  entries: number;
@@ -192,8 +199,16 @@ export declare class BBRadarClient {
192
199
  getRepositoryChanges(query: Record<string, QueryValue>, controls?: ApiReadControls): Promise<ApiResult<unknown>>;
193
200
  searchTargets(query: Record<string, QueryValue>, controls?: ApiReadControls): Promise<ApiResult<unknown>>;
194
201
  getOpportunities(level: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
202
+ getHistoryChanges(query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
203
+ getHistoryChangeDetail(changeId: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
204
+ getProgramHistory(programId: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
205
+ getProgramActivity(programId: string): Promise<ApiResult<unknown>>;
206
+ getAnalyticsPlatformSeries(platform: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
207
+ getAnalyticsProgramActivity(query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
208
+ getAnalyticsProgramDetail(programId: string, query: Record<string, QueryValue>): Promise<ApiResult<unknown>>;
195
209
  exportTargets(body: Record<string, unknown>): Promise<ApiResult<unknown>>;
196
210
  request<T = unknown>(options: ApiRequestOptions): Promise<ApiResult<T>>;
211
+ private requestWithSignal;
197
212
  private fetchFromApi;
198
213
  private refreshInBackground;
199
214
  private withRequestSlot;