@jclvsh/dropspace 2.10.0 → 2.12.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,6 +1,6 @@
1
1
  # @jclvsh/dropspace
2
2
 
3
- MCP server and CLI for the [Dropspace](https://www.dropspace.dev) API. Lets AI agents (Claude Code, Cursor) and humans manage launches, personas, media generation, boosts, autopilot, recommendations, platform connections, API keys, and webhooks. 59 tools across 13 categories.
3
+ MCP server and CLI for the [Dropspace](https://www.dropspace.dev) API. Lets AI agents (Claude Code, Cursor) and humans manage launches, personas, media generation, boosts, autopilot, recommendations, reddit engagement, platform connections, API keys, and webhooks. 69 tools across 14 categories.
4
4
 
5
5
  ## Installation
6
6
 
@@ -42,20 +42,21 @@ dropspace launches --help
42
42
 
43
43
  ### CLI Commands
44
44
 
45
- | Group | Commands | Count |
46
- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----- |
47
- | launches | list, create, get, update, delete, publish, retry, generate, retry-content, analytics, batch-analytics, status, delete-post, delete-posts | 14 |
48
- | personas | list, create, get, update, delete, analyze | 6 |
49
- | media | generate, job | 2 |
50
- | connections | list | 1 |
51
- | boosts | create, list, get, activate, cancel, performance | 6 |
52
- | credits | balance, purchase | 2 |
53
- | keys | me, list, create, rename, revoke, rotate | 6 |
54
- | webhooks | list, create, get, update, delete, test, rotate-secret, deliveries | 8 |
55
- | status | dropspace official account connections | 1 |
56
- | usage | plan, limits, and billing period | 1 |
57
- | autopilot | status, configure, review, approve, reject, run, runs | 7 |
58
- | recommendations (recs) | list, apply, dismiss | 3 |
45
+ | Group | Commands | Count |
46
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----- |
47
+ | launches | list, create, get, update, delete, publish, retry, generate, retry-content, analytics, batch-analytics, status, delete-post, delete-posts | 14 |
48
+ | personas | list, create, get, update, delete, analyze | 6 |
49
+ | media | generate, job | 2 |
50
+ | connections | list | 1 |
51
+ | boosts | create, list, get, activate, cancel, performance | 6 |
52
+ | reddit | suggest, config, configure, discover, comments, approve, reject, standing, activate, deactivate | 10 |
53
+ | credits | balance, purchase | 2 |
54
+ | keys | me, list, create, rename, revoke, rotate | 6 |
55
+ | webhooks | list, create, get, update, delete, test, rotate-secret, deliveries | 8 |
56
+ | status | dropspace official account connections | 1 |
57
+ | usage | plan, limits, and billing period | 1 |
58
+ | autopilot | status, configure, review, approve, reject, run, runs | 7 |
59
+ | recommendations (recs) | list, apply, dismiss | 3 |
59
60
 
60
61
  ## MCP Server Configuration
61
62
 
@@ -108,41 +109,41 @@ Add to your `.cursor/mcp.json`:
108
109
 
109
110
  ### Launches
110
111
 
111
- | Tool | Description |
112
- | ---------------------- | -------------------------------------------------------------- |
113
- | `list_launches` | List your launches with pagination |
114
- | `create_launch` | Create a new launch with content, media, and account selection |
115
- | `get_launch` | Get a launch by ID with posting status |
116
- | `update_launch` | Update a launch's schedule, status, content, or media |
117
- | `delete_launch` | Delete a launch |
118
- | `publish_launch` | Publish a launch to all configured platforms |
119
- | `retry_launch` | Retry failed platforms for a launch |
120
- | `generate_content` | Generate AI content and video scripts for a launch |
121
- | `retry_launch_content` | Retry AI content generation for failed platforms |
122
- | `get_launch_analytics` | Get publishing analytics with per-post engagement metrics |
123
- | `get_batch_launch_analytics` | Get analytics for multiple launches in one call |
124
- | `get_launch_status` | Get detailed posting status with per-platform logs |
112
+ | Tool | Description |
113
+ | ---------------------------- | -------------------------------------------------------------- |
114
+ | `list_launches` | List your launches with pagination |
115
+ | `create_launch` | Create a new launch with content, media, and account selection |
116
+ | `get_launch` | Get a launch by ID with posting status |
117
+ | `update_launch` | Update a launch's schedule, status, content, or media |
118
+ | `delete_launch` | Delete a launch |
119
+ | `publish_launch` | Publish a launch to all configured platforms |
120
+ | `retry_launch` | Retry failed platforms for a launch |
121
+ | `generate_content` | Generate AI content and video scripts for a launch |
122
+ | `retry_launch_content` | Retry AI content generation for failed platforms |
123
+ | `get_launch_analytics` | Get publishing analytics with per-post engagement metrics |
124
+ | `get_batch_launch_analytics` | Get analytics for multiple launches in one call |
125
+ | `get_launch_status` | Get detailed posting status with per-platform logs |
125
126
 
126
127
  #### `create_launch` Parameters
127
128
 
128
- | Parameter | Type | Required | Description |
129
- | ----------------------------- | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
130
- | `title` | string | Yes | Launch title |
131
- | `product_description` | string | Yes | Product description |
132
- | `platforms` | string[] | Yes | Platforms to publish to (1-9) |
133
- | `product_url` | string | No | Product URL |
134
- | `scheduled_date` | string | No | ISO 8601 datetime (15+ min in the future) |
135
- | `persona_id` | string | No | Persona ID for tone/style |
136
- | `dropspace_platforms` | string[] | No | Platforms to post via official dropspace accounts |
137
- | `media_mode` | string | No | `"images"` or `"video"` |
138
- | `user_platform_accounts` | object | No | Map of platform key to token_id (UUID). Simple keys: `twitter`, `reddit`, `instagram`, `tiktok`. LinkedIn: `linkedin:personal` or `linkedin:organization:<org_id>`. Facebook: `facebook:page:<page_id>` |
129
+ | Parameter | Type | Required | Description |
130
+ | ----------------------------- | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | `title` | string | Yes | Launch title |
132
+ | `product_description` | string | Yes | Product description |
133
+ | `platforms` | string[] | Yes | Platforms to publish to (1-9) |
134
+ | `product_url` | string | No | Product URL |
135
+ | `scheduled_date` | string | No | ISO 8601 datetime (15+ min in the future) |
136
+ | `persona_id` | string | No | Persona ID for tone/style |
137
+ | `dropspace_platforms` | string[] | No | Platforms to post via official dropspace accounts |
138
+ | `media_mode` | string | No | `"images"` or `"video"` |
139
+ | `user_platform_accounts` | object | No | Map of platform key to token_id (UUID). Simple keys: `twitter`, `reddit`, `instagram`, `tiktok`. LinkedIn: `linkedin:personal` or `linkedin:organization:<org_id>`. Facebook: `facebook:page:<page_id>` |
139
140
  | `platform_contents` | object | No | Per-platform content (mutually exclusive with `custom_content`). Reddit accepts `metadata.subreddits` (string[], max 10, no `r/` prefix — one post per subreddit, each retryable) and `metadata.first_comment` (max 1500) |
140
- | `custom_content` | string \| string[] | No | Single content for all platforms, or array of tweets for twitter thread mode (mutually exclusive with `platform_contents`) |
141
- | `custom_content_reddit_title` | string | No | Reddit title (required with `custom_content` + reddit) |
142
- | `media` | object[] | No | Inline media, URL or base64 (1-10 items, mutually exclusive with `media_assets`) |
143
- | `media_assets` | object[] | No | Pre-uploaded media references (mutually exclusive with `media`) |
144
- | `media_attach_platforms` | string[] | No | Platforms to attach media to |
145
- | `generate_ai_videos` | string[] | No | Auto-generate AI videos (`"instagram"`, `"tiktok"`) |
141
+ | `custom_content` | string \| string[] | No | Single content for all platforms, or array of tweets for twitter thread mode (mutually exclusive with `platform_contents`) |
142
+ | `custom_content_reddit_title` | string | No | Reddit title (required with `custom_content` + reddit) |
143
+ | `media` | object[] | No | Inline media, URL or base64 (1-10 items, mutually exclusive with `media_assets`) |
144
+ | `media_assets` | object[] | No | Pre-uploaded media references (mutually exclusive with `media`) |
145
+ | `media_attach_platforms` | string[] | No | Platforms to attach media to |
146
+ | `generate_ai_videos` | string[] | No | Auto-generate AI videos (`"instagram"`, `"tiktok"`) |
146
147
 
147
148
  #### `update_launch` Parameters
148
149
 
@@ -169,16 +170,16 @@ Add to your `.cursor/mcp.json`:
169
170
 
170
171
  ### Personas
171
172
 
172
- | Tool | Description |
173
- | ----------------- | ------------------------------------------------ |
174
- | `list_personas` | List your personas |
175
- | `create_persona` | Create a new persona for content tone/style |
176
- | `get_persona` | Get a persona by ID with analysis details |
177
- | `update_persona` | Update a persona's name or writing samples |
178
- | `delete_persona` | Delete a persona |
179
- | `analyze_persona` | Trigger AI analysis of a persona's writing style |
180
- | `finalize_persona` | Accept a completed analysis so the persona is usable |
181
- | `discard_persona_analysis` | Discard a pending analysis result |
173
+ | Tool | Description |
174
+ | -------------------------- | ---------------------------------------------------- |
175
+ | `list_personas` | List your personas |
176
+ | `create_persona` | Create a new persona for content tone/style |
177
+ | `get_persona` | Get a persona by ID with analysis details |
178
+ | `update_persona` | Update a persona's name or writing samples |
179
+ | `delete_persona` | Delete a persona |
180
+ | `analyze_persona` | Trigger AI analysis of a persona's writing style |
181
+ | `finalize_persona` | Accept a completed analysis so the persona is usable |
182
+ | `discard_persona_analysis` | Discard a pending analysis result |
182
183
 
183
184
  #### `analyze_persona` Parameters
184
185
 
@@ -204,10 +205,10 @@ Add to your `.cursor/mcp.json`:
204
205
 
205
206
  ### Media
206
207
 
207
- | Tool | Description |
208
- | ---------------- | ---------------------------------------------------------------------------- |
209
- | `generate_media` | Submit an AI image or video generation job for a launch (returns a job ID) |
210
- | `get_media_job` | Poll a media generation job's status; returns the result URL when complete |
208
+ | Tool | Description |
209
+ | ---------------- | -------------------------------------------------------------------------- |
210
+ | `generate_media` | Submit an AI image or video generation job for a launch (returns a job ID) |
211
+ | `get_media_job` | Poll a media generation job's status; returns the result URL when complete |
211
212
 
212
213
  ### Connections
213
214
 
@@ -217,14 +218,14 @@ Add to your `.cursor/mcp.json`:
217
218
 
218
219
  ### Boosts
219
220
 
220
- | Tool | Description |
221
- | ----------------------- | ------------------------------------------------------------------------ |
221
+ | Tool | Description |
222
+ | ----------------------- | ------------------------------------------------------------------------- |
222
223
  | `create_boost` | Create a paid Meta boost on an organic Facebook/Instagram launch (paused) |
223
- | `list_boosts` | List all boosts attached to a launch |
224
- | `get_boost` | Get a boost by ID with current performance metrics |
225
- | `activate_boost` | Activate a paused boost — ad spend begins immediately |
226
- | `cancel_boost` | Cancel a boost (deletes the Meta campaign, irreversible) |
227
- | `get_boost_performance` | Fetch latest boost metrics (impressions, clicks, reach, spend) |
224
+ | `list_boosts` | List all boosts attached to a launch |
225
+ | `get_boost` | Get a boost by ID with current performance metrics |
226
+ | `activate_boost` | Activate a paused boost — ad spend begins immediately |
227
+ | `cancel_boost` | Cancel a boost (deletes the Meta campaign, irreversible) |
228
+ | `get_boost_performance` | Fetch latest boost metrics (impressions, clicks, reach, spend) |
228
229
 
229
230
  ### Credits
230
231
 
@@ -315,11 +316,11 @@ Autopilot is available on pro and premium plans.
315
316
 
316
317
  ### Recommendations
317
318
 
318
- | Tool | Description |
319
- | ------------------------ | ---------------------------------------------------------------------------------------------------- |
320
- | `list_recommendations` | List actionable insights from your organic post performance (winners, boost/promote/pause signals) |
319
+ | Tool | Description |
320
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------- |
321
+ | `list_recommendations` | List actionable insights from your organic post performance (winners, boost/promote/pause signals) |
321
322
  | `apply_recommendation` | Apply the one-click action: draft launch, PAUSED boost (needs `ad_account_id`), autopilot queue, directive |
322
- | `dismiss_recommendation` | Dismiss a pending recommendation |
323
+ | `dismiss_recommendation` | Dismiss a pending recommendation |
323
324
 
324
325
  Applying never publishes or spends by itself — drafts need scheduling and boosts need explicit activation.
325
326
 
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { registerUsageCommands } from "./commands/usage.js";
14
14
  import { registerCreditCommands } from "./commands/credits.js";
15
15
  import { registerMediaCommands } from "./commands/media.js";
16
16
  import { registerBoostCommands } from "./commands/boosts.js";
17
+ import { registerRedditCommands } from "./commands/reddit.js";
17
18
  import { registerAutopilotCommands } from "./commands/autopilot.js";
18
19
  import { registerRecommendationCommands } from "./commands/recommendations.js";
19
20
  const program = new Command()
@@ -36,6 +37,7 @@ try {
36
37
  registerBoostCommands(program, client);
37
38
  registerAutopilotCommands(program, client);
38
39
  registerRecommendationCommands(program, client);
40
+ registerRedditCommands(program, client);
39
41
  await program.parseAsync(process.argv);
40
42
  }
41
43
  catch (error) {
package/dist/client.d.ts CHANGED
@@ -4,6 +4,7 @@ export declare class DropspaceClient {
4
4
  constructor();
5
5
  get<T>(path: string, params?: Record<string, string>): Promise<T>;
6
6
  post<T>(path: string, body?: unknown): Promise<T>;
7
+ put<T>(path: string, body: unknown): Promise<T>;
7
8
  patch<T>(path: string, body: unknown): Promise<T>;
8
9
  delete(path: string): Promise<void>;
9
10
  private request;
package/dist/client.js CHANGED
@@ -31,6 +31,13 @@ export class DropspaceClient {
31
31
  body: body !== undefined ? JSON.stringify(body) : undefined,
32
32
  });
33
33
  }
34
+ async put(path, body) {
35
+ return this.request(`${this.baseUrl}${path}`, {
36
+ method: "PUT",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+ }
34
41
  async patch(path, body) {
35
42
  return this.request(`${this.baseUrl}${path}`, {
36
43
  method: "PATCH",
@@ -26,7 +26,7 @@ export function registerKeyCommands(program, client) {
26
26
  keys.command("create")
27
27
  .description("create a new API key (requires admin scope)")
28
28
  .requiredOption("-n, --name <name>", "key name")
29
- .option("--scopes <list>", "comma-separated scopes: read,write,generate,send,admin (legacy publish/delete still accepted)", parseList)
29
+ .option("--scopes <list>", "comma-separated scopes: read,write,generate,send,admin (defaults to read,write when omitted)", parseList)
30
30
  .action(async (opts) => {
31
31
  try {
32
32
  const body = { name: opts.name };
@@ -0,0 +1,8 @@
1
+ import type { Command } from "commander";
2
+ import type { DropspaceClient } from "../client.js";
3
+ /**
4
+ * Reddit discovery + engagement CLI — the earn-standing-before-promoting loop:
5
+ * suggest → activate → discover → approve/reject → standing. Requires an api key whose
6
+ * account has reddit connected; caps and gates are enforced server-side (hard rails).
7
+ */
8
+ export declare function registerRedditCommands(program: Command, client: DropspaceClient): void;
@@ -0,0 +1,148 @@
1
+ import { formatOutput, handleError, getFormat } from "../cli-utils.js";
2
+ /**
3
+ * Reddit discovery + engagement CLI — the earn-standing-before-promoting loop:
4
+ * suggest → activate → discover → approve/reject → standing. Requires an api key whose
5
+ * account has reddit connected; caps and gates are enforced server-side (hard rails).
6
+ */
7
+ export function registerRedditCommands(program, client) {
8
+ const reddit = program
9
+ .command("reddit")
10
+ .description("subreddit discovery + engagement (earn standing before promoting)");
11
+ reddit
12
+ .command("suggest")
13
+ .description("ICP-matched subreddit discovery with per-community standing badges")
14
+ .requiredOption("-b, --brief <text>", "what the product is and who it's for (10-2000 chars)")
15
+ .action(async (opts) => {
16
+ try {
17
+ const result = await client.post("/reddit/subreddits/suggest", { brief: opts.brief });
18
+ formatOutput(result, getFormat(program.opts()));
19
+ }
20
+ catch (error) {
21
+ handleError(error);
22
+ }
23
+ });
24
+ reddit
25
+ .command("config")
26
+ .description("read the engagement settings (enabled, caps, gate state, comment voice)")
27
+ .action(async () => {
28
+ try {
29
+ const result = await client.get("/reddit/engagement/config");
30
+ formatOutput(result, getFormat(program.opts()));
31
+ }
32
+ catch (error) {
33
+ handleError(error);
34
+ }
35
+ });
36
+ reddit
37
+ .command("configure")
38
+ .description("enable/disable engagement and tune caps (values above the rails are clamped)")
39
+ .requiredOption("--enabled <bool>", "true | false")
40
+ .option("--max-per-day <n>", "daily comment cap (clamped to 5)")
41
+ .option("--max-per-sub <n>", "per-subreddit daily cap (clamped to 2)")
42
+ .option("--min-gap <minutes>", "minimum gap between comments (floored at 20)")
43
+ .option("--persona-id <uuid|null>", "persona whose voice drafts the comments; 'null' = auto (latest)")
44
+ .action(async (opts) => {
45
+ try {
46
+ const body = { enabled: opts.enabled === "true" };
47
+ if (opts.maxPerDay)
48
+ body.max_comments_per_day = parseInt(opts.maxPerDay, 10);
49
+ if (opts.maxPerSub)
50
+ body.max_comments_per_sub_per_day = parseInt(opts.maxPerSub, 10);
51
+ if (opts.minGap)
52
+ body.min_gap_minutes = parseInt(opts.minGap, 10);
53
+ if (opts.personaId)
54
+ body.persona_id = opts.personaId === "null" ? null : opts.personaId;
55
+ const result = await client.put("/reddit/engagement/config", body);
56
+ formatOutput(result, getFormat(program.opts()));
57
+ }
58
+ catch (error) {
59
+ handleError(error);
60
+ }
61
+ });
62
+ reddit
63
+ .command("discover")
64
+ .description("draft helpful comments for one active subreddit (posts nothing — fills the review queue)")
65
+ .action(async () => {
66
+ try {
67
+ const result = await client.post("/reddit/engagement/discover");
68
+ formatOutput(result, getFormat(program.opts()));
69
+ }
70
+ catch (error) {
71
+ handleError(error);
72
+ }
73
+ });
74
+ reddit
75
+ .command("comments")
76
+ .description("list drafted/posted engagement comments")
77
+ .option("--status <status>", "pending (review queue, default) | history")
78
+ .action(async (opts) => {
79
+ try {
80
+ const result = await client.get("/reddit/engagement/comments", opts.status ? { status: opts.status } : undefined);
81
+ formatOutput(result, getFormat(program.opts()));
82
+ }
83
+ catch (error) {
84
+ handleError(error);
85
+ }
86
+ });
87
+ reddit
88
+ .command("approve <comment-id>")
89
+ .description("approve a drafted comment — POSTS it into a pacing slot (caps/gaps still enforced)")
90
+ .option("--body <text>", "edited comment text (replaces the draft)")
91
+ .action(async (commentId, opts) => {
92
+ try {
93
+ const result = await client.post(`/reddit/engagement/comments/${commentId}/approve`, opts.body ? { body: opts.body } : undefined);
94
+ formatOutput(result, getFormat(program.opts()));
95
+ }
96
+ catch (error) {
97
+ handleError(error);
98
+ }
99
+ });
100
+ reddit
101
+ .command("reject <comment-id>")
102
+ .description("discard a drafted comment without posting")
103
+ .action(async (commentId) => {
104
+ try {
105
+ const result = await client.post(`/reddit/engagement/comments/${commentId}/reject`);
106
+ formatOutput(result, getFormat(program.opts()));
107
+ }
108
+ catch (error) {
109
+ handleError(error);
110
+ }
111
+ });
112
+ reddit
113
+ .command("standing")
114
+ .description("earned standing per subreddit (tier, comments, karma, any removal pause)")
115
+ .action(async () => {
116
+ try {
117
+ const result = await client.get("/reddit/engagement/standing");
118
+ formatOutput(result, getFormat(program.opts()));
119
+ }
120
+ catch (error) {
121
+ handleError(error);
122
+ }
123
+ });
124
+ reddit
125
+ .command("activate <subreddit>")
126
+ .description("activate a subreddit as an engagement candidate (you pick the communities)")
127
+ .action(async (subreddit) => {
128
+ try {
129
+ const result = await client.post("/reddit/engagement/subreddits", { subreddit });
130
+ formatOutput(result, getFormat(program.opts()));
131
+ }
132
+ catch (error) {
133
+ handleError(error);
134
+ }
135
+ });
136
+ reddit
137
+ .command("deactivate <subreddit>")
138
+ .description("stop drafting comments for a subreddit (earned standing is kept)")
139
+ .action(async (subreddit) => {
140
+ try {
141
+ await client.delete(`/reddit/engagement/subreddits/${encodeURIComponent(subreddit)}`);
142
+ formatOutput({ subreddit, engagement_enabled: false }, getFormat(program.opts()));
143
+ }
144
+ catch (error) {
145
+ handleError(error);
146
+ }
147
+ });
148
+ }
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { registerMediaTools } from "./tools/media.js";
14
14
  import { registerBoostTools } from "./tools/boosts.js";
15
15
  import { registerAutopilotTools } from "./tools/autopilot.js";
16
16
  import { registerRecommendationTools } from "./tools/recommendations.js";
17
+ import { registerRedditTools } from "./tools/reddit.js";
17
18
  const server = new McpServer({
18
19
  name: "dropspace",
19
20
  version: "2.9.0",
@@ -31,5 +32,6 @@ registerMediaTools(server, client);
31
32
  registerBoostTools(server, client);
32
33
  registerAutopilotTools(server, client);
33
34
  registerRecommendationTools(server, client);
35
+ registerRedditTools(server, client);
34
36
  const transport = new StdioServerTransport();
35
37
  await server.connect(transport);
@@ -4,7 +4,9 @@
4
4
  * Phase 1: facebook only, engagement objective, auto-targeting. API key auth.
5
5
  */
6
6
  import { z } from "zod";
7
- const platformSchema = z.enum(["facebook", "instagram"]).describe("ad platform — facebook or instagram (IG boost requires the IG business account linked to one of the user's facebook pages)");
7
+ const platformSchema = z
8
+ .enum(["facebook", "instagram"])
9
+ .describe("ad platform — facebook or instagram (IG boost requires the IG business account linked to one of the user's facebook pages)");
8
10
  const objectiveSchema = z.literal("engagement").describe("campaign objective (phase 1: engagement only)");
9
11
  const launchIdParam = z.string().uuid().describe("launch ID to boost — must have a successful facebook post");
10
12
  const boostIdParam = z.string().uuid().describe("boost ID");
@@ -37,7 +39,12 @@ export function registerBoostTools(server, client) {
37
39
  }
38
40
  catch (error) {
39
41
  return {
40
- content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
46
+ },
47
+ ],
41
48
  isError: true,
42
49
  };
43
50
  }
@@ -49,7 +56,12 @@ export function registerBoostTools(server, client) {
49
56
  }
50
57
  catch (error) {
51
58
  return {
52
- content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
59
+ content: [
60
+ {
61
+ type: "text",
62
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
63
+ },
64
+ ],
53
65
  isError: true,
54
66
  };
55
67
  }
@@ -61,7 +73,12 @@ export function registerBoostTools(server, client) {
61
73
  }
62
74
  catch (error) {
63
75
  return {
64
- content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
76
+ content: [
77
+ {
78
+ type: "text",
79
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
80
+ },
81
+ ],
65
82
  isError: true,
66
83
  };
67
84
  }
@@ -73,7 +90,12 @@ export function registerBoostTools(server, client) {
73
90
  }
74
91
  catch (error) {
75
92
  return {
76
- content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
93
+ content: [
94
+ {
95
+ type: "text",
96
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
97
+ },
98
+ ],
77
99
  isError: true,
78
100
  };
79
101
  }
@@ -81,11 +103,18 @@ export function registerBoostTools(server, client) {
81
103
  server.tool("cancel_boost", "cancel a boost — deletes the campaign on meta and soft-deletes the dropspace record. final, irreversible.", { launch_id: launchIdParam, boost_id: boostIdParam }, async ({ launch_id, boost_id }) => {
82
104
  try {
83
105
  await client.delete(`/launches/${launch_id}/boost/${boost_id}`);
84
- return { content: [{ type: "text", text: JSON.stringify({ success: true, boost_id }, null, 2) }] };
106
+ return {
107
+ content: [{ type: "text", text: JSON.stringify({ success: true, boost_id }, null, 2) }],
108
+ };
85
109
  }
86
110
  catch (error) {
87
111
  return {
88
- content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
112
+ content: [
113
+ {
114
+ type: "text",
115
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
116
+ },
117
+ ],
89
118
  isError: true,
90
119
  };
91
120
  }
@@ -97,7 +126,12 @@ export function registerBoostTools(server, client) {
97
126
  }
98
127
  catch (error) {
99
128
  return {
100
- content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
129
+ content: [
130
+ {
131
+ type: "text",
132
+ text: `error: ${error instanceof Error ? error.message : String(error)}`,
133
+ },
134
+ ],
101
135
  isError: true,
102
136
  };
103
137
  }
@@ -1,7 +1,6 @@
1
1
  import { z } from "zod";
2
2
  // Cross-repo canonical scope vocabulary (2026-05-07): read/write/generate/send/admin.
3
- // Pre-rename: `delete` was folded into `write` (soft-deletes are writes); `publish` is now `send`.
4
- // Existing keys with legacy scope values continue to authenticate via SCOPE_ALIASES on the server.
3
+ // Legacy values (publish/delete) were fully retired 2026-07-20 - all rows backfilled canonical.
5
4
  const SCOPES = ["read", "write", "generate", "send", "admin"];
6
5
  export function registerKeyTools(server, client) {
7
6
  server.tool("get_current_api_key", "get the current API key's info — name, scopes, and prefix (no scope required)", {}, async () => {
@@ -44,7 +43,7 @@ export function registerKeyTools(server, client) {
44
43
  });
45
44
  server.tool("create_api_key", "create a new API key — the raw key is returned once, store it securely (requires admin scope)", {
46
45
  name: z.string().min(1).max(100).describe("key name (1-100 characters)"),
47
- scopes: z.array(z.enum(SCOPES)).optional().describe("permission scopes (defaults to all scopes)"),
46
+ scopes: z.array(z.enum(SCOPES)).optional().describe("permission scopes (defaults to read,write when omitted)"),
48
47
  }, async (args) => {
49
48
  try {
50
49
  const result = await client.post("/keys", args);
@@ -84,7 +84,11 @@ export function registerLaunchTools(server, client) {
84
84
  .max(10)
85
85
  .optional()
86
86
  .describe("target platforms - required unless dropspace_platforms or user_platform_accounts is provided (inferred from their union)"),
87
- product_url: z.string().url().optional().describe("product URL — scraped to generate content; can substitute for product_description"),
87
+ product_url: z
88
+ .string()
89
+ .url()
90
+ .optional()
91
+ .describe("product URL — scraped to generate content; can substitute for product_description"),
88
92
  scheduled_date: z
89
93
  .string()
90
94
  .optional()
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Reddit engagement + discovery MCP tools.
3
+ *
4
+ * The full loop an agent runs to earn standing before promoting (reddit's 90:10 norm):
5
+ * suggest ICP subreddits → activate the relevant ones → discover (drafts helpful comments) →
6
+ * approve/reject within the hard rails → standing grows → launch fan-out posts clear the
7
+ * publish-time 90:10 gate. Requires an api key whose account has reddit connected.
8
+ */
9
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { DropspaceClient } from "../client.js";
11
+ export declare function registerRedditTools(server: McpServer, client: DropspaceClient): void;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Reddit engagement + discovery MCP tools.
3
+ *
4
+ * The full loop an agent runs to earn standing before promoting (reddit's 90:10 norm):
5
+ * suggest ICP subreddits → activate the relevant ones → discover (drafts helpful comments) →
6
+ * approve/reject within the hard rails → standing grows → launch fan-out posts clear the
7
+ * publish-time 90:10 gate. Requires an api key whose account has reddit connected.
8
+ */
9
+ import { z } from "zod";
10
+ const subredditParam = z
11
+ .string()
12
+ .min(3)
13
+ .describe("community name, no r/ prefix (canonicalized to lowercase server-side)");
14
+ const commentIdParam = z.string().uuid().describe("engagement comment ID (from list_engagement_comments)");
15
+ function ok(result) {
16
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
17
+ }
18
+ function fail(error) {
19
+ return {
20
+ content: [{ type: "text", text: `error: ${error instanceof Error ? error.message : String(error)}` }],
21
+ isError: true,
22
+ };
23
+ }
24
+ export function registerRedditTools(server, client) {
25
+ server.tool("suggest_subreddits", "ICP-matched subreddit discovery: pass a product brief, get verified real subreddits with a promo-policy classification and a green/yellow/red standing badge for THIS account. prefer green subs; earn standing in yellow ones by commenting before posting.", {
26
+ brief: z.string().min(10).max(2000).describe("what the product is and who it's for"),
27
+ }, async (args) => {
28
+ try {
29
+ return ok(await client.post("/reddit/subreddits/suggest", args));
30
+ }
31
+ catch (error) {
32
+ return fail(error);
33
+ }
34
+ });
35
+ server.tool("get_engagement_config", "read the account's reddit engagement settings — enabled, caps, the 14d/50-karma gate state, and which persona voices the comments.", {}, async () => {
36
+ try {
37
+ return ok(await client.get("/reddit/engagement/config"));
38
+ }
39
+ catch (error) {
40
+ return fail(error);
41
+ }
42
+ });
43
+ server.tool("update_engagement_config", "enable/disable reddit engagement and tune caps. caps are STRUCTURAL RAILS: values above the ceilings (5/day, 2/sub/day, 20-min gap) are clamped, not obeyed. persona_id pins which persona voices the comments (null = auto latest).", {
44
+ enabled: z.boolean().describe("turn engagement on/off for this account"),
45
+ max_comments_per_day: z.number().int().min(1).max(5).optional().describe("clamped to 5"),
46
+ max_comments_per_sub_per_day: z.number().int().min(1).max(2).optional().describe("clamped to 2"),
47
+ min_gap_minutes: z.number().int().min(20).optional().describe("floored at 20"),
48
+ persona_id: z
49
+ .string()
50
+ .uuid()
51
+ .nullable()
52
+ .optional()
53
+ .describe("persona whose voice drafts the comments; null = auto (latest complete persona)"),
54
+ }, async (args) => {
55
+ try {
56
+ return ok(await client.put("/reddit/engagement/config", args));
57
+ }
58
+ catch (error) {
59
+ return fail(error);
60
+ }
61
+ });
62
+ server.tool("discover_engagement", "draft helpful comments for ONE of the account's active subreddits (call again to cover more). fills the review queue; posts NOTHING — approval is the separate, rail-gated step. `skipped` names why nothing ran (kill_switch | not_enabled | gate_not_passed).", {}, async () => {
63
+ try {
64
+ return ok(await client.post("/reddit/engagement/discover"));
65
+ }
66
+ catch (error) {
67
+ return fail(error);
68
+ }
69
+ });
70
+ server.tool("list_engagement_comments", "list drafted/posted engagement comments. status=pending (default) is the review queue awaiting approve/reject; status=history is everything already acted on.", {
71
+ status: z.enum(["pending", "history"]).optional().describe("default: pending"),
72
+ }, async (args) => {
73
+ try {
74
+ return ok(await client.get("/reddit/engagement/comments", args.status ? { status: args.status } : undefined));
75
+ }
76
+ catch (error) {
77
+ return fail(error);
78
+ }
79
+ });
80
+ server.tool("approve_engagement_comment", "approve a drafted comment — this POSTS it (scheduled into a pacing slot). the rails still apply: per-day/per-sub caps and the min-gap return 429 when hit. optional body edits the draft before posting.", {
81
+ comment_id: commentIdParam,
82
+ body: z.string().min(20).max(1500).optional().describe("edited comment text (replaces the draft)"),
83
+ }, async (args) => {
84
+ try {
85
+ return ok(await client.post(`/reddit/engagement/comments/${args.comment_id}/approve`, args.body ? { body: args.body } : undefined));
86
+ }
87
+ catch (error) {
88
+ return fail(error);
89
+ }
90
+ });
91
+ server.tool("reject_engagement_comment", "discard a drafted comment without posting.", { comment_id: commentIdParam }, async (args) => {
92
+ try {
93
+ return ok(await client.post(`/reddit/engagement/comments/${args.comment_id}/reject`));
94
+ }
95
+ catch (error) {
96
+ return fail(error);
97
+ }
98
+ });
99
+ server.tool("get_engagement_standing", "the standing the account has EARNED per subreddit — tier (none → seedling → active → established), comments posted, karma earned, and any active removal pause. this is what lets fan-out posts clear the 90:10 gate.", {}, async () => {
100
+ try {
101
+ return ok(await client.get("/reddit/engagement/standing"));
102
+ }
103
+ catch (error) {
104
+ return fail(error);
105
+ }
106
+ });
107
+ server.tool("activate_engagement_subreddit", "activate a subreddit as an engagement candidate — the seed that lets a fresh account draft its first comments. pick subs from suggest_subreddits; YOU choose the communities, the machine only sustains engagement within them.", { subreddit: subredditParam }, async (args) => {
108
+ try {
109
+ return ok(await client.post("/reddit/engagement/subreddits", { subreddit: args.subreddit }));
110
+ }
111
+ catch (error) {
112
+ return fail(error);
113
+ }
114
+ });
115
+ server.tool("deactivate_engagement_subreddit", "stop drafting comments for a subreddit. earned standing is kept, so re-activating later resumes where it left off.", { subreddit: subredditParam }, async (args) => {
116
+ try {
117
+ await client.delete(`/reddit/engagement/subreddits/${encodeURIComponent(args.subreddit)}`);
118
+ return ok({ subreddit: args.subreddit, engagement_enabled: false });
119
+ }
120
+ catch (error) {
121
+ return fail(error);
122
+ }
123
+ });
124
+ }
package/dist/types.d.ts CHANGED
@@ -423,3 +423,69 @@ export interface RecommendationApplyResponse {
423
423
  recommendation: Recommendation;
424
424
  outcome: RecommendationApplyOutcome;
425
425
  }
426
+ export interface SubredditStandingBadge {
427
+ level: string;
428
+ tier: string;
429
+ promo_posts_here: number;
430
+ contributions_here: number;
431
+ promo_share: number | null;
432
+ reasons: string[];
433
+ }
434
+ export interface SubredditSuggestion {
435
+ name: string;
436
+ title: string;
437
+ subscribers: number;
438
+ over18: boolean;
439
+ subreddit_type: string;
440
+ promo_policy: string;
441
+ promo_evidence: string | null;
442
+ standing: SubredditStandingBadge;
443
+ reason: string;
444
+ }
445
+ export interface SubredditSuggestions {
446
+ icp_summary: string;
447
+ suggestions: SubredditSuggestion[];
448
+ }
449
+ export interface EngagementConfig {
450
+ enabled: boolean;
451
+ max_comments_per_day: number;
452
+ max_comments_per_sub_per_day: number;
453
+ min_gap_minutes: number;
454
+ gate_passed: boolean;
455
+ account_username: string | null;
456
+ account_total_karma: number | null;
457
+ persona_id: string | null;
458
+ }
459
+ export interface EngagementComment {
460
+ id: string;
461
+ comment_kind: string;
462
+ subreddit: string;
463
+ body: string;
464
+ status: string;
465
+ scheduled_for: string | null;
466
+ comment_permalink: string | null;
467
+ comment_score: number | null;
468
+ error_message: string | null;
469
+ created_at: string;
470
+ target: {
471
+ subreddit: string;
472
+ thread_title: string;
473
+ thread_url: string;
474
+ thread_author: string | null;
475
+ relevance_score: number | null;
476
+ } | null;
477
+ }
478
+ export interface SubredditStanding {
479
+ subreddit: string;
480
+ tier: string;
481
+ comments_posted: number;
482
+ comments_removed: number;
483
+ karma_earned: number;
484
+ paused_until: string | null;
485
+ pause_reason: string | null;
486
+ last_activity_at: string | null;
487
+ }
488
+ export interface EngagementSweepResult {
489
+ drafted: number;
490
+ skipped: string | null;
491
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jclvsh/dropspace",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "MCP server and CLI for the Dropspace API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,6 +15,7 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "build": "tsc",
18
+ "prepublishOnly": "npm run build",
18
19
  "start": "node dist/index.js"
19
20
  },
20
21
  "dependencies": {