@bbradar/mcp 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +188 -3
- package/benchmark/pro-benchmark.mjs +26 -4
- package/dist/activityContract.d.ts +2 -0
- package/dist/activityContract.js +15 -0
- package/dist/activityContract.js.map +1 -0
- package/dist/bbradarClient.d.ts +5 -0
- package/dist/bbradarClient.js +54 -13
- package/dist/bbradarClient.js.map +1 -1
- package/dist/sanitize.js +3 -0
- package/dist/sanitize.js.map +1 -1
- package/dist/server.js +619 -60
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { requiresActivityQuery } from "./activityContract.js";
|
|
2
3
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { BBRadarApiError } from "./bbradarClient.js";
|
|
@@ -6,7 +7,7 @@ import { NumericCursorStore } from "./cursorStore.js";
|
|
|
6
7
|
import { stableStringify, stripUndefined } from "./json.js";
|
|
7
8
|
import { FixedWindowRateLimiter } from "./rateLimit.js";
|
|
8
9
|
import { readArray, readBoolean, readNumber, readObject, readString, sanitizeJson, sanitizeProPayload, sanitizeChange, sanitizeProgram, sanitizeRepositoryChange, sanitizeTarget, sanitizeTargetsForExport } from "./sanitize.js";
|
|
9
|
-
const SERVER_VERSION = "0.
|
|
10
|
+
const SERVER_VERSION = "0.3.0";
|
|
10
11
|
const MAX_PROGRAMS_PER_SEARCH = 100;
|
|
11
12
|
const MAX_TARGET_EXPORT = 500;
|
|
12
13
|
const MAX_RECENT_CHANGES = 100;
|
|
@@ -71,7 +72,8 @@ const CORE_TOOL_NAMES = new Set([
|
|
|
71
72
|
"get_program_activity",
|
|
72
73
|
"get_analytics_platform_series",
|
|
73
74
|
"get_analytics_program_activity",
|
|
74
|
-
"get_analytics_program_detail"
|
|
75
|
+
"get_analytics_program_detail",
|
|
76
|
+
"get_activity_capabilities", "get_activity_snapshot", "get_activity_rankings", "get_program_payouts", "get_activity_change_detail"
|
|
75
77
|
]);
|
|
76
78
|
const TOOL_ENDPOINT_REQUIREMENTS = {
|
|
77
79
|
search_targets: "target_search",
|
|
@@ -187,7 +189,7 @@ const changeListModeSchema = z.enum(["compact", "full"]);
|
|
|
187
189
|
const repositoryChangeListModeSchema = z.enum(["compact", "full"]);
|
|
188
190
|
const programListModeSchema = z.enum(["compact", "full"]);
|
|
189
191
|
const rewardThresholdModeSchema = z.enum(["max_at_least", "min_at_least"]);
|
|
190
|
-
const vdpModeSchema = z.enum(["include", "exclude", "only_likely", "only_known_no_bounty"]);
|
|
192
|
+
const vdpModeSchema = z.enum(["include", "exclude", "only", "only_likely", "only_known_no_bounty"]);
|
|
191
193
|
const targetMatchModeSchema = z
|
|
192
194
|
.enum(["smart", "contains", "exact", "suffix", "wildcard"])
|
|
193
195
|
.describe("How to match target_query. smart is forgiving for URLs, wildcards, target names, and partial words.");
|
|
@@ -283,6 +285,59 @@ const programSortSchema = z
|
|
|
283
285
|
"best_hunt_value"
|
|
284
286
|
])
|
|
285
287
|
.default("best_match");
|
|
288
|
+
const ACTIVITY_TABS = ["payouts", "value", "scope", "rewards", "updates", "reports", "response", "status", "all_changes"];
|
|
289
|
+
const ACTIVITY_EVENT_PERIODS = ["7d", "30d", "90d"];
|
|
290
|
+
const ACTIVITY_COUNT_TABS = new Set(["payouts", "reports", "response"]);
|
|
291
|
+
const ACTIVITY_SHARED_PARAMS = ["tab", "sort", "direction", "platforms", "tags", "program_kind", "program_type", "search",
|
|
292
|
+
"current_status", "labels", "opportunity_tiers", "cursor", "limit", "snapshot"];
|
|
293
|
+
// Inputs the API accepts only for these views. Anything else is rejected before
|
|
294
|
+
// a request so it cannot be silently ignored or reinterpreted.
|
|
295
|
+
const ACTIVITY_TAB_PARAMS = {
|
|
296
|
+
payouts: ["period", "basis", "precision"],
|
|
297
|
+
value: ["include_map"],
|
|
298
|
+
scope: ["period"],
|
|
299
|
+
rewards: ["period"],
|
|
300
|
+
updates: ["period", "category"],
|
|
301
|
+
reports: ["period", "launched_within", "report_change_min", "report_change_max"],
|
|
302
|
+
response: ["period", "launched_within"],
|
|
303
|
+
status: ["period", "change_type"],
|
|
304
|
+
all_changes: ["period", "category"]
|
|
305
|
+
};
|
|
306
|
+
const activityCatalogSortSchema = z.enum(["updated", "payout_amount", "payout_lower_bound", "payout_upper_bound", "scope_added", "scope_net", "reward_change", "active_days", "reports", "reports_change", "resolved", "last_resolved", "first_response", "time_to_bounty", "last_change"]);
|
|
307
|
+
const activityCountPeriodSchema = () => z.enum(["24h", "7d", "30d", "90d", "all_time"]);
|
|
308
|
+
const activityEventPeriodSchema = () => z.enum(["7d", "30d", "90d"]);
|
|
309
|
+
const activityCatalogNumberSchema = () => z.number().finite().min(0).optional();
|
|
310
|
+
const activityCatalogInput = {
|
|
311
|
+
program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
|
|
312
|
+
payout_metric: z.enum(["payout_total", "payout_increase", "payout_native_90d"]).optional(),
|
|
313
|
+
payout_period: activityCountPeriodSchema().optional(),
|
|
314
|
+
payout_precision: z.enum(["exact", "proven_bounds"]).optional(),
|
|
315
|
+
payout_min: activityCatalogNumberSchema(), payout_max: activityCatalogNumberSchema(),
|
|
316
|
+
payout_positive: z.boolean().optional().describe("true requires a proved positive payout; false requires an exact observed zero. Omit to include either."),
|
|
317
|
+
payout_activity: z.enum(["increase", "no_increase", "lower"]).optional(),
|
|
318
|
+
scope_period: activityEventPeriodSchema().optional(),
|
|
319
|
+
scope_added_min: activityCatalogNumberSchema(), scope_removed_min: activityCatalogNumberSchema(),
|
|
320
|
+
scope_net_min: z.number().finite().optional(), scope_net_max: z.number().finite().optional(),
|
|
321
|
+
reward_period: activityEventPeriodSchema().optional(),
|
|
322
|
+
reward_change: z.enum(["increased", "decreased"]).optional(), reward_change_min_pct: activityCatalogNumberSchema(),
|
|
323
|
+
updates_period: activityEventPeriodSchema().optional(),
|
|
324
|
+
active_days_min: activityCatalogNumberSchema(), changes_min: activityCatalogNumberSchema(),
|
|
325
|
+
change_category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
|
|
326
|
+
reports_period: activityCountPeriodSchema().optional(),
|
|
327
|
+
reports_min: activityCatalogNumberSchema(), reports_max: activityCatalogNumberSchema(),
|
|
328
|
+
reports_change: z.enum(["rising", "falling"]).optional(), reports_change_min_pct: activityCatalogNumberSchema(),
|
|
329
|
+
launched_within: z.enum(["7d", "30d", "90d"]).optional(),
|
|
330
|
+
resolved_period: activityCountPeriodSchema().optional(), resolved_min: activityCatalogNumberSchema(),
|
|
331
|
+
last_resolved_within: z.enum(["7d", "30d", "90d"]).optional(),
|
|
332
|
+
first_response_max_hours: activityCatalogNumberSchema(), time_to_bounty_max_hours: activityCatalogNumberSchema(),
|
|
333
|
+
status_period: activityEventPeriodSchema().optional(),
|
|
334
|
+
status_change: z.enum(["first_seen", "paused", "resumed", "left_listing", "returned"]).optional(),
|
|
335
|
+
current_status: z.enum(["active", "paused", "suspended", "disabled", "closed", "unavailable", "unknown"]).optional(),
|
|
336
|
+
activity_labels: z.array(z.string().min(1).max(64)).max(10).optional(),
|
|
337
|
+
sort_by: activityCatalogSortSchema.optional(), sort_direction: z.enum(["asc", "desc"]).optional()
|
|
338
|
+
};
|
|
339
|
+
const activityCatalogGuidance = "Give each Activity signal its period (e.g. payout_period=30d with payout_positive=true; scope_period with scope_added_min) and use sort_by/sort_direction combinations enabled in get_activity_capabilities; default is updated descending. Tracked no_increase/lower require 30d. Nothing falls back to another period or precision.";
|
|
340
|
+
const freeSubmissionOnlySchema = z.boolean().default(false).describe("Only programs with no detected submission fee or deposit.");
|
|
286
341
|
const programIdSchema = z
|
|
287
342
|
.string()
|
|
288
343
|
.trim()
|
|
@@ -305,51 +360,24 @@ const programIdentifierSchema = z
|
|
|
305
360
|
function jsonRecordSchema() {
|
|
306
361
|
return z.record(z.unknown());
|
|
307
362
|
}
|
|
308
|
-
function rateLimitOutputSchema() {
|
|
309
|
-
return z.object({
|
|
310
|
-
remaining: z.number().int().min(0),
|
|
311
|
-
reset_at: z.string()
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
function toolTimingOutputSchema() {
|
|
315
|
-
return z.object({
|
|
316
|
-
tool_name: z.string(),
|
|
317
|
-
duration_ms: z.number().int().min(0)
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
function cacheOutputSchema() {
|
|
321
|
-
return z.object({
|
|
322
|
-
hit: z.boolean(),
|
|
323
|
-
stale_while_revalidate: z.boolean().optional(),
|
|
324
|
-
coalesced_live_request: z.boolean().optional(),
|
|
325
|
-
no_store: z.boolean().optional(),
|
|
326
|
-
expires_at: z.string().optional()
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
function errorOutputSchema() {
|
|
330
|
-
return z.object({
|
|
331
|
-
message: z.string(),
|
|
332
|
-
status: z.number().int().optional(),
|
|
333
|
-
detail: z.unknown().optional(),
|
|
334
|
-
errors: z.unknown().optional()
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
363
|
function programOutputSchema() {
|
|
338
364
|
return jsonRecordSchema();
|
|
339
365
|
}
|
|
366
|
+
// Every tool repeats this envelope in discovery. Diagnostic objects (cache,
|
|
367
|
+
// rate limit, timing, error) stay open records so each tool's schema stays small.
|
|
340
368
|
function apiEnvelopeOutputShape() {
|
|
341
369
|
return {
|
|
342
370
|
request_id: z.string(),
|
|
343
371
|
upstream_request_id: z.string().optional(),
|
|
344
372
|
fetched_at: z.string().optional(),
|
|
345
|
-
cache:
|
|
373
|
+
cache: jsonRecordSchema().optional(),
|
|
346
374
|
source_requests: z.array(jsonRecordSchema()).optional(),
|
|
347
|
-
mcp_rate_limit:
|
|
348
|
-
mcp_timing:
|
|
375
|
+
mcp_rate_limit: jsonRecordSchema().optional(),
|
|
376
|
+
mcp_timing: jsonRecordSchema().optional(),
|
|
349
377
|
warnings: z.array(z.string()).optional(),
|
|
350
378
|
program_id_resolution: jsonRecordSchema().optional(),
|
|
351
379
|
recovery: jsonRecordSchema().optional(),
|
|
352
|
-
error:
|
|
380
|
+
error: jsonRecordSchema().optional()
|
|
353
381
|
};
|
|
354
382
|
}
|
|
355
383
|
function programListOutputShape() {
|
|
@@ -367,7 +395,8 @@ function findProgramsOutputShape() {
|
|
|
367
395
|
warnings: z.array(z.string()).optional(),
|
|
368
396
|
errors: z.array(jsonRecordSchema()).optional(),
|
|
369
397
|
upstream_budget: jsonRecordSchema().optional(),
|
|
370
|
-
ranking_scope: jsonRecordSchema().optional()
|
|
398
|
+
ranking_scope: jsonRecordSchema().optional(),
|
|
399
|
+
scan: jsonRecordSchema().optional()
|
|
371
400
|
};
|
|
372
401
|
}
|
|
373
402
|
function decisionOutputShape() {
|
|
@@ -432,6 +461,8 @@ export function createBbradarServer(client, config) {
|
|
|
432
461
|
"Use resolve_program or run_program_name_action for a named program.",
|
|
433
462
|
"Use find_hunt_targets once for requests combining good rewards, broad scope, target quality, and recent target additions. Use find_repository_hunt_targets instead when Git or repository activity is explicitly required.",
|
|
434
463
|
"Use find_programs for lower-level program-only discovery and find_programs_by_target for reverse target lookup.",
|
|
464
|
+
"For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities, then get_activity_rankings with the matching tab for one ranking. For several conditions at once (e.g. recent payouts plus new scope, Web2-only, low competition, no submission fee) use one find_hunt_targets or find_programs call with Activity inputs and local filters together. Keep periods explicit; never intersect separately paginated rankings.",
|
|
465
|
+
"Activity data is published in versions. get_activity_snapshot reports the current version; pass its snapshot_id as snapshot to bind capabilities and first pages to one version, and never mix rows from different versions.",
|
|
435
466
|
"For semantic changes use get_history_changes or get_program_history, then get_history_change_detail only when before/after items are needed. For exact activity use get_program_activity; for ranked activity use get_analytics_program_activity. Use daily analytics tools directly for date series.",
|
|
436
467
|
"Default to bounty-eligible in-scope data unless the user explicitly requests broader results.",
|
|
437
468
|
"Prefer compact/identifier outputs and disclose sampled ranking scope when present."
|
|
@@ -442,6 +473,8 @@ export function createBbradarServer(client, config) {
|
|
|
442
473
|
"Simple default path: find_hunt_targets once for ranked target/program discovery, run_program_name_action for one named program, get_program_eligible_targets_by_name for exhaustive target lists by program name.",
|
|
443
474
|
"Do not repeat program discovery with alternate sorts or fan out get_program_targets across a shortlist; find_hunt_targets already includes exact bounded samples and recent target additions.",
|
|
444
475
|
"Use advanced tools only after the user's goal is clear or the guide points to them.",
|
|
476
|
+
"For payouts, reports, response measures or Activity comparisons, first read get_activity_capabilities. Use get_activity_rankings (or its per-view alias) for one ranking, or one find_hunt_targets/find_programs call combining Activity inputs with Web2, scope, competition and fee filters. Keep server order and explicit periods; never re-rank a sampled catalog or intersect separate pages.",
|
|
477
|
+
"Activity data is published in versions. get_activity_snapshot reports the current version; pass its snapshot_id as snapshot to bind capabilities and first pages to one version, and never mix rows from different versions.",
|
|
445
478
|
"Partial program name + action: run_program_name_action. Partial program name only: resolve_program.",
|
|
446
479
|
"Default to bounty-eligible in-scope data. Include out-of-scope, ineligible, VDP, disclosure-only, or no-bounty data only when the user explicitly asks.",
|
|
447
480
|
"Recently updated programs across platforms: find_recently_updated_programs. Do not infer a platform such as Immunefi unless the user explicitly names it.",
|
|
@@ -478,6 +511,135 @@ export function createBbradarServer(client, config) {
|
|
|
478
511
|
const targetSearchPageSizeSchema = z.number().int().min(1).max(maxTargetSearchPageSize).default(Math.min(50, maxTargetSearchPageSize));
|
|
479
512
|
const historyOutput = { ...apiEnvelopeOutputShape(), changes: z.array(jsonRecordSchema()).optional(), coverage: jsonRecordSchema().optional(), meta: jsonRecordSchema().optional() };
|
|
480
513
|
const analyticsOutput = { ...apiEnvelopeOutputShape(), platform: z.string().optional(), program: jsonRecordSchema().optional(), programs: z.array(jsonRecordSchema()).optional(), series: z.array(jsonRecordSchema()).optional(), observations: jsonRecordSchema().optional(), category: z.string().optional(), population: z.string().optional(), ranking_definition: z.string().optional(), inventory_definition: z.string().optional(), meta: jsonRecordSchema().optional() };
|
|
514
|
+
// Published Activity versions are 64 lowercase hex characters. "start" and
|
|
515
|
+
// catalog snapshot tokens are different contracts and are rejected here.
|
|
516
|
+
const activitySnapshotSchema = z.string().regex(/^[0-9a-f]{64}$/, "Use a snapshot_id returned by Activity.")
|
|
517
|
+
.describe("Published Activity version (get_activity_snapshot or meta.snapshot_id). Binds reads to one version; expired or withdrawn versions return 410. Never mix with a cursor from another version.");
|
|
518
|
+
const includeMapSchema = z.boolean().default(false)
|
|
519
|
+
.describe("Return Value map points (up to thousands). Off by default: map_points_omitted reports the count.");
|
|
520
|
+
const activityParams = {
|
|
521
|
+
tab: z.enum(ACTIVITY_TABS),
|
|
522
|
+
period: z.enum(["24h", "7d", "30d", "90d", "all_time"]).optional(),
|
|
523
|
+
basis: z.enum(["reported", "tracked"]).optional(),
|
|
524
|
+
precision: z.enum(["exact", "proven_bounds"]).optional(),
|
|
525
|
+
sort: z.string().min(1).max(64).optional(),
|
|
526
|
+
direction: z.enum(["asc", "desc"]).optional(),
|
|
527
|
+
platforms: platformListSchema(), tags: tagListSchema(),
|
|
528
|
+
program_kind: z.enum(["bounty", "contest", "audit", "challenge"]).optional(),
|
|
529
|
+
program_type: z.enum(["paid", "vdp", "unknown"]).optional(),
|
|
530
|
+
search: z.string().min(1).max(200).optional(),
|
|
531
|
+
current_status: z.enum(["active", "paused", "suspended", "disabled", "closed", "unavailable", "unknown"]).optional(),
|
|
532
|
+
labels: z.array(z.string().min(1).max(64)).max(10).optional().describe("Only labels listed by get_activity_capabilities; others return 422."),
|
|
533
|
+
opportunity_tiers: z.array(opportunityLevelSchema).min(1).max(4).optional().describe("Any of these catalog opportunity tiers, as frozen into the Activity version. get_activity_capabilities lists the tiers present in facets.opportunity_tiers; an API without the filter is rejected before any request."),
|
|
534
|
+
launched_within: z.enum(["7d", "30d", "90d"]).optional(),
|
|
535
|
+
category: z.enum(["scope", "rewards", "rules", "program"]).optional(),
|
|
536
|
+
change_type: z.enum(["first_seen", "paused", "resumed", "left_listing", "returned"]).optional(),
|
|
537
|
+
report_change_min: z.number().finite().optional(), report_change_max: z.number().finite().optional(),
|
|
538
|
+
include_map: includeMapSchema,
|
|
539
|
+
cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(25),
|
|
540
|
+
snapshot: activitySnapshotSchema.optional()
|
|
541
|
+
};
|
|
542
|
+
const activityOutput = {
|
|
543
|
+
...apiEnvelopeOutputShape(), rows: z.array(jsonRecordSchema()).optional(), points: z.array(jsonRecordSchema()).optional(),
|
|
544
|
+
tab: z.string().optional(), period: z.string().nullable().optional(),
|
|
545
|
+
basis: z.string().nullable().optional(), precision: z.string().nullable().optional(),
|
|
546
|
+
sort: z.string().nullable().optional(), direction: z.string().optional(), meta: jsonRecordSchema().optional(),
|
|
547
|
+
cohort_median: jsonRecordSchema().nullable().optional(), available_views: z.array(jsonRecordSchema()).optional(),
|
|
548
|
+
map_points_omitted: z.number().int().optional()
|
|
549
|
+
};
|
|
550
|
+
registerTool("get_activity_capabilities", {
|
|
551
|
+
title: "Get Available Activity Views",
|
|
552
|
+
description: "Read the exact enabled period, sort, direction, basis and precision combinations before choosing an Activity ranking. Availability is evaluated before user filters; do not infer combinations from independent enums. Honor each view's sort_directions and default_direction and the enabled label list. The snapshot block identifies the published version these capabilities belong to.",
|
|
553
|
+
inputSchema: z.object({ snapshot: activitySnapshotSchema.optional() }).strict(),
|
|
554
|
+
outputSchema: { ...apiEnvelopeOutputShape(), tabs: z.array(jsonRecordSchema()).optional(), version: z.union([z.string(), z.number()]).optional(), facets: jsonRecordSchema().optional(), labels: z.array(z.string()).optional(), listing: jsonRecordSchema().optional(), listing_payouts_enabled: z.boolean().optional(), derived_signals_enabled: z.boolean().optional(), snapshot: jsonRecordSchema().optional() },
|
|
555
|
+
annotations: readOnlyAnnotations
|
|
556
|
+
}, (args) => runTool("get_activity_capabilities", config, rateLimiter, async () => {
|
|
557
|
+
assertProFeature(client, "activity_capabilities", "enabled");
|
|
558
|
+
assertActivitySnapshotSelector(client, "activity_capabilities", args.snapshot);
|
|
559
|
+
return proPage(await client.getActivityCapabilities(toQuery({ snapshot: args.snapshot })), "tabs");
|
|
560
|
+
}));
|
|
561
|
+
registerTool("get_activity_snapshot", {
|
|
562
|
+
title: "Get Current Activity Version",
|
|
563
|
+
description: "Read only the identity of the currently published Activity version: snapshot_id, frozen as_of cutoff, expires_at, policy_version and suggested poll_seconds. Use it to detect newer Activity data and pass snapshot_id as snapshot to bind capabilities, first pages and payouts to one version. as_of is when BBRadar froze its calculation, not a provider payment or update time.",
|
|
564
|
+
inputSchema: z.object({}).strict(),
|
|
565
|
+
outputSchema: { ...apiEnvelopeOutputShape(), snapshot_id: z.string().optional(), as_of: z.string().optional(), expires_at: z.string().optional(), policy_version: z.string().optional(), poll_seconds: z.number().optional() },
|
|
566
|
+
annotations: readOnlyAnnotations
|
|
567
|
+
}, () => runTool("get_activity_snapshot", config, rateLimiter, async () => {
|
|
568
|
+
assertProFeature(client, "activity_snapshot", "enabled");
|
|
569
|
+
const api = await client.getActivitySnapshot();
|
|
570
|
+
const data = readObject(api.data);
|
|
571
|
+
if (!data || typeof data.snapshot_id !== "string" || !/^[0-9a-f]{64}$/.test(data.snapshot_id)) {
|
|
572
|
+
throw new Error("Malformed Activity version response.");
|
|
573
|
+
}
|
|
574
|
+
return withApiMetadata(api, sanitizeProPayload(api.data));
|
|
575
|
+
}));
|
|
576
|
+
registerTool("get_program_payouts", {
|
|
577
|
+
title: "Get Program Payouts and Response Facts",
|
|
578
|
+
description: "Read reported totals, measured counter increases, source dates, precision, report and response facts, derived signals and label explanations for one exact platform:handle. Keep null separate from zero, bounds separate from exact amounts and unknown payment dates null. Payout timestamps are when increases were seen, never payment dates. No provider requests are made.",
|
|
579
|
+
inputSchema: z.object({ program_id: programIdSchema, snapshot: activitySnapshotSchema.optional(), include_map: includeMapSchema }).strict(),
|
|
580
|
+
// Mirrors the API response, including its version identity: a client that
|
|
581
|
+
// discovered this schema validates every structured result against it.
|
|
582
|
+
outputSchema: { ...apiEnvelopeOutputShape(), program_id: z.string().optional(), program: jsonRecordSchema().optional(), summary: jsonRecordSchema().optional(), as_of: z.string().optional(), snapshot_id: z.string().optional(), expires_at: z.string().nullable().optional(), facts: z.array(jsonRecordSchema()).optional(), windows: jsonRecordSchema().optional(), series: z.array(jsonRecordSchema()).optional(), payout_summary: jsonRecordSchema().nullable().optional(), views: z.array(jsonRecordSchema()).optional(), reports: jsonRecordSchema().optional(), response: jsonRecordSchema().optional(), last_increase_at: z.string().nullable().optional(), signals: jsonRecordSchema().optional(), label_details: z.array(jsonRecordSchema()).optional(), labels: z.array(z.string()).optional(), value_points: z.array(jsonRecordSchema()).optional(), map_points_omitted: z.number().int().optional(), meta: jsonRecordSchema().optional() },
|
|
583
|
+
annotations: readOnlyAnnotations
|
|
584
|
+
}, (args) => runTool("get_program_payouts", config, rateLimiter, async () => {
|
|
585
|
+
assertProFeature(client, "program_payouts", "enabled");
|
|
586
|
+
assertActivitySnapshotSelector(client, "program_payouts", args.snapshot);
|
|
587
|
+
const api = await client.getProgramPayouts(args.program_id, toQuery({ snapshot: args.snapshot }));
|
|
588
|
+
if (!readObject(api.data))
|
|
589
|
+
throw new Error("Malformed program payout response.");
|
|
590
|
+
return omitMapPoints(withApiMetadata(api, sanitizeProPayload(api.data)), "value_points", args.include_map);
|
|
591
|
+
}));
|
|
592
|
+
const readActivityRanking = async (tab, args) => {
|
|
593
|
+
assertProFeature(client, "activity_rankings", "enabled");
|
|
594
|
+
assertActivitySnapshotSelector(client, "activity_rankings", args.snapshot);
|
|
595
|
+
assertActivityTierFilter(client, args.opportunity_tiers);
|
|
596
|
+
const { include_map, ...query } = args;
|
|
597
|
+
return activityRankingPage(await client.getActivityRankings(toQuery({ ...query, tab })), include_map === true);
|
|
598
|
+
};
|
|
599
|
+
registerTool("get_activity_rankings", {
|
|
600
|
+
title: "Get Activity Ranking",
|
|
601
|
+
description: "One globally ranked server page of an Activity view, frozen at meta.as_of. Read get_activity_capabilities first: explicit period/basis/precision/sort/direction never fall back, and each tab only accepts its own inputs. tab: payouts (reported and tracked bases stay separate; proven_bounds desc = highest reported minimum, asc = lowest reported maximum, never an exact ranking; source totals can lag); value (fixed lifetime generosity and 90-day volume percentiles, no period; one label gives its shortlist); scope (recorded additions/removals/net, not inventory differences); rewards (net reward-range change, unknown % stays null); updates (active UTC days, change groups); reports (received counts; volume is not competition; all_time is the lifetime counter); response (resolved counts, last resolution, response time; time to bounty is award timing); status (first_seen/paused/resumed/left_listing/returned events); all_changes (chronological events; get_activity_change_detail for before/after). Event tabs use rolling 7d/30d/90d, not calendar months. launched_within applies to reports/response. Payout increases are observed counters, not payment dates. Reuse cursors only with identical inputs. For several signals at once, or with Web2/scope/competition/fee filters, use find_programs or find_hunt_targets.",
|
|
602
|
+
inputSchema: z.object(activityParams).strict(),
|
|
603
|
+
outputSchema: activityOutput,
|
|
604
|
+
annotations: readOnlyAnnotations
|
|
605
|
+
}, (args) => runTool("get_activity_rankings", config, rateLimiter, async () => {
|
|
606
|
+
assertActivityTabInputs(args.tab, args);
|
|
607
|
+
return readActivityRanking(args.tab, args);
|
|
608
|
+
}));
|
|
609
|
+
// Single-view aliases for the full profile; each schema exposes only the
|
|
610
|
+
// inputs its view accepts and shares the ranking tool's request path.
|
|
611
|
+
const activityDescriptions = {
|
|
612
|
+
value: "Discover programs with frozen within-platform value and report-volume percentiles. Generosity is lifetime; volume is 90 days. No period or launch-cohort input applies. Use a label-specific shortlist: Pays near top orders by realization, No payouts seen by received_90d (raw count with its basis; small cohorts remain eligible). Comparative ratio sorts use server percentiles; never recompute after filtering. Preserve label_details explanations and destinations. A counter increase is observed activity, not an individual payment. Map points are returned only with include_map.",
|
|
613
|
+
payouts: "Rank payouts using the same server calculation as BBRadar. Last 7 days and last 30 days are rolling windows. Explicit periods never fall back. For high or low payouts lately, use the same enabled tracked-30-day cohort, or separately label a native-90-day cohort. Reported and tracked bases stay separate. With proven_bounds, descending is Highest reported minimum using lower bounds; ascending is Lowest reported maximum using upper bounds. Neither claims an exact ranking. Source totals can be delayed, including Immunefi.",
|
|
614
|
+
scope: "Rank recorded scope additions, removals, net movement or largest update; not differences between inventory totals. Periods are rolling 7, 30 or 90 days, not calendar months.",
|
|
615
|
+
rewards: "Rank comparable net reward-range changes over rolling 7, 30 or 90 days. Unknown change percentages remain null.",
|
|
616
|
+
updates: "Rank active UTC days and recorded change groups over rolling periods. Baselines and internal corrections are excluded.",
|
|
617
|
+
reports: "Rank received report counts. Each program uses measured lifetime-counter growth where available, otherwise its supported native period. All-time is the current lifetime counter. Previous and change compare like-for-like. Report volume is not competition. Launch cohorts use the canonical launch date; amounts are not reports since launch.",
|
|
618
|
+
response: "Rank resolved counter growth or current lifetime counters, last resolution, response duration or time to bounty. Keep the provider's definition: time to bounty is award timing, not payment settlement. Period applies only to resolved counts.",
|
|
619
|
+
status: "Read distinct First seen, Paused, Resumed, Left listing and Returned events. Event type and current status are separate filters; never merge events from one program.",
|
|
620
|
+
all_changes: "Read chronological semantic program changes with event IDs. Fetch get_activity_change_detail lazily for before/after items, including eligible events for programs that left the catalog. Separate events stay separate."
|
|
621
|
+
};
|
|
622
|
+
for (const tab of ACTIVITY_TABS) {
|
|
623
|
+
const name = `get_activity_${tab === "all_changes" ? "changes" : tab}`;
|
|
624
|
+
const allowed = new Set([...ACTIVITY_SHARED_PARAMS, ...ACTIVITY_TAB_PARAMS[tab]]);
|
|
625
|
+
const shape = Object.fromEntries(Object.entries(activityParams).filter(([key]) => key !== "tab" && allowed.has(key)));
|
|
626
|
+
if (!ACTIVITY_COUNT_TABS.has(tab) && shape.period)
|
|
627
|
+
shape.period = z.enum(ACTIVITY_EVENT_PERIODS).optional();
|
|
628
|
+
registerTool(name, {
|
|
629
|
+
title: `Get Activity ${tab === "all_changes" ? "Changes" : tab[0].toUpperCase() + tab.slice(1)}`,
|
|
630
|
+
description: `${activityDescriptions[tab]} One globally ranked server page, with a frozen as-of cutoff. Reuse cursors only with unchanged filters. Use get_activity_capabilities for supported combinations.`,
|
|
631
|
+
inputSchema: z.object(shape).strict(), outputSchema: activityOutput, annotations: readOnlyAnnotations
|
|
632
|
+
}, (args) => runTool(name, config, rateLimiter, () => readActivityRanking(tab, args)));
|
|
633
|
+
}
|
|
634
|
+
registerTool("get_activity_change_detail", {
|
|
635
|
+
title: "Get Activity Change Detail",
|
|
636
|
+
description: "Read one bounded page of recorded before/after items for an exact Activity event ID in its source snapshot. Pass meta.snapshot_id from the Activity page. Retained eligible events may describe programs that have left the catalog. Reuse an opaque cursor only with the same event, snapshot and limit.",
|
|
637
|
+
inputSchema: z.object({ event_id: z.string().min(1).max(128), snapshot: activitySnapshotSchema.describe("meta.snapshot_id from the Activity page that supplied this event."), cursor: opaqueCursorSchema.optional(), limit: proLimitSchema.default(50) }).strict(),
|
|
638
|
+
outputSchema: { ...apiEnvelopeOutputShape(), change: jsonRecordSchema().optional(), items: z.array(jsonRecordSchema()).optional(), meta: jsonRecordSchema().optional() }, annotations: readOnlyAnnotations
|
|
639
|
+
}, (args) => runTool("get_activity_change_detail", config, rateLimiter, async () => {
|
|
640
|
+
assertProFeature(client, "activity_event_detail", "enabled");
|
|
641
|
+
return proPage(await client.getActivityChangeDetail(args.event_id, toQuery({ snapshot: args.snapshot, cursor: args.cursor, limit: args.limit })), "items");
|
|
642
|
+
}));
|
|
481
643
|
registerTool("get_history_changes", {
|
|
482
644
|
title: "Get Program History Changes",
|
|
483
645
|
description: "One page of semantic program History. Use get_program_history for an exact program, especially Self-Hosted. History may be partial or unavailable; inspect coverage.",
|
|
@@ -554,8 +716,8 @@ export function createBbradarServer(client, config) {
|
|
|
554
716
|
}));
|
|
555
717
|
registerTool("search_programs", {
|
|
556
718
|
title: "Search BBRadar Programs",
|
|
557
|
-
description: `Search or list active and confirmed paused platform programs plus active Self-Hosted programs. page_size max ${maxProgramsPageSize}. Stable snapshots are opt-in
|
|
558
|
-
inputSchema: {
|
|
719
|
+
description: `Search or list active and confirmed paused platform programs plus active Self-Hosted programs. page_size max ${maxProgramsPageSize}. Stable snapshots are opt-in. For payout, report, scope-change or other Activity filters use find_programs.`,
|
|
720
|
+
inputSchema: z.object({
|
|
559
721
|
search: searchTextSchema.optional().describe("Server-side program name, handle, platform, policy-host, or target search."),
|
|
560
722
|
program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
|
|
561
723
|
platforms: platformListSchema(),
|
|
@@ -565,7 +727,7 @@ export function createBbradarServer(client, config) {
|
|
|
565
727
|
vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
|
|
566
728
|
page: pageSchema,
|
|
567
729
|
page_size: programsPageSizeSchema
|
|
568
|
-
},
|
|
730
|
+
}).strict(),
|
|
569
731
|
outputSchema: programListOutputShape(),
|
|
570
732
|
annotations: readOnlyAnnotations
|
|
571
733
|
}, (args) => runTool("search_programs", config, rateLimiter, async () => {
|
|
@@ -924,8 +1086,9 @@ export function createBbradarServer(client, config) {
|
|
|
924
1086
|
}));
|
|
925
1087
|
registerTool("find_hunt_targets", {
|
|
926
1088
|
title: "Find Best Hunt Targets",
|
|
927
|
-
description: "Preferred one-call path for requests combining good rewards, broad Web2/Web3 scope, target quality, competition, and recently added targets. Returns a final ranked shortlist with exact bounded target samples and recent additions; do not fan out into repeated find_programs/get_program_targets calls. Self-Hosted is supported through current inventory and automatically skips its unavailable change feed. Use find_repository_hunt_targets instead only when Git or repository activity is explicitly requested.",
|
|
928
|
-
inputSchema: {
|
|
1089
|
+
description: "Preferred one-call path for requests combining good rewards, broad Web2/Web3 scope, target quality, competition, recent payouts or scope changes, and recently added targets. Activity inputs (e.g. payout_period=30d with payout_positive=true, scope_period=30d with scope_added_min=1) first narrow candidates on the server in global order; the other filters and ranking_mode then apply locally. Returns a final ranked shortlist with exact bounded target samples and recent additions; do not fan out into repeated find_programs/get_program_targets calls. Self-Hosted is supported through current inventory and automatically skips its unavailable change feed. Use find_repository_hunt_targets instead only when Git or repository activity is explicitly requested.",
|
|
1090
|
+
inputSchema: z.object({
|
|
1091
|
+
...activityCatalogInput,
|
|
929
1092
|
platforms: platformListSchema(),
|
|
930
1093
|
exclude_platforms: stringListSchema("Platforms to exclude from the final shortlist."),
|
|
931
1094
|
tags: tagListSchema(),
|
|
@@ -952,9 +1115,10 @@ export function createBbradarServer(client, config) {
|
|
|
952
1115
|
max_program_pages: z.number().int().min(1).max(MAX_FIND_PROGRAM_PAGES).default(DEFAULT_HUNT_PROGRAM_PAGES),
|
|
953
1116
|
max_results: z.number().int().min(1).max(MAX_HUNT_RESULTS).default(10),
|
|
954
1117
|
targets_per_result: z.number().int().min(1).max(MAX_HUNT_TARGETS_PER_RESULT).default(5),
|
|
1118
|
+
free_submission_only: freeSubmissionOnlySchema,
|
|
955
1119
|
ranking_mode: z.enum(["balanced", "highest_reward", "widest_scope", "freshest_targets", "lowest_competition"]).default("balanced"),
|
|
956
1120
|
upstream_request_budget: z.number().int().min(2).max(MAX_FIND_UPSTREAM_REQUEST_BUDGET).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
|
|
957
|
-
},
|
|
1121
|
+
}).strict(),
|
|
958
1122
|
outputSchema: {
|
|
959
1123
|
...apiEnvelopeOutputShape(),
|
|
960
1124
|
matches: z.array(jsonRecordSchema()).optional(),
|
|
@@ -962,11 +1126,15 @@ export function createBbradarServer(client, config) {
|
|
|
962
1126
|
errors: z.array(jsonRecordSchema()).optional(),
|
|
963
1127
|
upstream_budget: jsonRecordSchema().optional(),
|
|
964
1128
|
ranking_scope: jsonRecordSchema().optional(),
|
|
1129
|
+
scan: jsonRecordSchema().optional(),
|
|
965
1130
|
result_guidance: jsonRecordSchema().optional(),
|
|
966
1131
|
meta: jsonRecordSchema().optional()
|
|
967
1132
|
},
|
|
968
1133
|
annotations: readOnlyAnnotations
|
|
969
|
-
}, (args) => runTool("find_hunt_targets", config, rateLimiter, async () =>
|
|
1134
|
+
}, (args) => runTool("find_hunt_targets", config, rateLimiter, async () => {
|
|
1135
|
+
const activity = Object.fromEntries(Object.keys(activityCatalogInput).map((key) => [key, args[key]]));
|
|
1136
|
+
return findHuntTargets(client, config, args, activity);
|
|
1137
|
+
}));
|
|
970
1138
|
registerTool("find_repository_hunt_targets", {
|
|
971
1139
|
title: "Find Repository-Aware Hunt Targets",
|
|
972
1140
|
description: "Fast one-call path for requests that combine recent repository activity with program rewards and target quality. Fetches program rankings and repository changes in parallel, joins them locally, and works with arbitrary supported platform catalogs, Web2/Web3 modes, reward thresholds, target types, languages, and freshness criteria. Self-Hosted has no repository stream and is rejected without upstream work.",
|
|
@@ -1184,16 +1352,19 @@ export function createBbradarServer(client, config) {
|
|
|
1184
1352
|
}, (args) => runTool("check_watchlist_new_targets", config, rateLimiter, async () => checkWatchlistNewTargets(client, config, args)));
|
|
1185
1353
|
registerTool("get_opportunities", {
|
|
1186
1354
|
title: "Get BBRadar Opportunities",
|
|
1187
|
-
description: `List programs in one opportunity tier. page_size max ${MAX_PROGRAMS_PER_SEARCH}.`,
|
|
1188
|
-
inputSchema: {
|
|
1355
|
+
description: `List programs in one opportunity tier. page_size max ${MAX_PROGRAMS_PER_SEARCH}. For Activity filters within a tier use find_programs with one opportunity_levels entry.`,
|
|
1356
|
+
inputSchema: z.object({
|
|
1189
1357
|
level: opportunityLevelSchema.default("elite"),
|
|
1358
|
+
program_kind: z.enum(["bounty", "contest", "challenge"]).optional(),
|
|
1359
|
+
search: searchTextSchema.optional(),
|
|
1190
1360
|
platforms: platformListSchema(),
|
|
1191
1361
|
tags: tagListSchema(),
|
|
1192
1362
|
updated_since: isoDateTimeSchema.optional(),
|
|
1363
|
+
snapshot: stableSnapshotSchema.optional(),
|
|
1193
1364
|
vdp_mode: vdpModeSchema.default("exclude").describe("VDP/no-bounty filter mode."),
|
|
1194
1365
|
page: pageSchema,
|
|
1195
1366
|
page_size: programsPageSizeSchema
|
|
1196
|
-
},
|
|
1367
|
+
}).strict(),
|
|
1197
1368
|
outputSchema: {
|
|
1198
1369
|
...programListOutputShape(),
|
|
1199
1370
|
level: opportunityLevelSchema.optional()
|
|
@@ -1201,11 +1372,15 @@ export function createBbradarServer(client, config) {
|
|
|
1201
1372
|
annotations: readOnlyAnnotations
|
|
1202
1373
|
}, (args) => runTool("get_opportunities", config, rateLimiter, async () => {
|
|
1203
1374
|
const { level, vdp_mode, ...filters } = args;
|
|
1204
|
-
|
|
1375
|
+
assertStableSnapshotSupported(client, "opportunities", filters.snapshot);
|
|
1376
|
+
const combined = args.program_kind !== undefined;
|
|
1377
|
+
if (combined)
|
|
1378
|
+
assertCatalogQuerySupported(client, "opportunities", args);
|
|
1379
|
+
const api = await client.getOpportunities(level, toQuery({ ...filters, vdp_mode: combined ? vdp_mode : undefined }));
|
|
1205
1380
|
const data = readObject(api.data);
|
|
1206
1381
|
const programs = readArray(data?.programs)
|
|
1207
1382
|
.map((program) => toProgramCandidate(program, config.webBaseUrl))
|
|
1208
|
-
.filter((candidate) => candidateMatchesVdpMode(candidate, vdp_mode))
|
|
1383
|
+
.filter((candidate) => combined || candidateMatchesVdpMode(candidate, vdp_mode))
|
|
1209
1384
|
.map((candidate) => candidate.program);
|
|
1210
1385
|
return withApiMetadata(api, {
|
|
1211
1386
|
level,
|
|
@@ -1215,8 +1390,12 @@ export function createBbradarServer(client, config) {
|
|
|
1215
1390
|
}));
|
|
1216
1391
|
registerTool("find_programs", {
|
|
1217
1392
|
title: "Find BBRadar Programs",
|
|
1218
|
-
description:
|
|
1219
|
-
inputSchema: {
|
|
1393
|
+
description: `Program discovery. Activity inputs (payouts, reports, scope/reward changes, response, status, labels) run on the server over the complete catalog in one global order, within at most one opportunity tier. They combine freely with local filters (Web2/Web3, wildcards, target counts, bounty, competition_risks, report counts, free_submission_only, freshness): pages are read in server order and local filters only remove programs; scan.complete and scan.next_cursor report coverage. Sampled sorts need no Activity inputs. ${activityCatalogGuidance}`,
|
|
1394
|
+
inputSchema: z.object({
|
|
1395
|
+
...activityCatalogInput,
|
|
1396
|
+
search: searchTextSchema.optional(),
|
|
1397
|
+
snapshot: stableSnapshotSchema.optional(),
|
|
1398
|
+
page: z.number().int().min(1).optional(),
|
|
1220
1399
|
platforms: platformListSchema(),
|
|
1221
1400
|
exclude_platforms: stringListSchema("Platforms to exclude after retrieval. Use list_filters first when the user asks for a geographic/platform exclusion without naming exact platform values."),
|
|
1222
1401
|
tags: tagListSchema(),
|
|
@@ -1226,6 +1405,9 @@ export function createBbradarServer(client, config) {
|
|
|
1226
1405
|
web3: z.boolean().default(false).describe("Require Web3 signals."),
|
|
1227
1406
|
program_kind: z.enum(["bounty", "contest", "challenge"]).optional().describe("Source-classified program kind; sent to the Pro program list."),
|
|
1228
1407
|
exclude_web3: z.boolean().default(false).describe("Exclude programs with Web3, blockchain, smart-contract, crypto, DeFi, NFT, Solidity, Ethereum, or Solana signals. Use this for Web2-only discovery."),
|
|
1408
|
+
free_submission_only: freeSubmissionOnlySchema,
|
|
1409
|
+
competition_risks: z.array(competitionRiskSchema).max(4).default([]).describe("Program competition labels to keep, e.g. [\"Low\"]."),
|
|
1410
|
+
cursor: z.string().min(1).max(512).optional().describe("scan.next_cursor from the previous result; repeat the same filters."),
|
|
1229
1411
|
opportunity_levels: z.array(opportunityLevelSchema).max(4).default([]),
|
|
1230
1412
|
updated_since: isoDateTimeSchema.optional(),
|
|
1231
1413
|
min_bounty_min: z.number().int().min(0).optional(),
|
|
@@ -1249,7 +1431,7 @@ export function createBbradarServer(client, config) {
|
|
|
1249
1431
|
min_added_7d: z.number().int().min(0).optional(),
|
|
1250
1432
|
max_pages: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
|
|
1251
1433
|
max_results: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(10),
|
|
1252
|
-
sort_by: programSortSchema,
|
|
1434
|
+
sort_by: z.union([programSortSchema.removeDefault(), activityCatalogSortSchema]).optional(),
|
|
1253
1435
|
include_target_samples: z.boolean().default(false),
|
|
1254
1436
|
target_sample_programs: z.number().int().min(0).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(3),
|
|
1255
1437
|
target_sample_size: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(5),
|
|
@@ -1257,13 +1439,40 @@ export function createBbradarServer(client, config) {
|
|
|
1257
1439
|
only_bounty_eligible_targets: z.boolean().default(true),
|
|
1258
1440
|
output_mode: programListModeSchema.default("compact"),
|
|
1259
1441
|
upstream_request_budget: z.number().int().min(1).max(MAX_ACCEPTED_NUMERIC_LIMIT).default(DEFAULT_FIND_UPSTREAM_REQUEST_BUDGET)
|
|
1260
|
-
},
|
|
1442
|
+
}).strict(),
|
|
1261
1443
|
outputSchema: findProgramsOutputShape(),
|
|
1262
1444
|
annotations: readOnlyAnnotations
|
|
1263
|
-
}, (args) => runTool("find_programs", config, rateLimiter, async () =>
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1445
|
+
}, (args) => runTool("find_programs", config, rateLimiter, async () => {
|
|
1446
|
+
const serverCatalog = hasActivityCatalogInput(args) || args.snapshot !== undefined || args.page !== undefined || args.cursor !== undefined
|
|
1447
|
+
|| (args.search !== undefined && supportsCatalogFilters(client, args.opportunity_levels.length ? "opportunities" : "programs")
|
|
1448
|
+
&& !legacyCatalogFilters(args).length && ["include", "exclude", "only"].includes(args.vdp_mode));
|
|
1449
|
+
if (serverCatalog && args.cursor === undefined && !legacyCatalogFilters(args).length)
|
|
1450
|
+
return findServerCatalogPrograms(client, config, args);
|
|
1451
|
+
if (serverCatalog) {
|
|
1452
|
+
// Platform, tag, kind, VDP, update and status predicates were applied
|
|
1453
|
+
// by the server; only the remaining filters run locally.
|
|
1454
|
+
return findFilteredServerPrograms(client, config, args, {
|
|
1455
|
+
...args, sort_by: "best_match", platforms: [], tags: [], program_kind: undefined, contest_like: false,
|
|
1456
|
+
vdp_mode: "include", opportunity_levels: [], updated_since: undefined, skip_status_check: true,
|
|
1457
|
+
min_bounty_min: args.min_bounty_min, min_bounty_max: args.min_bounty_max, max_bounty_max: args.max_bounty_max,
|
|
1458
|
+
max_public_report_count: args.max_public_report_count, fresh_launch_days: args.fresh_launch_days,
|
|
1459
|
+
min_eligible_targets: args.min_eligible_targets, min_total_targets: args.min_total_targets,
|
|
1460
|
+
min_added_24h: args.min_added_24h, min_added_7d: args.min_added_7d,
|
|
1461
|
+
target_samples_only_wildcards: args.min_wildcards > 0
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
return findPrograms(client, config, {
|
|
1465
|
+
...args,
|
|
1466
|
+
updated_since: args.updated_since,
|
|
1467
|
+
min_bounty_min: args.min_bounty_min, min_bounty_max: args.min_bounty_max,
|
|
1468
|
+
max_bounty_max: args.max_bounty_max, max_public_report_count: args.max_public_report_count,
|
|
1469
|
+
fresh_launch_days: args.fresh_launch_days,
|
|
1470
|
+
min_eligible_targets: args.min_eligible_targets, min_total_targets: args.min_total_targets,
|
|
1471
|
+
min_added_24h: args.min_added_24h, min_added_7d: args.min_added_7d,
|
|
1472
|
+
sort_by: programSortSchema.parse(args.sort_by),
|
|
1473
|
+
target_samples_only_wildcards: args.min_wildcards > 0
|
|
1474
|
+
});
|
|
1475
|
+
}));
|
|
1267
1476
|
registerTool("find_wildcard_programs", {
|
|
1268
1477
|
title: "Find Wildcard Programs",
|
|
1269
1478
|
description: "Wildcard program shortlist with platform, minimum reward, freshness, and report-count filters.",
|
|
@@ -2177,6 +2386,9 @@ function bbradarGuide(topic, profile, client) {
|
|
|
2177
2386
|
arguments: { include_targets: false, max_cursor_pages: 2 },
|
|
2178
2387
|
then: "For incremental polling, seed once with after_id and later set resume_cursor=true with the same filters."
|
|
2179
2388
|
},
|
|
2389
|
+
{ user_goal: "program payouts, recent reports, response times or Activity rankings", first_tool: "get_activity_capabilities", arguments: {}, then: "Choose an enabled period, sort and direction, then call get_activity_rankings with that tab for one server-ranked page. Do not approximate by paging or re-ranking the catalog." },
|
|
2390
|
+
{ user_goal: "programs with recent payouts and new scope that also match Web2, competition or fee criteria", first_tool: "find_hunt_targets", arguments: { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", free_submission_only: true, ranking_mode: "lowest_competition" }, then: "Use find_programs with the same Activity inputs and competition_risks for program-only results; continue with scan.next_cursor." },
|
|
2391
|
+
{ user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
|
|
2180
2392
|
{ user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch get_history_change_detail only for a selected change." },
|
|
2181
2393
|
{ user_goal: "most active programs in a date range", first_tool: "get_analytics_program_activity", arguments: {}, then: "Use this server-side ranking rather than sampling the program catalog." },
|
|
2182
2394
|
{ user_goal: "exact recorded activity for one program", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
|
|
@@ -2322,6 +2534,9 @@ function coreBbradarGuide(topic, client) {
|
|
|
2322
2534
|
first_tool: "get_repository_changes",
|
|
2323
2535
|
arguments: { include_targets: false, max_cursor_pages: 2 }
|
|
2324
2536
|
},
|
|
2537
|
+
{ user_goal: "program payouts, recent reports, response times or Activity rankings", first_tool: "get_activity_capabilities", arguments: {}, then: "Read supported view combinations and call get_activity_rankings with that tab. Use get_activity_change_detail lazily for a selected Activity event." },
|
|
2538
|
+
{ user_goal: "programs with recent payouts and new scope that also match Web2, competition or fee criteria", first_tool: "find_hunt_targets", arguments: { payout_period: "30d", payout_positive: true, scope_period: "30d", scope_added_min: 1, surface_mode: "web2", free_submission_only: true, ranking_mode: "lowest_competition" } },
|
|
2539
|
+
{ user_goal: "payout and report facts for one exact program", first_tool: "get_program_payouts", arguments: { program_id: "Platform:handle" } },
|
|
2325
2540
|
{ user_goal: "semantic program change history", first_tool: "get_program_history", arguments: { program_id: "Platform:handle" }, then: "Fetch change detail only when needed." },
|
|
2326
2541
|
{ user_goal: "most active programs", first_tool: "get_analytics_program_activity", arguments: {} },
|
|
2327
2542
|
{ user_goal: "exact recorded activity", first_tool: "get_program_activity", arguments: { program_id: "Platform:handle" } },
|
|
@@ -6040,6 +6255,9 @@ function compactProgram(program) {
|
|
|
6040
6255
|
competition_risk: stringField(program, "competition_risk"),
|
|
6041
6256
|
competition_risk_reason: stringField(program, "competition_risk_reason"),
|
|
6042
6257
|
submission_cost: program.submission_cost,
|
|
6258
|
+
payout_summary: program.payout_summary,
|
|
6259
|
+
activity: program.activity,
|
|
6260
|
+
activity_summary: program.activity_summary,
|
|
6043
6261
|
reward_range: program.reward_range,
|
|
6044
6262
|
reward_range_label: stringField(program, "reward_range_label"),
|
|
6045
6263
|
public_report_count: program.public_report_count,
|
|
@@ -6598,7 +6816,10 @@ function clampLimit(name, value, max, warnings) {
|
|
|
6598
6816
|
warnings.push(`${name} was clamped from ${value} to ${max}.`);
|
|
6599
6817
|
return max;
|
|
6600
6818
|
}
|
|
6601
|
-
|
|
6819
|
+
// Candidate pool for hunts narrowed by Activity on the server. Matches arrive in
|
|
6820
|
+
// server order, so a larger pool lets ranking_mode choose among more of them.
|
|
6821
|
+
const HUNT_SERVER_POOL = 100;
|
|
6822
|
+
async function findHuntTargets(client, config, input, activity = {}) {
|
|
6602
6823
|
const warnings = [];
|
|
6603
6824
|
const errors = [];
|
|
6604
6825
|
const selfHostedOnly = isSelfHostedOnlyPlatformSelection(input.platforms);
|
|
@@ -6641,6 +6862,7 @@ async function findHuntTargets(client, config, input) {
|
|
|
6641
6862
|
min_total_targets: input.min_total_targets,
|
|
6642
6863
|
min_added_24h: undefined,
|
|
6643
6864
|
min_added_7d: input.min_added_7d,
|
|
6865
|
+
free_submission_only: input.free_submission_only,
|
|
6644
6866
|
max_pages: input.max_program_pages,
|
|
6645
6867
|
max_results: candidatePoolSize,
|
|
6646
6868
|
sort_by: huntProgramSort(input.ranking_mode),
|
|
@@ -6662,8 +6884,17 @@ async function findHuntTargets(client, config, input) {
|
|
|
6662
6884
|
else if (recentChangeQueries.length < desiredRecentChangeQueries.length) {
|
|
6663
6885
|
warnings.push(`Recent target-change queries were reduced from ${desiredRecentChangeQueries.length} to ${recentChangeQueries.length} to preserve the upstream request budget.`);
|
|
6664
6886
|
}
|
|
6887
|
+
const serverFiltered = hasActivityCatalogInput(activity);
|
|
6665
6888
|
const [programPayload, recentResult] = await Promise.all([
|
|
6666
|
-
|
|
6889
|
+
serverFiltered
|
|
6890
|
+
? findFilteredServerPrograms(client, config, {
|
|
6891
|
+
...programInput, ...activity, opportunity_levels: [], sort_by: activity.sort_by, sort_direction: activity.sort_direction,
|
|
6892
|
+
platforms: input.platforms, tags: input.tags, updated_since: input.updated_since, vdp_mode: "exclude", include_target_samples: false
|
|
6893
|
+
}, {
|
|
6894
|
+
...programInput, platforms: [], tags: [], vdp_mode: "include", updated_since: undefined,
|
|
6895
|
+
max_pages: programRequestBudget, upstream_request_budget: programRequestBudget
|
|
6896
|
+
}, HUNT_SERVER_POOL)
|
|
6897
|
+
: findPrograms(client, config, programInput),
|
|
6667
6898
|
recentChangesEnabled
|
|
6668
6899
|
? fetchHuntRecentChanges(client, config, input, recentChangeQueries)
|
|
6669
6900
|
: Promise.resolve({ changes: [], sourceRequests: [], requestsUsed: 0, errors: [] })
|
|
@@ -6733,6 +6964,8 @@ async function findHuntTargets(client, config, input) {
|
|
|
6733
6964
|
min_target_score: input.min_target_score,
|
|
6734
6965
|
target_opportunity_label: input.target_opportunity_label,
|
|
6735
6966
|
competition_risk: input.competition_risk,
|
|
6967
|
+
free_submission_only: input.free_submission_only || undefined,
|
|
6968
|
+
activity: serverFiltered ? stripUndefined(activity) : undefined,
|
|
6736
6969
|
recent_target_days: input.include_recent_changes ? input.recent_target_days : undefined,
|
|
6737
6970
|
ranking_mode: input.ranking_mode
|
|
6738
6971
|
}),
|
|
@@ -6743,10 +6976,12 @@ async function findHuntTargets(client, config, input) {
|
|
|
6743
6976
|
},
|
|
6744
6977
|
ranking_scope: stripUndefined({
|
|
6745
6978
|
...(readObject(programPayload.ranking_scope) ?? {}),
|
|
6979
|
+
local_ranking: serverFiltered ? input.ranking_mode : undefined,
|
|
6746
6980
|
single_program_catalog_pass: true,
|
|
6747
6981
|
recent_change_window_days: input.include_recent_changes ? input.recent_target_days : undefined,
|
|
6748
6982
|
recent_change_page_size: input.include_recent_changes ? input.recent_changes_page_size : undefined
|
|
6749
6983
|
}),
|
|
6984
|
+
scan: readObject(programPayload.scan),
|
|
6750
6985
|
result_guidance: {
|
|
6751
6986
|
final_shortlist: true,
|
|
6752
6987
|
follow_up_calls_required: false,
|
|
@@ -7459,6 +7694,262 @@ function uniqueProgramsFromRepositoryMatches(matches) {
|
|
|
7459
7694
|
}
|
|
7460
7695
|
return [...programs.values()];
|
|
7461
7696
|
}
|
|
7697
|
+
/** Filters the MCP applies to program records after retrieval. */
|
|
7698
|
+
function localCatalogFilters(input) {
|
|
7699
|
+
const keys = ["exclude_platforms", "scope_tags", "target_types", "language_tags", "web3", "exclude_web3",
|
|
7700
|
+
"min_bounty_min", "min_bounty_max", "max_bounty_max", "require_bounty", "max_public_report_count",
|
|
7701
|
+
"require_known_report_count", "include_unknown_report_count", "exclude_ctf", "exclude_mobile_only",
|
|
7702
|
+
"exclude_non_web", "has_api_scope", "fresh_launch_days", "min_eligible_targets", "min_total_targets",
|
|
7703
|
+
"min_added_24h", "min_added_7d", "free_submission_only", "competition_risks"].filter(key => {
|
|
7704
|
+
const value = input[key];
|
|
7705
|
+
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== false;
|
|
7706
|
+
});
|
|
7707
|
+
if (Number(input.min_wildcards) > 0)
|
|
7708
|
+
keys.push("min_wildcards");
|
|
7709
|
+
return keys;
|
|
7710
|
+
}
|
|
7711
|
+
function legacyCatalogFilters(input) {
|
|
7712
|
+
return input.include_target_samples === true ? [...localCatalogFilters(input), "include_target_samples"] : localCatalogFilters(input);
|
|
7713
|
+
}
|
|
7714
|
+
function supportsCatalogFilters(client, endpoint) {
|
|
7715
|
+
return client.capabilitySnapshot().status === "negotiated"
|
|
7716
|
+
&& readObject(client.endpointCapability(endpoint)?.catalog_filters)?.enabled === true;
|
|
7717
|
+
}
|
|
7718
|
+
function assertCatalogQuerySupported(client, endpoint, input) {
|
|
7719
|
+
if (!["include", "exclude", "only"].includes(String(input.vdp_mode ?? "exclude"))) {
|
|
7720
|
+
throw new Error("Server-ranked queries accept vdp_mode include, exclude or only. The heuristic only_likely and only_known_no_bounty modes are available only in legacy sampled discovery.");
|
|
7721
|
+
}
|
|
7722
|
+
const capability = client.endpointCapability(endpoint);
|
|
7723
|
+
if (!supportsCatalogFilters(client, endpoint)) {
|
|
7724
|
+
throw new Error("This API deployment does not advertise the required catalog filters. Upgrade the API, or use legacy discovery without server paging or Activity filters.");
|
|
7725
|
+
}
|
|
7726
|
+
if (requiresActivityQuery(input) && readObject(capability?.activity_filters)?.enabled !== true) {
|
|
7727
|
+
throw new Error("Activity catalog filters are unavailable on this API deployment. Ordinary program discovery remains available.");
|
|
7728
|
+
}
|
|
7729
|
+
const kind = input.program_kind ?? (input.contest_like ? "contest" : undefined);
|
|
7730
|
+
if (kind && !readArray(capability?.program_kind_filter).includes(kind)) {
|
|
7731
|
+
throw new Error(`This API deployment does not support program_kind=${String(kind)} on ${endpoint}; the filter was not sent or discarded.`);
|
|
7732
|
+
}
|
|
7733
|
+
}
|
|
7734
|
+
function hasActivityCatalogInput(input) {
|
|
7735
|
+
return Object.keys(activityCatalogInput).some((key) => key === "sort_by"
|
|
7736
|
+
? activityCatalogSortSchema.safeParse(input[key]).success
|
|
7737
|
+
: input[key] !== undefined);
|
|
7738
|
+
}
|
|
7739
|
+
/** Validates and builds one server-ordered catalog query; nothing is sent. */
|
|
7740
|
+
function serverCatalogQuery(client, input) {
|
|
7741
|
+
const levels = Array.isArray(input.opportunity_levels) ? input.opportunity_levels : [];
|
|
7742
|
+
if (levels.length > 1)
|
|
7743
|
+
throw new Error("Combined Activity queries accept at most one opportunity tier; separate tier pages cannot form a global ranking.");
|
|
7744
|
+
if (input.sort_by !== undefined && !activityCatalogSortSchema.safeParse(input.sort_by).success) {
|
|
7745
|
+
throw new Error("Combined Activity queries require an enabled server sort_by. Omit it for updated descending; legacy sampled sorts cannot rank these results.");
|
|
7746
|
+
}
|
|
7747
|
+
if (input.contest_like && input.program_kind && input.program_kind !== "contest")
|
|
7748
|
+
throw new Error("contest_like conflicts with program_kind.");
|
|
7749
|
+
const endpoint = levels.length ? "opportunities" : "programs";
|
|
7750
|
+
assertCatalogQuerySupported(client, endpoint, input);
|
|
7751
|
+
assertStableSnapshotSupported(client, endpoint, typeof input.snapshot === "string" ? input.snapshot : undefined);
|
|
7752
|
+
const signals = Object.fromEntries(Object.keys(activityCatalogInput).filter((key) => input[key] !== undefined).map((key) => [key, input[key]]));
|
|
7753
|
+
return {
|
|
7754
|
+
endpoint, level: levels[0], maxPageSize: client.endpointNumber(endpoint, "max_page_size", MAX_PROGRAMS_PER_SEARCH),
|
|
7755
|
+
query: { ...signals, search: input.search, platforms: input.platforms, tags: input.tags,
|
|
7756
|
+
program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined), updated_since: input.updated_since,
|
|
7757
|
+
vdp_mode: input.vdp_mode, snapshot: input.snapshot }
|
|
7758
|
+
};
|
|
7759
|
+
}
|
|
7760
|
+
function readServerCatalogPage(client, catalog, page, pageSize, snapshot) {
|
|
7761
|
+
const query = toQuery({ ...catalog.query, snapshot, page, page_size: pageSize });
|
|
7762
|
+
return catalog.level ? client.getOpportunities(catalog.level, query) : client.listPrograms(query);
|
|
7763
|
+
}
|
|
7764
|
+
async function findServerCatalogPrograms(client, config, input) {
|
|
7765
|
+
// One exact server page: page and snapshot continue it. Local filters use the
|
|
7766
|
+
// ordered scan below instead.
|
|
7767
|
+
const catalog = serverCatalogQuery(client, input);
|
|
7768
|
+
const pageSize = Number(input.max_results) || 10;
|
|
7769
|
+
if (pageSize > catalog.maxPageSize)
|
|
7770
|
+
throw new Error(`Combined Activity pages support max_results up to ${catalog.maxPageSize}; use page and the returned stable snapshot to continue.`);
|
|
7771
|
+
const api = await readServerCatalogPage(client, catalog, Number(input.page ?? 1), pageSize, input.snapshot);
|
|
7772
|
+
const data = readObject(api.data);
|
|
7773
|
+
const mode = input.output_mode === "full" ? "full" : "compact";
|
|
7774
|
+
return withApiMetadata(api, {
|
|
7775
|
+
programs: readArray(data?.programs).map((program) => formatProgram(sanitizeProgram(program, config.webBaseUrl), mode)),
|
|
7776
|
+
ranking_scope: { mode: "server", sort_by: input.sort_by ?? "updated", sort_direction: input.sort_direction ?? "desc" },
|
|
7777
|
+
meta: sanitizeJson(data?.meta)
|
|
7778
|
+
});
|
|
7779
|
+
}
|
|
7780
|
+
// Pages requested together while scanning. Activity catalog reads share the
|
|
7781
|
+
// two-request changes pool, so a wider window would only queue.
|
|
7782
|
+
const CATALOG_SCAN_PREFETCH = 2;
|
|
7783
|
+
function encodeScanCursor(cursor) {
|
|
7784
|
+
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
|
|
7785
|
+
}
|
|
7786
|
+
function decodeScanCursor(value, fingerprint, pageSize) {
|
|
7787
|
+
let cursor;
|
|
7788
|
+
try {
|
|
7789
|
+
cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
7790
|
+
}
|
|
7791
|
+
catch {
|
|
7792
|
+
cursor = undefined;
|
|
7793
|
+
}
|
|
7794
|
+
if (!cursor || cursor.v !== 1 || !Number.isInteger(cursor.p) || cursor.p < 1 || !Number.isInteger(cursor.k) || cursor.k < 0) {
|
|
7795
|
+
throw new Error("Invalid scan cursor. Use scan.next_cursor from the previous find_programs result.");
|
|
7796
|
+
}
|
|
7797
|
+
if (cursor.f !== fingerprint || cursor.s !== pageSize) {
|
|
7798
|
+
throw new Error("This scan cursor belongs to different filters. Repeat the previous filters exactly, or start again without cursor.");
|
|
7799
|
+
}
|
|
7800
|
+
return cursor;
|
|
7801
|
+
}
|
|
7802
|
+
/**
|
|
7803
|
+
* Reads server-ordered pages and keeps the programs that also pass the local
|
|
7804
|
+
* filters, until enough match or the page/request budget is spent. Filtering
|
|
7805
|
+
* never reorders, so the result is a prefix of the server's global order.
|
|
7806
|
+
*/
|
|
7807
|
+
async function scanServerCatalog(client, config, input, local, limits) {
|
|
7808
|
+
const catalog = serverCatalogQuery(client, input);
|
|
7809
|
+
const pageSize = catalog.maxPageSize;
|
|
7810
|
+
const localFilters = localCatalogFilters(input);
|
|
7811
|
+
const fingerprint = createHash("sha256")
|
|
7812
|
+
.update(JSON.stringify([catalog.endpoint, catalog.level, toQuery({ ...catalog.query, snapshot: undefined }), localFilters.map((key) => [key, input[key]])]))
|
|
7813
|
+
.digest("base64url").slice(0, 16);
|
|
7814
|
+
const cursorValue = typeof input.cursor === "string" ? input.cursor : undefined;
|
|
7815
|
+
if (cursorValue && input.page !== undefined)
|
|
7816
|
+
throw new Error("Use either cursor or page, not both.");
|
|
7817
|
+
const start = cursorValue ? decodeScanCursor(cursorValue, fingerprint, pageSize)
|
|
7818
|
+
: { v: 1, p: Number(input.page ?? 1), k: 0, s: pageSize, f: fingerprint, a: undefined, t: undefined };
|
|
7819
|
+
const sourceRequests = [];
|
|
7820
|
+
let matches = [];
|
|
7821
|
+
let seen = new Set();
|
|
7822
|
+
let nextPage = start.p;
|
|
7823
|
+
let skip = start.k;
|
|
7824
|
+
let version = start.a;
|
|
7825
|
+
let token = start.t ?? input.snapshot;
|
|
7826
|
+
let totalPages;
|
|
7827
|
+
let serverTotal;
|
|
7828
|
+
let rowsScanned = 0;
|
|
7829
|
+
let pagesRead = 0;
|
|
7830
|
+
let restarted = false;
|
|
7831
|
+
let exhausted = false;
|
|
7832
|
+
let resume;
|
|
7833
|
+
scan: while (matches.length < limits.maxResults) {
|
|
7834
|
+
const pagesLeft = totalPages === undefined ? Number.POSITIVE_INFINITY : totalPages - nextPage + 1;
|
|
7835
|
+
const allowance = Math.min(limits.maxPages - pagesRead, limits.budget.remaining, pagesLeft);
|
|
7836
|
+
if (allowance <= 0)
|
|
7837
|
+
break;
|
|
7838
|
+
// Speculate only from an observed match rate, never before the first page.
|
|
7839
|
+
const rate = rowsScanned > 0 ? Math.max(matches.length / rowsScanned, 0.05) : 1;
|
|
7840
|
+
const needed = pagesRead === 0 ? 1 : Math.ceil((limits.maxResults - matches.length) / (rate * pageSize));
|
|
7841
|
+
const batch = Math.max(1, Math.min(allowance, needed, CATALOG_SCAN_PREFETCH));
|
|
7842
|
+
const pageToken = token;
|
|
7843
|
+
const pages = await Promise.all(Array.from({ length: batch }, (_, index) => nextPage + index)
|
|
7844
|
+
.map(async (page) => ({ page, api: await readServerCatalogPage(client, catalog, page, pageSize, pageToken) })));
|
|
7845
|
+
for (const { page, api } of pages) {
|
|
7846
|
+
pagesRead += 1;
|
|
7847
|
+
limits.budget.remaining -= api.cached || api.coalesced ? 0 : 1;
|
|
7848
|
+
sourceRequests.push({ source: catalog.level ? `opportunities:${catalog.level}` : "programs", page, request_id: api.requestId,
|
|
7849
|
+
upstream_request_id: api.upstreamRequestId, ...apiSourceMetadata(api) });
|
|
7850
|
+
const data = readObject(api.data);
|
|
7851
|
+
const meta = readObject(data?.meta);
|
|
7852
|
+
const pageVersion = stringField(meta ?? {}, "activity_snapshot_id");
|
|
7853
|
+
if (version && pageVersion && pageVersion !== version) {
|
|
7854
|
+
// Never mix rows from two Activity versions. Within one call, restart
|
|
7855
|
+
// once on the new version; a continued scan must be restarted by the caller.
|
|
7856
|
+
if (cursorValue || restarted) {
|
|
7857
|
+
throw new Error("The Activity version changed while scanning. Rerun the same query without cursor.");
|
|
7858
|
+
}
|
|
7859
|
+
restarted = true;
|
|
7860
|
+
matches = [];
|
|
7861
|
+
seen = new Set();
|
|
7862
|
+
rowsScanned = 0;
|
|
7863
|
+
nextPage = start.p;
|
|
7864
|
+
skip = start.k;
|
|
7865
|
+
totalPages = undefined;
|
|
7866
|
+
version = pageVersion;
|
|
7867
|
+
continue scan;
|
|
7868
|
+
}
|
|
7869
|
+
version ??= pageVersion;
|
|
7870
|
+
totalPages = readNumber(meta?.total_pages) ?? totalPages;
|
|
7871
|
+
serverTotal = readNumber(meta?.total) ?? serverTotal;
|
|
7872
|
+
if (token !== undefined && typeof meta?.snapshot === "string")
|
|
7873
|
+
token = meta.snapshot;
|
|
7874
|
+
const rows = readArray(data?.programs);
|
|
7875
|
+
const lastPage = rows.length < pageSize || (totalPages !== undefined && page >= totalPages);
|
|
7876
|
+
for (let index = skip; index < rows.length; index += 1) {
|
|
7877
|
+
rowsScanned += 1;
|
|
7878
|
+
const candidate = toProgramCandidate(rows[index], config.webBaseUrl);
|
|
7879
|
+
const id = stringField(candidate.program, "id") ?? `${page}:${index}`;
|
|
7880
|
+
if (seen.has(id) || !programCandidateMatches(candidate, local))
|
|
7881
|
+
continue;
|
|
7882
|
+
seen.add(id);
|
|
7883
|
+
matches.push(candidate);
|
|
7884
|
+
if (matches.length >= limits.maxResults) {
|
|
7885
|
+
resume = index + 1 < rows.length ? { p: page, k: index + 1 } : lastPage ? undefined : { p: page + 1, k: 0 };
|
|
7886
|
+
exhausted = resume === undefined;
|
|
7887
|
+
break scan;
|
|
7888
|
+
}
|
|
7889
|
+
}
|
|
7890
|
+
skip = 0;
|
|
7891
|
+
if (lastPage) {
|
|
7892
|
+
exhausted = true;
|
|
7893
|
+
break scan;
|
|
7894
|
+
}
|
|
7895
|
+
nextPage = page + 1;
|
|
7896
|
+
}
|
|
7897
|
+
}
|
|
7898
|
+
if (!exhausted && !resume)
|
|
7899
|
+
resume = { p: nextPage, k: skip };
|
|
7900
|
+
return {
|
|
7901
|
+
candidates: matches,
|
|
7902
|
+
sourceRequests,
|
|
7903
|
+
scan: stripUndefined({
|
|
7904
|
+
order: "Server order; local filters only remove programs.",
|
|
7905
|
+
sort_by: input.sort_by ?? "updated",
|
|
7906
|
+
sort_direction: input.sort_direction ?? "desc",
|
|
7907
|
+
local_filters: localFilters.length > 0 ? localFilters : undefined,
|
|
7908
|
+
server_matches: serverTotal,
|
|
7909
|
+
rows_scanned: rowsScanned,
|
|
7910
|
+
pages_read: pagesRead,
|
|
7911
|
+
complete: exhausted,
|
|
7912
|
+
next_cursor: resume
|
|
7913
|
+
? encodeScanCursor({ v: 1, p: resume.p, k: resume.k, s: pageSize, f: fingerprint, a: version, t: typeof token === "string" ? token : undefined })
|
|
7914
|
+
: undefined,
|
|
7915
|
+
activity_snapshot_id: version,
|
|
7916
|
+
restarted_on_new_activity_version: restarted || undefined
|
|
7917
|
+
})
|
|
7918
|
+
};
|
|
7919
|
+
}
|
|
7920
|
+
/** find_programs with server Activity filters plus local filters or target samples. */
|
|
7921
|
+
async function findFilteredServerPrograms(client, config, input, local, maxResults = local.max_results) {
|
|
7922
|
+
const normalized = normalizeFindProgramsInput(local);
|
|
7923
|
+
const warnings = normalized.warnings;
|
|
7924
|
+
local = normalized.input;
|
|
7925
|
+
const budget = { initial: local.upstream_request_budget, remaining: local.upstream_request_budget };
|
|
7926
|
+
const result = await scanServerCatalog(client, config, input, local, { maxResults, maxPages: local.max_pages, budget });
|
|
7927
|
+
if (result.scan.complete !== true) {
|
|
7928
|
+
warnings.push("More server pages remain; scan.next_cursor continues in the same order.");
|
|
7929
|
+
}
|
|
7930
|
+
const samplePrograms = Math.min(local.target_sample_programs, result.candidates.length, Math.max(0, budget.remaining));
|
|
7931
|
+
const targetSamples = local.include_target_samples && samplePrograms > 0
|
|
7932
|
+
? await collectTargetSamples(client, result.candidates, { ...local, target_sample_programs: samplePrograms }, budget)
|
|
7933
|
+
: {};
|
|
7934
|
+
const outputMode = local.output_mode ?? "compact";
|
|
7935
|
+
return stripUndefined({
|
|
7936
|
+
request_id: randomUUID(),
|
|
7937
|
+
fetched_at: new Date().toISOString(),
|
|
7938
|
+
source_requests: result.sourceRequests,
|
|
7939
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
7940
|
+
upstream_budget: { used: budget.initial - budget.remaining, remaining: budget.remaining },
|
|
7941
|
+
ranking_scope: { mode: "server_filtered", sort_by: result.scan.sort_by, sort_direction: result.scan.sort_direction, complete: result.scan.complete },
|
|
7942
|
+
scan: result.scan,
|
|
7943
|
+
programs: result.candidates.map((candidate, index) => candidateOutput(candidate, index, targetSamples, outputMode)),
|
|
7944
|
+
meta: { returned: result.candidates.length, server_matches: result.scan.server_matches }
|
|
7945
|
+
});
|
|
7946
|
+
}
|
|
7947
|
+
function programHasNoSubmissionFee(program) {
|
|
7948
|
+
// Mirrors the API: no detected cost, or a detected zero with no maximum.
|
|
7949
|
+
const cost = readObject(program.submission_cost);
|
|
7950
|
+
const amount = numberField(cost, "amount");
|
|
7951
|
+
return amount === undefined || (amount === 0 && (numberField(cost, "maximum_amount") ?? 0) === 0);
|
|
7952
|
+
}
|
|
7462
7953
|
async function findPrograms(client, config, input) {
|
|
7463
7954
|
const normalized = normalizeFindProgramsInput(input);
|
|
7464
7955
|
const warnings = normalized.warnings;
|
|
@@ -7475,6 +7966,7 @@ async function findPrograms(client, config, input) {
|
|
|
7475
7966
|
...(input.min_wildcards > 0 ? ["wildcard"] : [])
|
|
7476
7967
|
]);
|
|
7477
7968
|
const collected = await collectPrograms(client, {
|
|
7969
|
+
search: input.search,
|
|
7478
7970
|
platforms: input.platforms,
|
|
7479
7971
|
tags: upstreamTags,
|
|
7480
7972
|
program_kind: input.program_kind ?? (input.contest_like ? "contest" : undefined),
|
|
@@ -7534,6 +8026,8 @@ async function findPrograms(client, config, input) {
|
|
|
7534
8026
|
min_total_targets: input.min_total_targets,
|
|
7535
8027
|
min_added_24h: input.min_added_24h,
|
|
7536
8028
|
min_added_7d: input.min_added_7d,
|
|
8029
|
+
free_submission_only: input.free_submission_only || undefined,
|
|
8030
|
+
competition_risks: input.competition_risks?.length ? input.competition_risks : undefined,
|
|
7537
8031
|
sort_by: input.sort_by,
|
|
7538
8032
|
output_mode: outputMode
|
|
7539
8033
|
}),
|
|
@@ -8046,7 +8540,7 @@ function programCandidateMatches(candidate, filters) {
|
|
|
8046
8540
|
return false;
|
|
8047
8541
|
}
|
|
8048
8542
|
const programStatus = normalizeTag(stringField(candidate.program, "program_status") ?? "");
|
|
8049
|
-
if (readBoolean(candidate.program.is_active) === false || programStatus === "paused") {
|
|
8543
|
+
if (!filters.skip_status_check && (readBoolean(candidate.program.is_active) === false || programStatus === "paused")) {
|
|
8050
8544
|
return false;
|
|
8051
8545
|
}
|
|
8052
8546
|
if (filters.platforms.length > 0 && !filters.platforms.map(normalizeTag).includes(normalizeTag(stringField(candidate.program, "platform") ?? ""))) {
|
|
@@ -8139,6 +8633,15 @@ function programCandidateMatches(candidate, filters) {
|
|
|
8139
8633
|
if (filters.min_added_7d !== undefined && (candidate.added7d ?? 0) < filters.min_added_7d) {
|
|
8140
8634
|
return false;
|
|
8141
8635
|
}
|
|
8636
|
+
if (filters.free_submission_only && !programHasNoSubmissionFee(candidate.program)) {
|
|
8637
|
+
return false;
|
|
8638
|
+
}
|
|
8639
|
+
if ((filters.competition_risks?.length ?? 0) > 0) {
|
|
8640
|
+
const risk = normalizeTag(stringField(candidate.program, "competition_risk") ?? "unknown");
|
|
8641
|
+
if (!filters.competition_risks.some((value) => normalizeTag(value) === risk)) {
|
|
8642
|
+
return false;
|
|
8643
|
+
}
|
|
8644
|
+
}
|
|
8142
8645
|
return true;
|
|
8143
8646
|
}
|
|
8144
8647
|
function compareProgramCandidates(left, right, sortBy) {
|
|
@@ -8179,6 +8682,8 @@ function compareProgramCandidates(left, right, sortBy) {
|
|
|
8179
8682
|
right.lastUpdatedTime - left.lastUpdatedTime);
|
|
8180
8683
|
}
|
|
8181
8684
|
function candidateMatchesVdpMode(candidate, vdpMode) {
|
|
8685
|
+
if (vdpMode === "only")
|
|
8686
|
+
return stringField(candidate.program, "program_type") === "vdp";
|
|
8182
8687
|
if (vdpMode === "exclude") {
|
|
8183
8688
|
return !candidateLooksLikeVdp(candidate);
|
|
8184
8689
|
}
|
|
@@ -8839,6 +9344,54 @@ function assertProFeature(client, endpoint, flag) {
|
|
|
8839
9344
|
throw new Error(`${endpoint} is advertised but ${flag}=false on this API deployment.`);
|
|
8840
9345
|
}
|
|
8841
9346
|
}
|
|
9347
|
+
function assertActivityTabInputs(tab, args) {
|
|
9348
|
+
const allowed = new Set([...ACTIVITY_SHARED_PARAMS, ...ACTIVITY_TAB_PARAMS[tab]]);
|
|
9349
|
+
const misplaced = Object.entries(args)
|
|
9350
|
+
.filter(([key, value]) => !allowed.has(key) && value !== undefined && value !== false)
|
|
9351
|
+
.map(([key]) => key);
|
|
9352
|
+
if (misplaced.length > 0) {
|
|
9353
|
+
throw new Error(`${misplaced.join(", ")} ${misplaced.length === 1 ? "does" : "do"} not apply to tab=${tab}. Remove ${misplaced.length === 1 ? "it" : "them"} rather than querying a different ranking.`);
|
|
9354
|
+
}
|
|
9355
|
+
if (typeof args.period === "string" && !ACTIVITY_COUNT_TABS.has(tab) && !ACTIVITY_EVENT_PERIODS.includes(args.period)) {
|
|
9356
|
+
throw new Error(`tab=${tab} uses rolling 7d, 30d or 90d periods.`);
|
|
9357
|
+
}
|
|
9358
|
+
}
|
|
9359
|
+
/** One ranking page without the capabilities document the API repeats on every page. */
|
|
9360
|
+
function activityRankingPage(api, includeMap) {
|
|
9361
|
+
const page = proPage(api, "rows");
|
|
9362
|
+
const capabilities = readObject(page.capabilities);
|
|
9363
|
+
delete page.capabilities;
|
|
9364
|
+
// With no enabled default view the API answers sort=null and no rows; keep
|
|
9365
|
+
// only this tab's views so the caller can choose an explicit one.
|
|
9366
|
+
if (page.sort === null && readArray(page.rows).length === 0 && capabilities) {
|
|
9367
|
+
const views = readArray(readArray(capabilities.tabs).map(readObject).find((tab) => tab?.id === page.tab)?.views);
|
|
9368
|
+
page.available_views = views;
|
|
9369
|
+
}
|
|
9370
|
+
return omitMapPoints(page, "points", includeMap);
|
|
9371
|
+
}
|
|
9372
|
+
function omitMapPoints(payload, field, include) {
|
|
9373
|
+
if (include || !Array.isArray(payload[field]))
|
|
9374
|
+
return payload;
|
|
9375
|
+
const { [field]: points, ...rest } = payload;
|
|
9376
|
+
return { ...rest, map_points_omitted: points.length };
|
|
9377
|
+
}
|
|
9378
|
+
function assertActivitySnapshotSelector(client, endpoint, snapshot) {
|
|
9379
|
+
// Older deployments ignore unknown parameters silently; never let a binding
|
|
9380
|
+
// the caller relies on be dropped without saying so.
|
|
9381
|
+
if (snapshot === undefined || client.capabilitySnapshot().status !== "negotiated")
|
|
9382
|
+
return;
|
|
9383
|
+
if (client.endpointCapability(endpoint)?.snapshot_parameter !== "snapshot") {
|
|
9384
|
+
throw new Error(`The negotiated BBRadar API does not advertise Activity version binding for ${endpoint}. Omit snapshot or upgrade the API deployment.`);
|
|
9385
|
+
}
|
|
9386
|
+
}
|
|
9387
|
+
function assertActivityTierFilter(client, tiers) {
|
|
9388
|
+
// An API without the filter answers 422; say why locally and send nothing.
|
|
9389
|
+
if (!Array.isArray(tiers) || tiers.length === 0 || client.capabilitySnapshot().status !== "negotiated")
|
|
9390
|
+
return;
|
|
9391
|
+
if (!Array.isArray(client.endpointCapability("activity_rankings")?.opportunity_tiers)) {
|
|
9392
|
+
throw new Error("The negotiated BBRadar API does not advertise Activity opportunity tier filtering. Omit opportunity_tiers or upgrade the API deployment.");
|
|
9393
|
+
}
|
|
9394
|
+
}
|
|
8842
9395
|
function assertHistoryCapture(client) {
|
|
8843
9396
|
if (client.capabilitySnapshot().status === "negotiated" &&
|
|
8844
9397
|
client.endpointCapability("history_changes")?.history_capture_enabled === false) {
|
|
@@ -8880,6 +9433,12 @@ function errorPayload(error, decision, toolName, durationMs) {
|
|
|
8880
9433
|
};
|
|
8881
9434
|
}
|
|
8882
9435
|
function recoveryForGone(error, toolName) {
|
|
9436
|
+
if (toolName === "get_activity_change_detail") {
|
|
9437
|
+
return { action: "refresh_activity_source", message: "Rerun the originating Activity changes or status query without its cursor, keeping its filters. Use the new meta.snapshot_id and event ID for this detail request; do not use snapshot=start." };
|
|
9438
|
+
}
|
|
9439
|
+
if (toolName.startsWith("get_activity_") || toolName === "get_program_payouts") {
|
|
9440
|
+
return { action: "restart_activity_query", message: "Restart this Activity query without its expired cursor or snapshot binding, keeping the same explicit period, basis, precision, sort, direction and filters; call get_activity_snapshot for the current version if binding is needed. Do not combine old pages or pass snapshot=start or after_id." };
|
|
9441
|
+
}
|
|
8883
9442
|
const detail = typeof error.detail === "string" ? error.detail.toLowerCase() : error.message.toLowerCase();
|
|
8884
9443
|
if (detail.includes("retained analytics") || detail.includes("precede")) {
|
|
8885
9444
|
return { action: "choose_supported_interval", message: "Choose dates inside the retained analytics window." };
|