@mambalabsdev/mcp-bluesky-posts-scraper 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mamba Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,113 @@
1
- # Temporary Holding Version
1
+ # Bluesky Posts Scraper MCP Server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@mambalabsdev/mcp-bluesky-posts-scraper)](https://www.npmjs.com/package/@mambalabsdev/mcp-bluesky-posts-scraper) [![license](https://img.shields.io/github/license/mambalabsdev/mcp-bluesky-posts-scraper)](https://github.com/mambalabsdev/mcp-bluesky-posts-scraper/blob/main/LICENSE)
4
+
5
+ An MCP server for the Mamba Labs Bluesky Posts Scraper actor on Apify. Bluesky posts by keyword or account, and replies by post.
6
+
7
+ ## What's Inside
8
+
9
+ - [What it does](#what-it-does)
10
+ - [Quick start](#quick-start)
11
+ - [Prerequisites](#prerequisites)
12
+ - [Example prompts](#example-prompts)
13
+ - [Tool](#tool)
14
+ - [Inputs](#inputs)
15
+ - [Pricing](#pricing)
16
+ - [Full actor documentation](#full-actor-documentation)
17
+ - [License](#license)
18
+
19
+ ## What it does
20
+
21
+ Give keywords, accounts, or post addresses. The actor reads Bluesky's public API with no login and no key, and returns one row per post or reply with exact counts, dates, hashtags, links, and images. A free status row per source says what it returned or why it stopped.
22
+
23
+ All of the work runs on Apify. This package is a thin client that starts the actor run, waits for it, and hands back the dataset rows.
24
+
25
+ ## Quick start
26
+
27
+ You need Node.js 18 or newer and an Apify account with an API token.
28
+
29
+ Add this to your Claude Desktop config (`claude_desktop_config.json`), Cursor, or any MCP client:
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "mamba-bluesky-posts-scraper": {
35
+ "command": "npx",
36
+ "args": [
37
+ "-y",
38
+ "@mambalabsdev/mcp-bluesky-posts-scraper"
39
+ ],
40
+ "env": {
41
+ "APIFY_TOKEN": "your-apify-token"
42
+ }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Restart the client. The `get_bluesky_posts` tool appears.
49
+
50
+ ## Prerequisites
51
+
52
+ - Node.js 18 or newer.
53
+ - An Apify API token from [console.apify.com/account/integrations](https://console.apify.com/account/integrations), set as `APIFY_TOKEN`. The server lists its tool without a token; a call needs one.
54
+
55
+ ## Example prompts
56
+
57
+ - "What are people saying on Bluesky about cold email this month?"
58
+ - "Get the last 50 posts from this Bluesky account with their like counts."
59
+ - "Read the replies on this Bluesky post into a table."
60
+
61
+ ## Tool
62
+
63
+ `get_bluesky_posts`. One tool. Example arguments:
64
+
65
+ ```json
66
+ {
67
+ "queries": [
68
+ "apify"
69
+ ],
70
+ "max_posts_per_source": 10
71
+ }
72
+ ```
73
+
74
+ ## Inputs
75
+
76
+ Every buyer facing input of the actor. Omitted inputs take the actor's own defaults.
77
+
78
+ | Input | Type | Required | Description |
79
+ |---|---|---|---|
80
+ | `queries` | array | no | Keywords to search, up to 20. Without your own app password each search returns one page, about 100 posts. Text from Clay splits on new lines, so a query can hold a comma. |
81
+ | `authors` | array | no | Bluesky handles, profile addresses, or DIDs, up to 20. Returns each account's own posts, newest first. |
82
+ | `post_urls` | array | no | Addresses of Bluesky posts to read, up to 100. |
83
+ | `sort` | string | no | latest or top. Default latest. Used for queries. |
84
+ | `since_date` | string | no | Only posts from this date, such as 2026-10-01. |
85
+ | `until_date` | string | no | Only posts before this date, such as 2026-11-01. |
86
+ | `language` | string | no | Language code such as en or de. Used for queries. Reads the language the post's author set. |
87
+ | `max_posts_per_source` | number | no | Per query or account, up to 2000. Default 100. A search with no app password stops at about 100. |
88
+ | `include_replies` | boolean | no | yes or no. Default no. Reads the replies under each post found. Each reply is a charged row. |
89
+ | `max_replies_per_post` | number | no | Up to 200. Default 20. |
90
+ | `include_account_replies` | boolean | no | yes or no. Default no. Used for accounts. |
91
+ | `include_reposts` | boolean | no | yes or no. Default no. Used for accounts. A repost row shows the original author. |
92
+ | `bluesky_handle` | string | no | Optional, with the app password. Used only to page a search past its first page. |
93
+ | `bluesky_app_password` | string | no | Optional. Create an app password in Bluesky settings; never use your main password. Sent only to Bluesky. Without it a search returns one page, about 100 posts. Accounts and post addresses need no password. |
94
+ | `max_charge_usd` | string | no | Optional. The run stops before a charge that would pass this amount. Summary rows are always returned. |
95
+
96
+ ## Pricing
97
+
98
+ Pay per event on Apify, billed to your Apify account. Prices in USD.
99
+
100
+ | Event | Free plan | Bronze | Silver | Gold |
101
+ |---|---|---|---|---|
102
+ | `post-row`, one post or reply | $0.0011 | $0.001045 | $0.00099 | $0.000935 |
103
+ | `apify-actor-start`, per run start | $0.0065 | $0.0065 | $0.0065 | $0.0065 |
104
+
105
+ Source status rows and refused input are free. Mamba Labs pays the platform costs of the run. Mamba Labs pays the platform costs of the run, Apify proxy traffic included.
106
+
107
+ ## Full actor documentation
108
+
109
+ [apify.com/mambalabs/bluesky-posts-scraper](https://apify.com/mambalabs/bluesky-posts-scraper)
110
+
111
+ ## License
112
+
113
+ MIT. See [LICENSE](LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,31 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ The latest published `1.x` release of
6
+ `@mambalabsdev/mcp-bluesky-posts-scraper` receives security updates.
7
+
8
+ | Version | Supported |
9
+ | ------- | ------------------ |
10
+ | 1.x | :white_check_mark: |
11
+ | < 1.0 | :x: |
12
+
13
+ ## Reporting a vulnerability
14
+
15
+ Please do not open a public GitHub issue for security reports.
16
+
17
+ Email security reports to **labs@mambamindsetgrowth.com** with:
18
+
19
+ - a description of the issue and its impact,
20
+ - steps to reproduce, and
21
+ - any relevant logs or proof of concept.
22
+
23
+ We aim to acknowledge reports within 3 business days and to provide a remediation
24
+ timeline after triage. Coordinated disclosure is appreciated.
25
+
26
+ ## Handling of credentials
27
+
28
+ This server reads an `APIFY_TOKEN` from the environment and uses it only to call
29
+ the Apify API on your behalf. The token is never logged or written to disk by this
30
+ package. Treat your `APIFY_TOKEN` as a secret and rotate it at
31
+ https://console.apify.com/account/integrations if you believe it has been exposed.
package/build/index.js ADDED
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { z } from "zod";
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
10
+ // Distinctive UA so Apify run meta.userAgent marks MCP-originated runs.
11
+ const USER_AGENT = `mambalabs-mcp ${pkg.name}@${pkg.version}`;
12
+ // Drop undefined values so optional inputs are not sent to the actor.
13
+ function compact(obj) {
14
+ const out = {};
15
+ for (const [k, v] of Object.entries(obj)) {
16
+ if (v !== undefined)
17
+ out[k] = v;
18
+ }
19
+ return out;
20
+ }
21
+ // How long the actor run itself is allowed to take, in seconds. Fleet standard
22
+ // (standing build rule 5, 2026-10-05). A run of 50 searches of 100 posts each
23
+ const ACTOR_RUN_TIMEOUT_SECS = 1800;
24
+ // How long this wrapper waits for that run, in milliseconds. The actor's own
25
+ // timeout plus two minutes, so the run's own TIMED-OUT status is what the
26
+ // caller sees rather than the wrapper giving up first and reporting nothing.
27
+ const WRAPPER_WAIT_MS = (ACTOR_RUN_TIMEOUT_SECS + 120) * 1000;
28
+ const POLL_INTERVAL_MS = 3000;
29
+ const TERMINAL = new Set(["SUCCEEDED", "FAILED", "TIMED-OUT", "ABORTED", "ABORTING"]);
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+ // Shared caller. actorPath is the actor's immutable Apify actor ID (a stable key
32
+ // that survives Store renames). The /v2/acts/{id} endpoint accepts it directly,
33
+ // so a Store rename never breaks these calls.
34
+ //
35
+ // START AND POLL, NOT RUN-SYNC. Apify's synchronous endpoints carry a platform
36
+ // ceiling of 300 seconds on the HTTP wait itself and answer 408 past it whatever
37
+ // the timeout parameter says, so a long run reads as a timeout even though the
38
+ // actor goes on to finish. Starting the run, polling it to a terminal status and
39
+ // then reading the dataset is the only way to wait as long as the actor needs.
40
+ //
41
+ // The token is read here rather than at module load, so the tool registers
42
+ // unconditionally and a server started without APIFY_TOKEN still advertises its
43
+ // capabilities instead of reporting none.
44
+ async function runActor(actorPath, actorLabel, input) {
45
+ const APIFY_TOKEN = process.env.APIFY_TOKEN;
46
+ if (!APIFY_TOKEN) {
47
+ return { isError: true, content: [{ type: "text", text: "APIFY_TOKEN is not set. Create a token at https://console.apify.com/account/integrations and set it as the APIFY_TOKEN environment variable." }] };
48
+ }
49
+ const headers = {
50
+ Authorization: `Bearer ${APIFY_TOKEN}`,
51
+ "Content-Type": "application/json",
52
+ "User-Agent": USER_AGENT,
53
+ };
54
+ const httpError = async (response) => {
55
+ let detail = "";
56
+ try {
57
+ const body = (await response.json());
58
+ if (body?.error?.message)
59
+ detail = ` ${body.error.message}`;
60
+ }
61
+ catch {
62
+ detail = "";
63
+ }
64
+ switch (response.status) {
65
+ case 400:
66
+ return `The ${actorLabel} run was rejected as invalid input.${detail}`;
67
+ case 401:
68
+ return "Invalid Apify token. Check your APIFY_TOKEN environment variable.";
69
+ case 402:
70
+ return "Insufficient Apify credits. Check your account balance at https://console.apify.com/billing";
71
+ default:
72
+ return `Apify request to ${actorLabel} failed with status ${response.status}.${detail}`;
73
+ }
74
+ };
75
+ // 1. Start the run.
76
+ let started;
77
+ try {
78
+ started = await fetch(`https://api.apify.com/v2/acts/${actorPath}/runs?timeout=${ACTOR_RUN_TIMEOUT_SECS}`, { method: "POST", headers, body: JSON.stringify(input) });
79
+ }
80
+ catch (err) {
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ return { isError: true, content: [{ type: "text", text: `Could not reach the Apify API: ${message}` }] };
83
+ }
84
+ if (!started.ok) {
85
+ return { isError: true, content: [{ type: "text", text: await httpError(started) }] };
86
+ }
87
+ let run;
88
+ try {
89
+ run = (await started.json()).data ?? {};
90
+ }
91
+ catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run start returned a response that could not be parsed: ${message}` }] };
94
+ }
95
+ const runId = run.id;
96
+ if (!runId) {
97
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run start returned no run id, so there is nothing to wait for.` }] };
98
+ }
99
+ // 2. Poll to a terminal status.
100
+ const deadline = Date.now() + WRAPPER_WAIT_MS;
101
+ let status = run.status ?? "READY";
102
+ let datasetId = run.defaultDatasetId;
103
+ while (!TERMINAL.has(status)) {
104
+ if (Date.now() >= deadline) {
105
+ return {
106
+ isError: true,
107
+ content: [{ type: "text", text: `The ${actorLabel} run ${runId} was still ${status} after ${Math.round(WRAPPER_WAIT_MS / 1000)} seconds and this call stopped waiting. The run itself is still on Apify: read it at https://console.apify.com/actors/runs/${runId}` }],
108
+ };
109
+ }
110
+ await sleep(POLL_INTERVAL_MS);
111
+ let poll;
112
+ try {
113
+ poll = await fetch(`https://api.apify.com/v2/actor-runs/${runId}`, { headers });
114
+ }
115
+ catch (err) {
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ return { isError: true, content: [{ type: "text", text: `Lost contact with the Apify API while waiting for ${actorLabel} run ${runId}: ${message}` }] };
118
+ }
119
+ if (!poll.ok) {
120
+ return { isError: true, content: [{ type: "text", text: await httpError(poll) }] };
121
+ }
122
+ const body = (await poll.json());
123
+ status = body.data?.status ?? status;
124
+ datasetId = body.data?.defaultDatasetId ?? datasetId;
125
+ }
126
+ // 3. A run that did not succeed is a failure the caller must see, never an
127
+ // empty success. Surfacing it here is what keeps a crashed run from reading
128
+ // as "no results found".
129
+ if (status !== "SUCCEEDED") {
130
+ return {
131
+ isError: true,
132
+ content: [{ type: "text", text: `The ${actorLabel} run did not succeed (run ID: ${runId}, status: ${status}).` }],
133
+ };
134
+ }
135
+ if (!datasetId) {
136
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run ${runId} succeeded but reported no dataset, so there is nothing to return.` }] };
137
+ }
138
+ // 4. Read the dataset.
139
+ let ds;
140
+ try {
141
+ ds = await fetch(`https://api.apify.com/v2/datasets/${datasetId}/items?format=json`, { headers });
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ return { isError: true, content: [{ type: "text", text: `Could not read the ${actorLabel} dataset: ${message}` }] };
146
+ }
147
+ if (!ds.ok) {
148
+ return { isError: true, content: [{ type: "text", text: await httpError(ds) }] };
149
+ }
150
+ let items;
151
+ try {
152
+ items = await ds.json();
153
+ }
154
+ catch (err) {
155
+ const message = err instanceof Error ? err.message : String(err);
156
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run returned a response that could not be parsed: ${message}` }] };
157
+ }
158
+ if (!Array.isArray(items)) {
159
+ const asObj = items;
160
+ const detail = asObj?.error?.message
161
+ ? `${asObj.error.message}`
162
+ : JSON.stringify(items);
163
+ return { isError: true, content: [{ type: "text", text: `The ${actorLabel} run did not return a dataset. ${detail}` }] };
164
+ }
165
+ return { content: [{ type: "text", text: JSON.stringify(items, null, 2) }] };
166
+ }
167
+ // The actor's number and on or off inputs are text fields (Apify checks input
168
+ // types before the actor starts, and Clay sends text), so numbers and booleans
169
+ // from the model are sent as text.
170
+ function asActorInput(args) {
171
+ const out = {};
172
+ for (const [k, v] of Object.entries(compact(args))) {
173
+ out[k] = typeof v === "number" || typeof v === "boolean" ? String(v) : v;
174
+ }
175
+ return out;
176
+ }
177
+ const server = new McpServer({
178
+ name: "mamba-bluesky-posts-scraper",
179
+ version: pkg.version,
180
+ });
181
+ // Bluesky Posts Scraper (immutable actor ID KldSjS7wjoeZ7vQGm). Every buyer facing input of the actor is exposed.
182
+ server.registerTool("get_bluesky_posts", {
183
+ title: "Get Bluesky posts",
184
+ description: "Search public Bluesky posts by keyword, read an account's posts, or read a post and its replies. Returns exact like, reply, repost, and quote counts, dates, hashtags, links, and images. No login. Pay per event on Apify.",
185
+ annotations: {
186
+ title: "Get Bluesky posts",
187
+ readOnlyHint: true,
188
+ destructiveHint: false,
189
+ idempotentHint: true,
190
+ openWorldHint: true,
191
+ },
192
+ inputSchema: {
193
+ queries: z.array(z.string()).optional().describe("Keywords to search, up to 20. Without your own app password each search returns one page, about 100 posts. Text from Clay splits on new lines, so a query can hold a comma."),
194
+ authors: z.array(z.string()).optional().describe("Bluesky handles, profile addresses, or DIDs, up to 20. Returns each account's own posts, newest first."),
195
+ post_urls: z.array(z.string()).optional().describe("Addresses of Bluesky posts to read, up to 100."),
196
+ sort: z.string().optional().describe("latest or top. Default latest. Used for queries."),
197
+ since_date: z.string().optional().describe("Only posts from this date, such as 2026-10-01."),
198
+ until_date: z.string().optional().describe("Only posts before this date, such as 2026-11-01."),
199
+ language: z.string().optional().describe("Language code such as en or de. Used for queries. Reads the language the post's author set."),
200
+ max_posts_per_source: z.number().optional().describe("Per query or account, up to 2000. Default 100. A search with no app password stops at about 100."),
201
+ include_replies: z.boolean().optional().describe("yes or no. Default no. Reads the replies under each post found. Each reply is a charged row."),
202
+ max_replies_per_post: z.number().optional().describe("Up to 200. Default 20."),
203
+ include_account_replies: z.boolean().optional().describe("yes or no. Default no. Used for accounts."),
204
+ include_reposts: z.boolean().optional().describe("yes or no. Default no. Used for accounts. A repost row shows the original author."),
205
+ bluesky_handle: z.string().optional().describe("Optional, with the app password. Used only to page a search past its first page."),
206
+ bluesky_app_password: z.string().optional().describe("Optional. Create an app password in Bluesky settings; never use your main password. Sent only to Bluesky. Without it a search returns one page, about 100 posts. Accounts and post addresses need no password."),
207
+ max_charge_usd: z.string().optional().describe("Optional. The run stops before a charge that would pass this amount. Summary rows are always returned."),
208
+ },
209
+ }, async (args) => runActor("KldSjS7wjoeZ7vQGm", "Bluesky Posts Scraper", asActorInput(args)));
210
+ const transport = new StdioServerTransport();
211
+ await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@mambalabsdev/mcp-bluesky-posts-scraper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Bluesky Posts Scraper. Bluesky posts by keyword or account, and replies by post, with exact counts. Runs on Apify. Clay-ready output.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-bluesky-posts-scraper": "./build/index.js"
8
+ },
9
+ "files": [
10
+ "build",
11
+ "README.md",
12
+ "LICENSE",
13
+ "SECURITY.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc && chmod +x build/index.js",
17
+ "test": "npm run build && node --test test/*.test.js",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "bluesky",
24
+ "social media",
25
+ "posts",
26
+ "replies",
27
+ "at protocol",
28
+ "social listening",
29
+ "apify",
30
+ "clay"
31
+ ],
32
+ "author": "Mamba Labs <labs@mambamindsetgrowth.com>",
33
+ "license": "MIT",
34
+ "homepage": "https://apify.com/mambalabs/bluesky-posts-scraper",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/mambalabsdev/mcp-bluesky-posts-scraper"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.32.2",
44
+ "zod": "^4.4.3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^26.2.0",
48
+ "typescript": "5.9.3"
49
+ },
50
+ "mcpName": "com.mambabuilt/mcp-bluesky-posts-scraper"
51
+ }