hackerone-mcp-server 1.0.0 → 1.1.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/README.md CHANGED
@@ -1,42 +1,37 @@
1
1
  # HackerOne MCP Server
2
2
 
3
- > **Disclaimer:** This is an unofficial, community-built project. It is not affiliated with, endorsed by, or maintained by HackerOne. "HackerOne" is a trademark of HackerOne, Inc. This project integrates with their publicly documented [Hacker API](https://api.hackerone.com/hacker-resources/) and uses the website's public GraphQL API for program signal requirements.
4
-
5
- MCP server that gives Claude Code (or any MCP client) full access to your HackerOne reports, programs, earnings, and scope data via the HackerOne API — including submitting reports and responding to triage.
3
+ Unofficial MCP server that gives Codex, Claude Code, or any MCP client full access to your HackerOne reports, programs, earnings, and scope data via the HackerOne API — including submitting reports and responding to triage.
6
4
 
7
5
  ## Setup
8
6
 
7
+ Install Node.js and npm, then use the published [hackerone-mcp-server package](https://www.npmjs.com/package/hackerone-mcp-server) through `npx`.
8
+
9
9
  ### 1. Get your HackerOne API token
10
10
 
11
11
  Go to **HackerOne > Settings > API Token** and generate one.
12
12
 
13
- ### 2. Install and build
13
+ ### 2. Add to your MCP client
14
14
 
15
- ```bash
16
- git clone https://github.com/Sicks3c/hackerone-mcp-server.git
17
- cd hackerone-mcp-server
18
- npm install
19
- npm run build
20
- ```
15
+ Replace `your-username` and `your-api-token` below with your HackerOne credentials.
21
16
 
22
- ### 3. Add to Claude Code
17
+ #### Codex
23
18
 
24
- ```bash
25
- claude mcp add hackerone \
26
- -e H1_USERNAME=your-username \
27
- -e H1_API_TOKEN=your-api-token \
28
- -s user \
29
- -- node /path/to/hackerone-mcp-server/dist/index.js
19
+ ```toml
20
+ [mcp_servers.hackerone]
21
+ command = "npx"
22
+ args = ["-y", "hackerone-mcp-server@latest"]
23
+ startup_timeout_sec = 60
24
+ env = { "H1_USERNAME" = "your-username", "H1_API_TOKEN" = "your-api-token" }
30
25
  ```
31
26
 
32
- Or add manually to `~/.claude.json`:
27
+ #### Claude Code
33
28
 
34
29
  ```json
35
30
  {
36
31
  "mcpServers": {
37
32
  "hackerone": {
38
- "command": "node",
39
- "args": ["/path/to/hackerone-mcp-server/dist/index.js"],
33
+ "command": "npx",
34
+ "args": ["-y", "hackerone-mcp-server@latest"],
40
35
  "env": {
41
36
  "H1_USERNAME": "your-username",
42
37
  "H1_API_TOKEN": "your-api-token"
@@ -46,14 +41,21 @@ Or add manually to `~/.claude.json`:
46
41
  }
47
42
  ```
48
43
 
49
- ### 4. Verify
44
+ ## Build from source
45
+
46
+ For local development:
50
47
 
51
48
  ```bash
52
- claude
53
- > /mcp
54
- # You should see "hackerone" listed with 17 tools
49
+ git clone https://github.com/11philip22/hackerone-mcp-server.git
50
+ cd hackerone-mcp-server
51
+ npm install
52
+ npm run build
55
53
  ```
56
54
 
55
+ Run `npm test` to build and run the fixture tests. Tests use Node's experimental module mocking and require Node.js 22.3+ (or 20.18+ on the Node 20 release line).
56
+
57
+ To use your local build, set the MCP command to `node` and its arguments to `["/absolute/path/to/hackerone-mcp-server/dist/index.js"]`, keeping the same environment variables.
58
+
57
59
  ## Tools
58
60
 
59
61
  ### Read
@@ -67,6 +69,7 @@ claude
67
69
  | `list_programs` | List all bug bounty programs you have access to (auto-paginates) |
68
70
  | `get_program_details` | Get single program info: policy, response times, metrics |
69
71
  | `get_program_signal_requirements` | Get a public program's minimum past-year signal threshold and whether requirements are enabled |
72
+ | `get_program_rewards` | Get a public program's currency, severity payout ranges, reward groups, and each group's scope assets |
70
73
  | `get_program_scope` | Get all in-scope assets for a program (auto-paginates) |
71
74
  | `get_program_weaknesses` | Get accepted CWE/weakness types for a program (auto-paginates) |
72
75
  | `get_earnings` | Get your bounty earnings history (amounts, dates, programs) |
@@ -125,6 +128,11 @@ Show my hacker profile — what's my current reputation and signal?
125
128
  What signal do I need to submit reports to the uber program?
126
129
  ```
127
130
 
131
+ **Compare rewards by asset group:**
132
+ ```
133
+ Show TikTok's reward groups, severity payout ranges, and which Android apps and websites belong to each group. Flag any unavailable data.
134
+ ```
135
+
128
136
  **Track earnings:**
129
137
  ```
130
138
  Show my recent bounty earnings and current balance
@@ -134,17 +142,3 @@ Show my recent bounty earnings and current balance
134
142
  ```
135
143
  Analyze my report patterns — what severity gets resolved most?
136
144
  ```
137
-
138
- ## How It Works
139
-
140
- - Connects to the [HackerOne Hacker API v1](https://api.hackerone.com/hacker-resources/) using your personal API token
141
- - Runs locally over stdio — your credentials never leave your machine
142
- - Supports both read and write operations (submit reports, add comments, close reports)
143
- - Auto-paginates programs, scope, and weakness endpoints so nothing gets silently truncated
144
- - Uses server-side API filters where available (program, severity, state) for faster searches
145
- - Built-in retry with exponential backoff for rate limit handling
146
- - 60-second response cache to reduce redundant API calls
147
-
148
- ## License
149
-
150
- MIT
@@ -81,6 +81,45 @@ export declare function getProgramDetails(handle: string): Promise<{
81
81
  bookmarked: any;
82
82
  }>;
83
83
  export declare function getProgramSignalRequirements(handle: string): Promise<any>;
84
+ type RewardAvailability = "available" | "partial" | "unavailable";
85
+ type RewardSeverity = "low" | "medium" | "high" | "critical";
86
+ interface RewardAsset {
87
+ id: string;
88
+ asset_identifier: string;
89
+ asset_type: string | null;
90
+ eligible_for_submission: boolean | null;
91
+ eligible_for_bounty: boolean | null;
92
+ }
93
+ interface RewardGroup {
94
+ id: string;
95
+ name: string | null;
96
+ description: string | null;
97
+ scope_kind: "all_assets" | "asset" | "reward_group" | null;
98
+ use_range: boolean | null;
99
+ rewards: Record<RewardSeverity, {
100
+ label: string | null;
101
+ minimum: number | null;
102
+ maximum: number | null;
103
+ }>;
104
+ assets: RewardAsset[] | null;
105
+ assets_availability: RewardAvailability;
106
+ }
107
+ export interface ProgramRewards {
108
+ handle: string;
109
+ currency: string | null;
110
+ source: string;
111
+ program_url: string;
112
+ format: "legacy" | "scope_and_rewards" | null;
113
+ description: string | null;
114
+ availability: RewardAvailability;
115
+ issues: {
116
+ code: string;
117
+ group_id: string | null;
118
+ message: string;
119
+ }[];
120
+ groups: RewardGroup[] | null;
121
+ }
122
+ export declare function getProgramRewards(handle: string): Promise<ProgramRewards>;
84
123
  export declare function getProgramScope(handle: string, pageSize?: number): Promise<{
85
124
  id: any;
86
125
  asset_type: any;
@@ -177,3 +216,4 @@ export declare function searchDisclosedReports(opts: {
177
216
  query?: string;
178
217
  page_size?: number;
179
218
  }): Promise<any>;
219
+ export {};
package/dist/h1client.js CHANGED
@@ -482,6 +482,265 @@ export async function getProgramSignalRequirements(handle) {
482
482
  cacheSet(cacheKey, result);
483
483
  return result;
484
484
  }
485
+ // ── Get public program rewards ───────────────────────────────────
486
+ export async function getProgramRewards(handle) {
487
+ handle = handle.trim();
488
+ if (!handle)
489
+ throw new Error("program_handle must not be empty");
490
+ const cacheKey = `program-rewards:${handle}`;
491
+ const cached = cacheGet(cacheKey);
492
+ if (cached)
493
+ return cached;
494
+ // shortcut: uses the public website API; switch when the Hacker API exposes reward groups.
495
+ const result = {
496
+ handle, currency: null, source: "https://hackerone.com/graphql",
497
+ program_url: `https://hackerone.com/${encodeURIComponent(handle)}`,
498
+ format: null, description: null, availability: "unavailable", issues: [], groups: null,
499
+ };
500
+ const issue = (code, message, group_id = null) => {
501
+ result.issues.push({ code, group_id, message });
502
+ };
503
+ const request = async (query, variables = {}) => {
504
+ const res = await fetch(result.source, {
505
+ method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000),
506
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
507
+ body: JSON.stringify({ query, variables: { handle, ...variables } }),
508
+ });
509
+ if (!res.ok)
510
+ throw new Error(`HackerOne GraphQL HTTP error ${res.status}`);
511
+ const body = await res.json();
512
+ if (body?.errors !== undefined && (!Array.isArray(body.errors) || body.errors.length)) {
513
+ if (body.data?.team === null && Array.isArray(body.errors) &&
514
+ body.errors.length && body.errors.every((e) => e?.type === "NOT_FOUND"))
515
+ return null;
516
+ throw new Error("HackerOne GraphQL returned errors");
517
+ }
518
+ const team = body?.data?.team;
519
+ if (team !== null && !isObject(team))
520
+ throw new Error("Malformed HackerOne team response");
521
+ return team;
522
+ };
523
+ const scopeFields = `_id asset_identifier asset_type eligible_for_submission eligible_for_bounty`;
524
+ const rewardsQuery = `query ProgramRewards($handle: String!, $after: String) {
525
+ team(handle: $handle) {
526
+ handle currency scope_and_rewards_groups_enabled
527
+ bounty_table {
528
+ _id description low_label medium_label high_label critical_label
529
+ bounty_table_rows(first: 100, after: $after) {
530
+ pageInfo { hasNextPage endCursor }
531
+ edges { node {
532
+ _id name description use_range
533
+ low low_minimum medium medium_minimum high high_minimum critical critical_minimum
534
+ structured_scope { ${scopeFields} }
535
+ } }
536
+ }
537
+ }
538
+ }
539
+ }`;
540
+ const rows = new Map();
541
+ const groups = [];
542
+ const nextCursor = (connection, seen) => {
543
+ const info = connection?.pageInfo;
544
+ if (!isObject(info) || typeof info.hasNextPage !== "boolean") {
545
+ throw new Error("Missing reward pagination metadata");
546
+ }
547
+ if (!connection.edges.length && seen.size)
548
+ throw new Error("Prematurely empty reward page");
549
+ if (!info.hasNextPage)
550
+ return null;
551
+ if (!connection.edges.length || typeof info.endCursor !== "string" ||
552
+ !info.endCursor || seen.has(info.endCursor)) {
553
+ throw new Error("Incomplete or repeated reward pagination cursor");
554
+ }
555
+ seen.add(info.endCursor);
556
+ return info.endCursor;
557
+ };
558
+ const nodes = (connection) => {
559
+ if (!isObject(connection) || !Array.isArray(connection.edges) ||
560
+ connection.edges.some((e) => !isObject(e?.node))) {
561
+ throw new Error("Malformed reward connection");
562
+ }
563
+ return connection.edges.map((e) => e.node);
564
+ };
565
+ const asset = (raw) => {
566
+ if (!isObject(raw) || typeof raw._id !== "string" || !raw._id ||
567
+ typeof raw.asset_identifier !== "string" || !raw.asset_identifier) {
568
+ throw new Error("Malformed reward scope asset");
569
+ }
570
+ return {
571
+ id: raw._id, asset_identifier: raw.asset_identifier,
572
+ asset_type: typeof raw.asset_type === "string" ? raw.asset_type : null,
573
+ eligible_for_submission: typeof raw.eligible_for_submission === "boolean" ? raw.eligible_for_submission : null,
574
+ eligible_for_bounty: typeof raw.eligible_for_bounty === "boolean" ? raw.eligible_for_bounty : null,
575
+ };
576
+ };
577
+ try {
578
+ let team = await request(rewardsQuery);
579
+ if (team === null) {
580
+ issue("not_public_or_not_found", "Program was not found or is not publicly accessible");
581
+ return result;
582
+ }
583
+ if (typeof team.handle !== "string" || typeof team.scope_and_rewards_groups_enabled !== "boolean") {
584
+ throw new Error("Malformed program rewards metadata");
585
+ }
586
+ result.handle = team.handle;
587
+ result.format = team.scope_and_rewards_groups_enabled ? "scope_and_rewards" : "legacy";
588
+ const sourceCurrency = team.currency;
589
+ result.currency = typeof team.currency === "string" && team.currency.trim() ? team.currency : null;
590
+ if (result.currency === null)
591
+ issue("missing_currency", "Reward currency is unknown");
592
+ if (team.bounty_table === null) {
593
+ result.groups = [];
594
+ issue("no_published_table", "No public bounty table is available");
595
+ return result;
596
+ }
597
+ const tableId = team.bounty_table?._id;
598
+ if (typeof tableId !== "string" || !tableId)
599
+ throw new Error("Malformed bounty table");
600
+ result.description = typeof team.bounty_table.description === "string" ? team.bounty_table.description : null;
601
+ const cursors = new Set();
602
+ while (true) {
603
+ const table = team?.bounty_table;
604
+ if (table?._id !== tableId || team.handle !== result.handle ||
605
+ team.scope_and_rewards_groups_enabled !== (result.format === "scope_and_rewards") ||
606
+ team.currency !== sourceCurrency) {
607
+ throw new Error("Program rewards changed during pagination");
608
+ }
609
+ for (const row of nodes(table.bounty_table_rows)) {
610
+ if (typeof row._id !== "string" || !row._id || rows.has(row._id)) {
611
+ throw new Error("Missing or repeated reward group ID");
612
+ }
613
+ const useRange = typeof row.use_range === "boolean" ? row.use_range : null;
614
+ const rewards = {};
615
+ let incomplete = useRange === null;
616
+ for (const severity of ["low", "medium", "high", "critical"]) {
617
+ const amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
618
+ let maximum = amount(row[severity]);
619
+ let minimum = useRange === false ? maximum : amount(row[`${severity}_minimum`]);
620
+ if (minimum !== null && maximum !== null && minimum > maximum) {
621
+ minimum = maximum = null;
622
+ }
623
+ incomplete ||= minimum === null || maximum === null;
624
+ rewards[severity] = {
625
+ label: typeof table[`${severity}_label`] === "string" ? table[`${severity}_label`] : null,
626
+ minimum, maximum,
627
+ };
628
+ }
629
+ if (incomplete)
630
+ issue("incomplete_rewards", "Some payout amounts, bounds, or the range setting are unknown or invalid", row._id);
631
+ groups.push({
632
+ id: row._id, name: typeof row.name === "string" ? row.name : null,
633
+ description: typeof row.description === "string" ? row.description : null,
634
+ scope_kind: result.format === "scope_and_rewards" ? "reward_group" :
635
+ row.structured_scope === null ? "all_assets" : isObject(row.structured_scope) ? "asset" : null,
636
+ use_range: useRange, rewards, assets: null, assets_availability: "unavailable",
637
+ });
638
+ rows.set(row._id, row);
639
+ }
640
+ const after = nextCursor(table.bounty_table_rows, cursors);
641
+ if (after === null)
642
+ break;
643
+ team = await request(rewardsQuery, { after });
644
+ }
645
+ if (!groups.length) {
646
+ result.groups = [];
647
+ issue("no_published_rewards", "The public bounty table contains no reward rows");
648
+ return result;
649
+ }
650
+ }
651
+ catch (err) {
652
+ issue("upstream_error", `Could not retrieve every reward group: ${err.message}`);
653
+ if (!groups.length)
654
+ return result;
655
+ }
656
+ result.groups = groups;
657
+ let allAssets;
658
+ for (const group of groups) {
659
+ try {
660
+ const assets = [];
661
+ const seenIds = new Set();
662
+ const append = (rawAssets) => {
663
+ for (const raw of rawAssets) {
664
+ const item = asset(raw);
665
+ if (seenIds.has(item.id))
666
+ throw new Error("Repeated scope asset during pagination");
667
+ seenIds.add(item.id);
668
+ assets.push(item);
669
+ }
670
+ };
671
+ if (group.scope_kind === "reward_group") {
672
+ const rowId = Number(group.id);
673
+ if (!/^\d+$/.test(group.id) || !Number.isSafeInteger(rowId) || rowId < 1 || rowId > 2147483647) {
674
+ throw new Error("Invalid reward group ID for scope search");
675
+ }
676
+ let total;
677
+ while (true) {
678
+ const team = await request(`query ProgramRewardAssets($handle: String!, $rowId: Int!, $from: Int!) {
679
+ team(handle: $handle) {
680
+ structured_scopes_search(bounty_table_row_id: $rowId, from: $from, size: 100,
681
+ sort: {field: "id", direction: ASC}) {
682
+ total_count edges { node { ... on StructuredScopeDocument {
683
+ _id asset_identifier: identifier asset_type eligible_for_submission eligible_for_bounty
684
+ } } }
685
+ }
686
+ }
687
+ }`, { rowId, from: assets.length });
688
+ const page = team?.structured_scopes_search;
689
+ const batch = nodes(page);
690
+ if (!Number.isSafeInteger(page.total_count) || page.total_count < 0 ||
691
+ (total !== undefined && total !== page.total_count)) {
692
+ throw new Error("Missing or inconsistent reward asset total");
693
+ }
694
+ total = page.total_count;
695
+ append(batch);
696
+ if (assets.length === total)
697
+ break;
698
+ if (!batch.length || assets.length > total)
699
+ throw new Error("Incomplete reward asset search");
700
+ }
701
+ group.assets = assets;
702
+ }
703
+ else if (group.scope_kind === "asset") {
704
+ group.assets = [asset(rows.get(group.id).structured_scope)];
705
+ }
706
+ else if (group.scope_kind === "all_assets") {
707
+ if (!allAssets) {
708
+ let after = null;
709
+ const cursors = new Set();
710
+ do {
711
+ const team = await request(`query ProgramRewardDefaultAssets($handle: String!, $after: String) {
712
+ team(handle: $handle) {
713
+ structured_scopes(first: 100, after: $after, archived: false,
714
+ eligible_for_submission: true, eligible_for_bounty: true) {
715
+ pageInfo { hasNextPage endCursor }
716
+ edges { node { ${scopeFields} } }
717
+ }
718
+ }
719
+ }`, { after });
720
+ const page = team?.structured_scopes;
721
+ append(nodes(page));
722
+ after = nextCursor(page, cursors);
723
+ } while (after !== null);
724
+ allAssets = assets;
725
+ }
726
+ group.assets = allAssets;
727
+ }
728
+ else {
729
+ throw new Error("Legacy reward scope applicability is unknown");
730
+ }
731
+ group.assets_availability = group.assets.some((a) => a.asset_type === null || a.eligible_for_bounty === null || a.eligible_for_submission === null) ? "partial" : "available";
732
+ if (group.assets_availability === "partial")
733
+ issue("incomplete_asset_fields", "Some asset types or eligibility fields are unknown", group.id);
734
+ }
735
+ catch (err) {
736
+ issue("assets_unavailable", `Could not retrieve every asset: ${err.message}`, group.id);
737
+ }
738
+ }
739
+ result.availability = result.issues.length ? "partial" : "available";
740
+ if (result.availability === "available")
741
+ cacheSet(cacheKey, result);
742
+ return result;
743
+ }
485
744
  // ── Get program scope (auto-paginated) ────────────────────────────
486
745
  export async function getProgramScope(handle, pageSize) {
487
746
  const allData = await h1FetchAllPages(`/hackers/programs/${handle}/structured_scopes`, pageSize);
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
- import { searchReports, getReport, getReportActivities, getReportSummary, listPrograms, getProgramDetails, getProgramSignalRequirements, getProgramScope, getProgramWeaknesses, getEarnings, getHackerProfile, getBalance, submitReport, addComment, closeReport, searchDisclosedReports, } from "./h1client.js";
5
+ import { searchReports, getReport, getReportActivities, getReportSummary, listPrograms, getProgramDetails, getProgramSignalRequirements, getProgramRewards, getProgramScope, getProgramWeaknesses, getEarnings, getHackerProfile, getBalance, submitReport, addComment, closeReport, searchDisclosedReports, } from "./h1client.js";
6
6
  const server = new McpServer({
7
7
  name: "hackerone",
8
8
  version: "2.0.0",
@@ -204,7 +204,26 @@ server.tool("get_program_signal_requirements", "Get a public program's minimum p
204
204
  };
205
205
  }
206
206
  });
207
- // ── Tool: analyze_report_patterns ──────────────────────────────────
207
+ // ── Tool: get_program_rewards ─────────────────────────────────────
208
+ server.tool("get_program_rewards", "Get a public program's published reward groups, currency, Low/Medium/High/Critical payout ranges, and associated scope assets. Supports legacy tables and Scope and rewards groups. Missing values are null, not zero; inspect availability and issues before relying on incomplete data. Legacy asset-specific rows take precedence over all-assets rows for the same asset ID. Private programs are not supported.", {
209
+ program_handle: z.string().trim().min(1).describe("Public program handle (e.g. 'security', 'tiktok')"),
210
+ }, { readOnlyHint: true }, async ({ program_handle }) => {
211
+ try {
212
+ const rewards = await getProgramRewards(program_handle);
213
+ return {
214
+ content: [{ type: "text", text: JSON.stringify(rewards, null, 2) }],
215
+ isError: rewards.availability === "unavailable" &&
216
+ rewards.issues.some((issue) => issue.code === "upstream_error"),
217
+ };
218
+ }
219
+ catch (err) {
220
+ return {
221
+ content: [{ type: "text", text: `Error: ${err.message}` }],
222
+ isError: true,
223
+ };
224
+ }
225
+ });
226
+ // ── Tool: analyze_report_patterns ────────────────────────────────
208
227
  server.tool("analyze_report_patterns", "Fetch your recent reports and analyze patterns: most common vulnerability types, severity distribution, resolution rates, and programs. Useful for understanding your hunting profile.", {
209
228
  page_size: z
210
229
  .number()
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "hackerone-mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for accessing HackerOne reports in Claude Code",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/11philip22/hackerone-mcp-server.git"
8
+ },
5
9
  "type": "module",
6
10
  "main": "dist/index.js",
7
11
  "bin": {
@@ -13,7 +17,7 @@
13
17
  "scripts": {
14
18
  "build": "tsc",
15
19
  "prepack": "npm run build",
16
- "test": "npm run build && node --test test/*.test.mjs",
20
+ "test": "npm run build && node --experimental-test-module-mocks --test test/*.test.mjs",
17
21
  "start": "node dist/index.js",
18
22
  "dev": "tsx src/index.ts"
19
23
  },