@bbradar/mcp 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.3.1 — hunt deadlines and target accuracy (2026-10-13)
4
+
5
+ - Reduced unnecessary upstream target rows for small direct reads while preserving offset/limit semantics and full-page scans for local filters. Added a live STDIO check with fresh prefix comparison and cache diagnostics. Catalog concurrency remains four: smaller-pool trials did not establish a reliable improvement under changing API latency.
6
+ - Fixed target inventory pagination in latest-addition and named-program workflows, added language-filter continuations, and exposed remaining offsets. Named target actions now honor target-type and language filters.
7
+ - Marked bounded scope counts and capped legacy exports as incomplete instead of full inventory totals. Export fallbacks reject upstream filters they cannot preserve. Malformed collection pages now fail explicitly rather than becoming empty successes.
8
+ - Rejected unknown arguments across all tools, respected negotiated aggregate page limits, and corrected SDK version diagnostics with a packaged-server regression check. Added live workflow smoke checks that report aggregate measurements and stop on failure without retries.
9
+ - Reserved catalog, recent-change and target-sampling time within the hunt's existing tool deadline. Slow optional reads no longer consume the entire call; completed catalog pages survive later-page timeouts with explicit incomplete coverage, errors and phase timings.
10
+ - Applied existing hunt VDP exclusion upstream when negotiated, preserving unknown classifications and the local filters. Added a read-only live hunt benchmark with aggregate timings and quota reporting, plus deadline, cancellation, coverage and legacy-API regression fixtures. Retained the four-request catalog limit after live concurrency comparisons showed no reliable gain from six.
11
+ - Rejected Activity version IDs on catalog/feed snapshot inputs and validated required signal periods and payout metric/period combinations before sending requests. Tool descriptions and guides distinguish the two snapshot contracts and preserve user filters after empty or partial results.
12
+
3
13
  ## 0.3.0 — Activity and payout views (2026-10-12)
4
14
 
5
15
  - 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.
package/README.md CHANGED
@@ -135,6 +135,8 @@ Repository results preserve the API's comparison completeness/method/ancestry ev
135
135
 
136
136
  `search_programs`, `search_targets`, `get_recent_changes`, and `get_repository_changes` accept the API's optional stable `snapshot` token. Send `snapshot: "start"` on page 1 and reuse the returned `meta.snapshot` with identical filters until `meta.snapshot_expires_at`. Snapshot responses bypass the local TTL cache, and an expired snapshot 410 is not mistaken for an expired change-feed cursor. Snapshot creation receives a 60-second minimum request timeout because it performs consistency work and can be materially slower; the fast `find_hunt_targets` workflow intentionally keeps live indexed paging and the ordinary configured timeout.
137
137
 
138
+ Catalog and feed snapshot tokens have at most 32 characters. `find_programs` uses that same contract. The 64-character version ID from `get_activity_snapshot` binds only Activity capabilities, rankings, event details and program payouts; do not pass it to catalog discovery.
139
+
138
140
  ### Activity and payouts
139
141
 
140
142
  Use `get_activity_capabilities` before choosing a ranking. Its versioned views give
@@ -344,6 +346,22 @@ Already-retired historical events cannot be reconstructed. `get_program_targets`
344
346
  follows upstream API pages until it can satisfy the requested offset/limit or the
345
347
  inventory ends. Missing public scope is treated as out of scope.
346
348
 
349
+ Target lists expose `meta.has_more` and `meta.next_offset`; repeat the same filters
350
+ with that offset to continue. `language_tags` applies while scanning pages, so
351
+ sparse matches do not skip eligible targets. Named target actions honor their
352
+ target-type and language filters. `get_latest_added_targets` also pages its optional
353
+ inventory and returns `full_targets_next_offset` for `get_program_targets`.
354
+ Scope summaries and breakdowns label sampled counts with `count_scope` and
355
+ `possibly_incomplete`; unknown full filtered totals are omitted. Legacy export
356
+ fallbacks disclose their cap and reject filters whose upstream semantics they
357
+ cannot preserve. All tools reject unknown arguments, and malformed collection
358
+ responses return errors instead of claiming the inventory is empty.
359
+
360
+ Small direct target reads request only the prefix needed for their offset and limit,
361
+ up to the negotiated page size, instead of always loading a 500-row page. Local
362
+ scope and language post-filters retain full pages to find sparse matches. This
363
+ reduces excess transfer and processing while preserving fresh reads and row offsets.
364
+
347
365
  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.
348
366
 
349
367
  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.
@@ -379,6 +397,24 @@ Call `get_mcp_status` to inspect cache and stale-hit rates, circuit states, curs
379
397
 
380
398
  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
399
 
400
+ With `BBRADAR_API_KEY` already configured, run `npm run build` then `node benchmark/live-hunt.mjs` for one authenticated all-platform Web2 hunt (domain/API, $5,000 maximum bounty, at least three eligible and five total targets, 15 results). It reports aggregate phase timings, request counts, quota and sampled coverage without printing the key or program/target records. This consumes live API quota and makes no automatic retries. Live catalog updates can change rows between pages; signatures help distinguish result changes from timing changes.
401
+
402
+ `node benchmark/live-api-check.mjs` checks negotiated capabilities, discovery,
403
+ all nine Activity tabs, program and target reads, payouts, History, daily analytics,
404
+ feeds, combined filters and the same hunt. It emits aggregate measurements and
405
+ stops at the first failure. An optional section (`activity`, `programs`, `analytics`,
406
+ `feeds`, or `hunt`) runs only that workflow and its prerequisites, so independent
407
+ checks can continue without repeating successful sections. These are live reads
408
+ and consume quota; a smoke pass is not an uptime or complete-catalog guarantee.
409
+
410
+ `node benchmark/live-stdio.mjs` performs the original hunt through the actual
411
+ STDIO server entry point using the configured key, then compares a fresh small
412
+ target page with the prefix of a fresh wider page. Use `targets` to run the target
413
+ check independently. It reports cache activity, timings and counts, never target
414
+ identifiers. Partial hunts fail the benchmark instead of counting as speed gains.
415
+ `live-hunt.mjs --catalog-concurrency=2` can test a lower catalog request cap;
416
+ inspect both timings and coverage before adopting a concurrency change.
417
+
382
418
  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
419
 
384
420
  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.
@@ -393,6 +429,12 @@ Use `find_hunt_targets` as the default one-call path when a request combines goo
393
429
 
394
430
  `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.
395
431
 
432
+ The hunt reserves time within the configured tool deadline: catalog discovery uses at most 60%, optional recent-change queries 30% in parallel, and target sampling finishes by 90%, leaving time to return the result. With the default 30 seconds, those bounds are 18, 9 and 27 seconds. A stalled later catalog page preserves completed pages; Activity scans retain only their completed ordered prefix. A failed first catalog page remains an error. Partial reads include `errors`, warnings, `meta.partial_success`, `result_guidance.coverage_incomplete` and `meta.phase_timing`. Missing enrichment is unavailable evidence. Keep the user's platform scope and thresholds; a sampled change feed cannot identify the complete supported platform set.
433
+
434
+ Legacy catalog hunts apply their existing VDP exclusion upstream when the API advertises that mode, then retain the local filters. Programs with unknown classifications and positive rewards remain eligible. Older APIs keep local exclusion. Catalog paging retains its four-request limit within the configured global pool; Activity scans keep their separate two-request limit.
435
+
436
+ Combined Activity filters need independent periods. `change_category` requires `updates_period`; filtering additions with `scope_period` and `scope_added_min` alone needs no category. `payout_total` requires `payout_period: "all_time"`; use `payout_increase` for tracked 24h/7d/30d/90d changes, or an enabled `payout_native_90d` comparison with 90d for provider-reported values. Invalid combinations fail locally with guidance. An empty valid result does not authorize changing requested thresholds.
437
+
396
438
  ```json
397
439
  {
398
440
  "platforms": ["Immunefi", "HackenProof", "BugCrowd", "YesWeHack"],
@@ -0,0 +1,126 @@
1
+ // Read-only production smoke check. Requires BBRADAR_API_KEY; emits aggregate
2
+ // measurements only and stops at the first failed call without retrying.
3
+ // Usage: node benchmark/live-api-check.mjs [all|activity|programs|analytics|feeds|hunt]
4
+ import assert from "node:assert/strict";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { BBRadarClient } from "../dist/bbradarClient.js";
8
+ import { loadConfig } from "../dist/config.js";
9
+ import { createBbradarServer } from "../dist/server.js";
10
+
11
+ const config = loadConfig();
12
+ config.toolProfile = "core";
13
+ const section = process.argv[2] ?? "all";
14
+ assert.ok(["all", "activity", "programs", "analytics", "feeds", "hunt"].includes(section), "Use all, activity, programs, analytics, feeds, or hunt.");
15
+ const enabled = name => section === "all" || section === name;
16
+ const upstream = new BBRadarClient(config);
17
+ const observations = [];
18
+ const report = { measured_at: new Date().toISOString(), fixture: false, section, observations };
19
+ let server, client;
20
+ async function call(name, args = {}) {
21
+ const before = upstream.diagnostics().upstream_requests.actual_requests;
22
+ const result = await client.callTool({ name, arguments: args });
23
+ const data = result.structuredContent;
24
+ observations.push({ tool: name, tab: args.tab, success: !result.isError,
25
+ status: data?.error?.status,
26
+ error: data?.error?.message?.replaceAll(config.apiKey, "[redacted]"),
27
+ duration_ms: data?.mcp_timing?.duration_ms,
28
+ requests: upstream.diagnostics().upstream_requests.actual_requests - before,
29
+ rows: (data?.rows ?? data?.programs ?? data?.targets ?? data?.changes ?? data?.series ?? data?.matches)?.length,
30
+ no_store: data?.cache?.no_store, partial_success: data?.meta?.partial_success,
31
+ coverage_incomplete: data?.result_guidance?.coverage_incomplete,
32
+ phase_timing: data?.meta?.phase_timing });
33
+ if (result.isError) throw new Error(`${name} failed with status ${data?.error?.status ?? "unknown"}`);
34
+ return data;
35
+ }
36
+
37
+ try {
38
+ await upstream.negotiateCapabilities();
39
+ const contract = upstream.capabilitySnapshot();
40
+ assert.equal(contract.status, "negotiated");
41
+ report.contract_version = contract.contract.contract_version;
42
+ report.api_endpoints = Object.keys(contract.contract.endpoints).sort();
43
+ server = createBbradarServer(upstream, config);
44
+ client = new Client({ name: "bbradar-live-check", version: "1" });
45
+ const [a, b] = InMemoryTransport.createLinkedPair();
46
+ await Promise.all([client.connect(a), server.connect(b)]);
47
+ const discovery = await client.listTools();
48
+ report.core_tool_count = discovery.tools.length;
49
+ report.discovery_bytes = Buffer.byteLength(JSON.stringify(discovery));
50
+ const status = await call("get_mcp_status");
51
+ report.server_version = status.status.server_version;
52
+ report.sdk_version = status.status.sdk_version;
53
+ await call("get_bbradar_guide");
54
+ let snapshot;
55
+ if (enabled("activity") || enabled("programs")) {
56
+ const version = await call("get_activity_snapshot");
57
+ snapshot = version.snapshot_id;
58
+ }
59
+ if (enabled("activity")) {
60
+ const capabilities = await call("get_activity_capabilities", { snapshot });
61
+ for (const tab of capabilities.tabs) {
62
+ // Let the server select its advertised default; an unqualified default
63
+ // legitimately returns no rows plus available_views, never an invented view.
64
+ const page = await call("get_activity_rankings", { tab: tab.id, snapshot, limit: 1 });
65
+ assert.equal(page.tab, tab.id);
66
+ assert.equal(page.meta.snapshot_id, snapshot);
67
+ assert.equal(page.cache.no_store, true);
68
+ assert.ok(Array.isArray(page.rows));
69
+ if (page.sort === null) assert.ok(Array.isArray(page.available_views));
70
+ }
71
+ }
72
+ let program;
73
+ if (enabled("programs") || enabled("analytics")) {
74
+ const catalog = await call("search_programs", { page_size: 5 });
75
+ program = catalog.programs.find(row => row.platform !== "Self-Hosted");
76
+ assert.ok(program?.id, "The bounded live catalog had no program for the detail checks.");
77
+ }
78
+ if (enabled("programs")) {
79
+ await call("get_program", { program_id: program.id });
80
+ await call("get_program_targets", { program_id: program.id, limit: 2 });
81
+ await call("get_program_payouts", { program_id: program.id, snapshot });
82
+ await call("get_program_activity", { program_id: program.id });
83
+ await call("get_program_history", { program_id: program.id, limit: 1 });
84
+ await call("get_history_changes", { limit: 1 });
85
+ }
86
+ if (enabled("analytics")) {
87
+ await call("get_analytics_platform_series", { platform: program.platform, limit: 1 });
88
+ await call("get_analytics_program_detail", { program_id: program.id, limit: 1 });
89
+ await call("get_analytics_program_activity", { limit: 1 });
90
+ }
91
+ if (enabled("feeds")) {
92
+ await call("get_recent_changes", { page_size: 1 });
93
+ await call("get_repository_changes", { page_size: 1, include_targets: false });
94
+ await call("get_opportunities", { level: "elite", page_size: 1 });
95
+ const combined = await call("find_programs", {
96
+ payout_period: "30d", payout_positive: true, require_bounty: true, max_pages: 1, max_results: 1
97
+ });
98
+ assert.ok(combined.scan);
99
+ }
100
+ if (enabled("hunt")) {
101
+ const hunt = await call("find_hunt_targets", {
102
+ surface_mode: "web2", target_types: ["domain", "api"], require_bounty: true,
103
+ min_bounty_max: 5000, min_eligible_targets: 3, min_total_targets: 5,
104
+ include_recent_changes: true, include_unknown_report_count: true,
105
+ recent_target_days: 90, max_results: 15, targets_per_result: 3,
106
+ ranking_mode: "balanced", upstream_request_budget: 50
107
+ });
108
+ assert.ok(hunt.matches.length > 0);
109
+ assert.equal(hunt.filters.min_bounty_max, 5000);
110
+ assert.equal(hunt.filters.surface_mode, "web2");
111
+ assert.ok(hunt.upstream_budget.used <= 50);
112
+ report.hunt = { returned: hunt.matches.length, target_samples: hunt.matches.reduce((sum, row) => sum + row.target_samples.length, 0),
113
+ ranking_scope: hunt.ranking_scope, upstream_budget: hunt.upstream_budget, partial_success: hunt.meta.partial_success };
114
+ if (hunt.meta.partial_success) process.exitCode = 1;
115
+ }
116
+ } catch (error) {
117
+ report.failure = String(error.message).replaceAll(config.apiKey, "[redacted]");
118
+ process.exitCode = 1;
119
+ } finally {
120
+ await client?.close();
121
+ await server?.close();
122
+ report.requests = upstream.diagnostics().upstream_requests.actual_requests;
123
+ report.concurrency = upstream.diagnostics().request_concurrency;
124
+ report.request_window = upstream.diagnostics().upstream_request_window;
125
+ console.log(JSON.stringify(report, null, 2));
126
+ }
@@ -0,0 +1,105 @@
1
+ // One authenticated read-only hunt. Only aggregate measurements reach stdout;
2
+ // credentials and program/target records are never written to disk.
3
+ // With BBRADAR_API_KEY configured: node benchmark/live-hunt.mjs
4
+ // Optional experiment: node benchmark/live-hunt.mjs --catalog-concurrency=2
5
+ // This only lowers catalog concurrency; it never changes filters or page counts.
6
+ import assert from "node:assert/strict";
7
+ import { createHash } from "node:crypto";
8
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
10
+ import { BBRadarClient } from "../dist/bbradarClient.js";
11
+ import { loadConfig } from "../dist/config.js";
12
+ import { createBbradarServer } from "../dist/server.js";
13
+
14
+ const args = {
15
+ surface_mode: "web2", target_types: ["domain", "api"], require_bounty: true,
16
+ min_bounty_max: 5000, min_eligible_targets: 3, min_total_targets: 5,
17
+ include_recent_changes: true, include_unknown_report_count: true,
18
+ recent_target_days: 90, max_results: 15, targets_per_result: 3,
19
+ ranking_mode: "balanced", upstream_request_budget: 50
20
+ };
21
+ const config = loadConfig();
22
+ const catalogOption = process.argv.slice(2);
23
+ assert.ok(catalogOption.length <= 1 && (!catalogOption.length || /^--catalog-concurrency=[1-4]$/.test(catalogOption[0])),
24
+ "Use --catalog-concurrency=1, 2, 3 or 4, or omit it for the server default.");
25
+ const catalogConcurrency = catalogOption.length ? Number(catalogOption[0].split("=")[1]) : Number.POSITIVE_INFINITY;
26
+ let activeCatalog = 0;
27
+ let peakCatalog = 0;
28
+ const catalogWaiters = [];
29
+ async function acquireCatalog() {
30
+ if (activeCatalog < catalogConcurrency) {
31
+ activeCatalog += 1;
32
+ peakCatalog = Math.max(peakCatalog, activeCatalog);
33
+ return;
34
+ }
35
+ await new Promise(resolve => catalogWaiters.push(resolve));
36
+ }
37
+ function releaseCatalog() {
38
+ const next = catalogWaiters.shift();
39
+ if (next) next(); else activeCatalog -= 1;
40
+ }
41
+ const upstream = new BBRadarClient(config);
42
+ const request = upstream.request.bind(upstream);
43
+ const redact = value => String(value).replaceAll(config.apiKey, "[redacted]");
44
+ let timings = [];
45
+ let catalogIds = new Set();
46
+ upstream.request = async options => {
47
+ const start = performance.now();
48
+ const endpoint = options.path.replace(/\/programs\/.+\/targets$/, "/programs/:program_id/targets");
49
+ const isCatalog = options.path === "/pro/programs";
50
+ if (isCatalog) await acquireCatalog();
51
+ try {
52
+ const result = await request(options);
53
+ for (const program of result.data?.programs ?? []) catalogIds.add(program.id);
54
+ timings.push({ endpoint, page: options.query?.page, elapsed_ms: Math.round(performance.now() - start),
55
+ status: result.status, cached: result.cached, coalesced: result.coalesced,
56
+ rows: result.data?.programs?.length ?? result.data?.results?.length ?? result.data?.targets?.length });
57
+ return result;
58
+ } catch (error) {
59
+ timings.push({ endpoint, page: options.query?.page, elapsed_ms: Math.round(performance.now() - start),
60
+ status: error.status ?? null, error: redact(error.message) });
61
+ throw error;
62
+ } finally {
63
+ if (isCatalog) releaseCatalog();
64
+ }
65
+ };
66
+ const hash = values => createHash("sha256").update(JSON.stringify([...values].sort())).digest("hex");
67
+
68
+ try {
69
+ await upstream.negotiateCapabilities();
70
+ const server = createBbradarServer(upstream, config);
71
+ const client = new Client({ name: "bbradar-live-benchmark", version: "1" });
72
+ const [a, b] = InMemoryTransport.createLinkedPair();
73
+ await Promise.all([client.connect(a), server.connect(b)]);
74
+ try {
75
+ timings = [];
76
+ catalogIds = new Set();
77
+ const before = upstream.diagnostics().upstream_requests.actual_requests;
78
+ const result = await client.callTool({ name: "find_hunt_targets", arguments: args });
79
+ const data = result.structuredContent;
80
+ const matches = data?.matches ?? [];
81
+ const observation = {
82
+ concurrency: config.maxConcurrentRequests,
83
+ catalog_concurrency_cap: Number.isFinite(catalogConcurrency) ? catalogConcurrency : "server_default",
84
+ catalog_request_peak: peakCatalog, is_error: result.isError ?? false,
85
+ error: data?.error ? { status: data.error.status, message: redact(data.error.message) } : undefined,
86
+ timing: data?.mcp_timing, phase_timing: data?.meta?.phase_timing,
87
+ returned: matches.length, target_samples: matches.reduce((count, match) => count + match.target_samples.length, 0),
88
+ programs_without_target_samples: matches.filter(match => !match.target_samples.length).length,
89
+ partial_success: data?.meta?.partial_success, coverage_incomplete: data?.result_guidance?.coverage_incomplete,
90
+ ranking_scope: data?.ranking_scope,
91
+ upstream_budget: data?.upstream_budget, actual_requests: upstream.diagnostics().upstream_requests.actual_requests - before,
92
+ catalog_rows: catalogIds.size, catalog_signature: hash(catalogIds),
93
+ shortlist_signature: hash(matches.map(match => match.program.id)),
94
+ errors: data?.errors?.map(error => ({ status: error.status, message: redact(error.message) })),
95
+ timings
96
+ };
97
+ if (result.isError || data?.meta?.partial_success) process.exitCode = 1;
98
+ console.log(JSON.stringify({ fixture: false, measured_at: new Date().toISOString(), arguments: args, observations: [observation],
99
+ quota: upstream.diagnostics().upstream_rate_limit,
100
+ request_window: upstream.diagnostics().upstream_request_window }, null, 2));
101
+ } finally { await client.close(); await server.close(); }
102
+ } catch (error) {
103
+ console.error(JSON.stringify({ status: error.status ?? null, message: redact(error.message) }));
104
+ process.exitCode = 1;
105
+ }
@@ -0,0 +1,80 @@
1
+ // Authenticated production smoke check through the actual STDIO entry point.
2
+ // Emits aggregates only; never writes credentials or program/target records.
3
+ // node benchmark/live-stdio.mjs [hunt|targets]
4
+ import assert from "node:assert/strict";
5
+ import { createHash } from "node:crypto";
6
+ import { fileURLToPath } from "node:url";
7
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9
+ import { loadConfig } from "../dist/config.js";
10
+
11
+ const config = loadConfig();
12
+ const mode = process.argv[2] ?? "hunt";
13
+ assert.ok(["hunt", "targets"].includes(mode), "Use hunt or targets.");
14
+ const client = new Client({ name: "bbradar-live-stdio", version: "1" });
15
+ const transport = new StdioClientTransport({ command: process.execPath,
16
+ args: [fileURLToPath(new URL("../dist/index.js", import.meta.url))], stderr: "pipe",
17
+ env: { BBRADAR_API_KEY: config.apiKey, BBRADAR_API_BASE_URL: config.apiBaseUrl,
18
+ BBRADAR_MCP_TOOL_PROFILE: "core", BBRADAR_MCP_VALIDATE_ON_STARTUP: "true" } });
19
+ const report = { measured_at: new Date().toISOString(), fixture: false, transport: "stdio", mode };
20
+ async function call(name, args = {}) {
21
+ const result = await client.callTool({ name, arguments: args });
22
+ if (result.isError) throw new Error(`${name}: ${result.structuredContent?.error?.message ?? "MCP call failed"}`);
23
+ return result.structuredContent;
24
+ }
25
+ try {
26
+ await client.connect(transport);
27
+ const tools = await client.listTools();
28
+ report.core_tools = tools.tools.length;
29
+ const before = (await call("get_mcp_status")).status.client;
30
+ assert.equal(before.capability_negotiation.status, "negotiated");
31
+ let programs;
32
+ if (mode === "hunt") {
33
+ const hunt = await call("find_hunt_targets", {
34
+ surface_mode: "web2", target_types: ["domain", "api"], require_bounty: true,
35
+ min_bounty_max: 5000, min_eligible_targets: 3, min_total_targets: 5,
36
+ include_recent_changes: true, include_unknown_report_count: true,
37
+ recent_target_days: 90, max_results: 15, targets_per_result: 3,
38
+ ranking_mode: "balanced", upstream_request_budget: 50
39
+ });
40
+ report.hunt = { duration_ms: hunt.mcp_timing.duration_ms, phase_timing: hunt.meta.phase_timing,
41
+ programs: hunt.matches.length, target_samples: hunt.matches.reduce((n, row) => n + row.target_samples.length, 0),
42
+ upstream_requests: hunt.upstream_budget.used, partial_success: hunt.meta.partial_success,
43
+ ranking_scope: hunt.ranking_scope, catalog_pages_read: hunt.source_requests.filter(row => row.source === "programs").length,
44
+ errors: hunt.errors?.map(error => ({ status: error.status, message: error.message?.replaceAll(config.apiKey, "[redacted]") })),
45
+ shortlist_signature: createHash("sha256").update(JSON.stringify(hunt.matches.map(row => row.program.id).sort())).digest("hex") };
46
+ assert.equal(hunt.meta.partial_success, false, "The hunt returned incomplete work; do not count it as a successful performance run.");
47
+ assert.ok(hunt.matches.length > 0);
48
+ programs = hunt.matches.map(row => row.program);
49
+ } else {
50
+ programs = (await call("search_programs", { page_size: 10 })).programs;
51
+ }
52
+ // Compare the requested prefix with a fresh wider read. A live source change
53
+ // remains a failed comparison, never an excuse to substitute old data.
54
+ const selected = [...programs].sort((a, b) => (b.scope_summary?.target_counts?.eligible_for_bounty ?? 0)
55
+ - (a.scope_summary?.target_counts?.eligible_for_bounty ?? 0))[0];
56
+ assert.ok(selected?.id, "The bounded catalog returned no target candidate.");
57
+ const args = { program_id: selected.id, output_mode: "identifiers" };
58
+ const wide = await call("get_program_targets", { ...args, limit: 500 });
59
+ const small = await call("get_program_targets", { ...args, limit: 3 });
60
+ assert.ok(wide.targets.length > 0);
61
+ assert.ok(JSON.stringify(wide.targets.slice(0, 3)) === JSON.stringify(small.targets), "Fresh target prefixes differ; inspect source drift before claiming equivalence.");
62
+ assert.equal(wide.cache.no_store, true);
63
+ assert.equal(small.cache.no_store, true);
64
+ report.target_prefix = { equal: true, wide_read_rows: wide.targets.length, small_read_rows: small.targets.length,
65
+ wide_read_ms: wide.mcp_timing.duration_ms, small_read_ms: small.mcp_timing.duration_ms,
66
+ small_read_has_more: small.meta.has_more, small_read_next_offset: small.meta.next_offset, both_no_store: true };
67
+ const after = (await call("get_mcp_status")).status.client;
68
+ report.upstream_requests = after.upstream_requests.actual_requests - before.upstream_requests.actual_requests;
69
+ report.response_cache = after.response_cache;
70
+ report.request_window = after.upstream_request_window;
71
+ assert.equal(after.request_concurrency.active, 0);
72
+ assert.equal(after.request_concurrency.queued, 0);
73
+ } catch (error) {
74
+ report.failure = String(error.message).replaceAll(config.apiKey, "[redacted]");
75
+ process.exitCode = 1;
76
+ } finally {
77
+ await client.close();
78
+ await transport.close();
79
+ console.log(JSON.stringify(report, null, 2));
80
+ }
@@ -1,2 +1,4 @@
1
1
  /** Catalog metadata stays available when Activity computation is disabled. */
2
2
  export declare function requiresActivityQuery(input?: Record<string, unknown>): boolean;
3
+ /** Validate dependencies without supplying or changing the user's periods. */
4
+ export declare function validateActivityCatalogInput(input: Record<string, unknown>): void;
@@ -12,4 +12,36 @@ export function requiresActivityQuery(input = {}) {
12
12
  return Object.entries(input).some(([key, value]) => value !== undefined && value !== null
13
13
  && (key === "sort_by" ? value !== "updated" : ACTIVITY_FILTERS.has(key)));
14
14
  }
15
+ /** Validate dependencies without supplying or changing the user's periods. */
16
+ export function validateActivityCatalogInput(input) {
17
+ const requirements = {
18
+ scope: ["scope_added_min", "scope_removed_min", "scope_net_min", "scope_net_max"],
19
+ reward: ["reward_change", "reward_change_min_pct"],
20
+ updates: ["active_days_min", "changes_min", "change_category"],
21
+ reports: ["reports_min", "reports_max", "reports_change", "reports_change_min_pct"],
22
+ resolved: ["resolved_min"], status: ["status_change"],
23
+ payout: ["payout_min", "payout_max", "payout_metric", "payout_activity", "payout_positive", "payout_precision"]
24
+ };
25
+ const sortPeriods = {
26
+ payout_amount: "payout", payout_lower_bound: "payout", payout_upper_bound: "payout",
27
+ scope_added: "scope", scope_net: "scope", reward_change: "reward", active_days: "updates",
28
+ last_change: "updates", reports: "reports", reports_change: "reports", resolved: "resolved"
29
+ };
30
+ for (const [prefix, keys] of Object.entries(requirements)) {
31
+ const signal = keys.find((key) => input[key] !== undefined);
32
+ if ((signal || sortPeriods[String(input.sort_by)] === prefix) && input[`${prefix}_period`] === undefined) {
33
+ throw new Error(`Specify ${prefix}_period for ${signal ?? `sort_by=${String(input.sort_by)}`}. Periods are never inferred from another signal.`);
34
+ }
35
+ }
36
+ const period = input.payout_period;
37
+ const metric = input.payout_metric ?? (period === "all_time" ? "payout_total" : "payout_increase");
38
+ if (period !== undefined && ((metric === "payout_total") !== (period === "all_time")
39
+ || (metric === "payout_native_90d" && period !== "90d"))) {
40
+ throw new Error("Payout metric and period are incompatible: payout_total requires all_time; payout_increase uses 24h, 7d, 30d or 90d; payout_native_90d requires 90d. Keep the requested period and choose its supported metric.");
41
+ }
42
+ if (input.payout_activity !== undefined && (metric !== "payout_increase"
43
+ || (input.payout_activity !== "increase" && period !== "30d"))) {
44
+ throw new Error("payout_activity requires payout_increase; no_increase and lower require payout_period=30d.");
45
+ }
46
+ }
15
47
  //# sourceMappingURL=activityContract.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"activityContract.js","sourceRoot":"","sources":["../src/activityContract.ts"],"names":[],"mappings":"AAAA,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC;IAC/B,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,YAAY,EAAE,YAAY;IACnG,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,iBAAiB,EAAE,mBAAmB;IAC5F,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB;IAC3F,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,iBAAiB,EAAE,gBAAgB;IACvF,aAAa,EAAE,aAAa,EAAE,gBAAgB,EAAE,wBAAwB,EAAE,iBAAiB;IAC3F,iBAAiB,EAAE,cAAc,EAAE,sBAAsB,EAAE,0BAA0B;IACrF,0BAA0B,EAAE,eAAe,EAAE,eAAe,EAAE,gBAAgB,EAAE,iBAAiB;CAClG,CAAC,CAAC;AAEH,8EAA8E;AAC9E,MAAM,UAAU,qBAAqB,CAAC,QAAiC,EAAE;IACvE,OAAO,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;WACpF,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;AAC9E,CAAC"}
1
+ {"version":3,"file":"activityContract.js","sourceRoot":"","sources":["../src/activityContract.ts"],"names":[],"mappings":"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"}
@@ -10,6 +10,7 @@ export type ApiRequestOptions = {
10
10
  staleWhileRevalidateMs?: number;
11
11
  timeoutMs?: number;
12
12
  signal?: AbortSignal;
13
+ expectedArray?: "programs" | "targets" | "results";
13
14
  };
14
15
  export type ApiReadControls = Pick<ApiRequestOptions, "cache" | "coalesce" | "timeoutMs" | "signal">;
15
16
  export type ApiResult<T> = {
@@ -73,7 +74,10 @@ export declare class BBRadarClient {
73
74
  private capabilityNegotiation;
74
75
  private capabilityState;
75
76
  constructor(config: ServerConfig);
76
- withRequestContext<T>(signal: AbortSignal, callback: () => T | Promise<T>, snapshotStart?: boolean): Promise<T>;
77
+ withRequestContext<T>(signal: AbortSignal, callback: () => T | Promise<T>, snapshotStart?: boolean, timeoutMs?: number): Promise<T>;
78
+ /** Give one aggregate stage a shorter budget without extending its parent. */
79
+ withRequestTimeout<T>(timeoutMs: number, callback: () => T | Promise<T>): Promise<T>;
80
+ throwIfRequestAborted(): void;
77
81
  diagnostics(): {
78
82
  response_cache: {
79
83
  entries: number;
@@ -131,12 +131,12 @@ export class BBRadarClient {
131
131
  this.cache = new TtlCache(config.cacheMaxEntries, config.cacheTtlMs, config.programStaleWhileRevalidateMs ?? DEFAULT_PROGRAM_SWR_MS);
132
132
  this.negativeCache = new TtlCache(config.cacheMaxEntries, config.negativeCacheTtlMs);
133
133
  }
134
- async withRequestContext(signal, callback, snapshotStart = false) {
134
+ async withRequestContext(signal, callback, snapshotStart = false, timeoutMs) {
135
135
  const controller = new AbortController();
136
136
  const onAbort = () => controller.abort(signal.reason);
137
- const timeout = setTimeout(() => controller.abort(new DOMException("Tool deadline exceeded.", "TimeoutError")), snapshotStart
137
+ const timeout = setTimeout(() => controller.abort(new DOMException("Tool deadline exceeded.", "TimeoutError")), timeoutMs ?? (snapshotStart
138
138
  ? Math.max(this.config.requestTimeoutMs, SNAPSHOT_START_MIN_TIMEOUT_MS)
139
- : this.config.requestTimeoutMs);
139
+ : this.config.requestTimeoutMs));
140
140
  signal.addEventListener("abort", onAbort, { once: true });
141
141
  if (signal.aborted)
142
142
  onAbort();
@@ -149,6 +149,15 @@ export class BBRadarClient {
149
149
  controller.abort();
150
150
  }
151
151
  }
152
+ /** Give one aggregate stage a shorter budget without extending its parent. */
153
+ withRequestTimeout(timeoutMs, callback) {
154
+ return this.withRequestContext(this.requestContext.getStore() ?? new AbortController().signal, callback, false, Math.max(1, timeoutMs));
155
+ }
156
+ throwIfRequestAborted() {
157
+ if (this.requestContext.getStore()?.aborted) {
158
+ throw new BBRadarApiError({ message: "BBRadar API request was cancelled or exceeded its tool deadline.", requestId: randomUUID(), status: 408 });
159
+ }
160
+ }
152
161
  diagnostics() {
153
162
  const endpointEntries = [...this.endpointMetrics.entries()];
154
163
  const totalRequests = endpointEntries.reduce((total, [, metric]) => total + metric.requests, 0);
@@ -307,6 +316,7 @@ export class BBRadarClient {
307
316
  listPrograms(query, controls = {}) {
308
317
  return this.request({
309
318
  path: "/pro/programs",
319
+ expectedArray: "programs",
310
320
  query,
311
321
  ...snapshotAwareReadControls(query, controls, true, this.config.requestTimeoutMs)
312
322
  });
@@ -326,6 +336,7 @@ export class BBRadarClient {
326
336
  getProgramTargets(programId, query = {}) {
327
337
  return this.request({
328
338
  path: `/pro/programs/${encodeProgramIdPath(programId)}/targets`,
339
+ expectedArray: "targets",
329
340
  query: {
330
341
  page: 1,
331
342
  page_size: this.endpointNumber("program_targets", "max_page_size", 500),
@@ -336,6 +347,7 @@ export class BBRadarClient {
336
347
  getRecentChanges(query, controls = {}) {
337
348
  return this.request({
338
349
  path: "/pro/targets/changes",
350
+ expectedArray: "results",
339
351
  query,
340
352
  ...snapshotAwareReadControls(query, controls, query.after_id === undefined, this.config.requestTimeoutMs)
341
353
  });
@@ -343,6 +355,7 @@ export class BBRadarClient {
343
355
  getRepositoryChanges(query, controls = {}) {
344
356
  return this.request({
345
357
  path: "/pro/repositories/changes",
358
+ expectedArray: "results",
346
359
  query,
347
360
  ...snapshotAwareReadControls(query, controls, query.after_id === undefined, this.config.requestTimeoutMs)
348
361
  });
@@ -350,6 +363,7 @@ export class BBRadarClient {
350
363
  searchTargets(query, controls = {}) {
351
364
  return this.request({
352
365
  path: "/pro/targets/search",
366
+ expectedArray: "results",
353
367
  query,
354
368
  ...snapshotAwareReadControls(query, controls, true, this.config.requestTimeoutMs)
355
369
  });
@@ -357,6 +371,7 @@ export class BBRadarClient {
357
371
  getOpportunities(level, query) {
358
372
  return this.request({
359
373
  path: `/pro/opportunities/${encodeURIComponent(level)}`,
374
+ expectedArray: "programs",
360
375
  query,
361
376
  ...snapshotAwareReadControls(query, {}, true, this.config.requestTimeoutMs)
362
377
  });
@@ -591,6 +606,12 @@ export class BBRadarClient {
591
606
  }
592
607
  throw new BBRadarApiError(params);
593
608
  }
609
+ if (options.expectedArray && !Array.isArray(asRecord(data)?.[options.expectedArray])) {
610
+ throw new BBRadarApiError({
611
+ message: `BBRadar API returned a malformed page: expected ${options.expectedArray}.`,
612
+ requestId, upstreamRequestId, status: 502, cacheAllowed: false
613
+ });
614
+ }
594
615
  const result = {
595
616
  data: data,
596
617
  status: response.status,