@jclvsh/dropspace 2.22.2 → 2.24.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
@@ -107,6 +107,21 @@ Add to your `.cursor/mcp.json`:
107
107
  }
108
108
  ```
109
109
 
110
+ ## Hosted Endpoint
111
+
112
+ Clients that connect to a remote MCP server over HTTPS can skip the install and use:
113
+
114
+ ```
115
+ https://api.dropspace.dev/mcp
116
+ ```
117
+
118
+ - Transport: MCP streamable HTTP, `POST` only, stateless, JSON responses.
119
+ - Auth: `Authorization: Bearer ds_live_...` (the same API key; its scopes apply).
120
+ - Tools: a subset of this package. API key, webhook, boost, credit, and reddit engagement tools are only available through the npm package.
121
+ - Every tool is annotated by class: `read` (`readOnlyHint: true`), `write`, or `sensitive` (`destructiveHint: true`: it publishes, schedules, or deletes on a connected platform, or deletes a launch or persona with no restore). The class of each hosted tool is listed at https://www.dropspace.dev/docs#mcp-hosted, and the source of truth is `src/tool-policy.ts`.
122
+ - `create_launch` sends a content-derived `Idempotency-Key`, so a retry after a timeout returns the launch already created instead of creating a second one (the result says so).
123
+ - Each MCP request counts once against the key's rate limit, and each tool call counts once more when it reaches the REST API.
124
+
110
125
  ## Available Tools
111
126
 
112
127
  ### Launches
@@ -139,7 +154,7 @@ Add to your `.cursor/mcp.json`:
139
154
  | `dropspace_platforms` | string[] | No | Platforms to post via official dropspace accounts |
140
155
  | `media_mode` | string | No | `"images"` or `"video"` |
141
156
  | `user_platform_accounts` | object | No | Map of platform key to token_id (UUID). Simple keys: `twitter`, `reddit`, `instagram`, `tiktok`, `youtube`. LinkedIn: `linkedin:personal` or `linkedin:organization:<org_id>`. Facebook: a bare `facebook` key works when you have exactly one Page (send any connection id from `connections list` as the value); to target a specific Page among several, use `facebook:page:<page_id>` or `facebook:<page_id>`, where `page_id` is the entity_id of that Page from `connections list`; several Pages with no `page_id` returns a 400 listing them |
142
- | `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) |
157
+ | `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). Instagram accepts `instagram_settings.post_type`: `"feed"` (default), `"story"`, or `"carousel"` (every attached image and the video as one carousel, 2 to 10 items) |
143
158
  | `custom_content` | string \| string[] | No | Single content for all platforms, or array of tweets for twitter thread mode (mutually exclusive with `platform_contents`) |
144
159
  | `custom_content_reddit_title` | string | No | Reddit title (required with `custom_content` + reddit) |
145
160
  | `media` | object[] | No | Inline media, URL or base64 (1-10 items, mutually exclusive with `media_assets`) |
@@ -154,7 +169,7 @@ Add to your `.cursor/mcp.json`:
154
169
  | `id` | string | Yes | Launch ID |
155
170
  | `scheduled_date` | string \| null | No | ISO 8601 datetime or null to clear |
156
171
  | `status` | string | No | `"draft"`, `"manual"`, `"trigger"`, `"scheduled"`, `"cancelled"` |
157
- | `platform_contents` | object | No | Per-platform content overrides |
172
+ | `platform_contents` | object | No | Per-platform content overrides (Instagram: `instagram_settings.post_type` `"feed"`, `"story"`, or `"carousel"`) |
158
173
  | `dropspace_platforms` | string[] | No | Official dropspace account platforms |
159
174
  | `user_platform_accounts` | object | No | Map of platform key to token_id (UUID). Simple keys: `twitter`, `reddit`, `instagram`, `tiktok`, `youtube`. LinkedIn: `linkedin:personal` or `linkedin:organization:<org_id>`. Facebook: a bare `facebook` key works when you have exactly one Page (send any connection id from `connections list` as the value); to target a specific Page among several, use `facebook:page:<page_id>` or `facebook:<page_id>`, where `page_id` is the entity_id of that Page from `connections list`; several Pages with no `page_id` returns a 400 listing them |
160
175
  | `media` | object[] | No | Inline media (URL or base64, 1-10 items) |
package/dist/client.d.ts CHANGED
@@ -1,12 +1,30 @@
1
+ export interface DropspaceClientOptions {
2
+ /** API key to send. Defaults to the DROPSPACE_API_KEY environment variable. */
3
+ apiKey?: string;
4
+ /** REST base URL. Defaults to DROPSPACE_API_URL, then https://api.dropspace.dev. */
5
+ baseUrl?: string;
6
+ }
1
7
  export declare class DropspaceClient {
2
8
  private apiKey;
3
9
  private baseUrl;
4
- constructor();
10
+ /** True when the base URL was injected; the network-error hint then names the URL, not an env var. */
11
+ private hasInjectedBaseUrl;
12
+ constructor(options?: DropspaceClientOptions);
5
13
  get<T>(path: string, params?: Record<string, string>): Promise<T>;
6
14
  post<T>(path: string, body?: unknown): Promise<T>;
7
15
  put<T>(path: string, body: unknown): Promise<T>;
8
16
  patch<T>(path: string, body: unknown): Promise<T>;
9
17
  delete(path: string): Promise<void>;
18
+ /**
19
+ * POST with an Idempotency-Key. The API answers a repeat of the same key within 24 hours
20
+ * with the original response and `Idempotent-Replayed: true`; `replayed` surfaces that so
21
+ * the caller can say "nothing new was created".
22
+ */
23
+ postIdempotent<T>(path: string, body: unknown, idempotencyKey: string): Promise<{
24
+ result: T;
25
+ replayed: boolean;
26
+ }>;
10
27
  private request;
28
+ private send;
11
29
  private handleError;
12
30
  }
package/dist/client.js CHANGED
@@ -2,8 +2,10 @@ const REQUEST_TIMEOUT_MS = 30_000;
2
2
  export class DropspaceClient {
3
3
  apiKey;
4
4
  baseUrl;
5
- constructor() {
6
- const apiKey = process.env.DROPSPACE_API_KEY;
5
+ /** True when the base URL was injected; the network-error hint then names the URL, not an env var. */
6
+ hasInjectedBaseUrl;
7
+ constructor(options = {}) {
8
+ const apiKey = options.apiKey ?? process.env.DROPSPACE_API_KEY;
7
9
  if (!apiKey) {
8
10
  throw new Error("DROPSPACE_API_KEY environment variable is required");
9
11
  }
@@ -11,7 +13,8 @@ export class DropspaceClient {
11
13
  throw new Error("DROPSPACE_API_KEY must start with 'ds_live_'. Get one at https://www.dropspace.dev/settings");
12
14
  }
13
15
  this.apiKey = apiKey;
14
- this.baseUrl = (process.env.DROPSPACE_API_URL || "https://api.dropspace.dev").replace(/\/$/, "");
16
+ this.hasInjectedBaseUrl = Boolean(options.baseUrl);
17
+ this.baseUrl = (options.baseUrl || process.env.DROPSPACE_API_URL || "https://api.dropspace.dev").replace(/\/$/, "");
15
18
  }
16
19
  async get(path, params) {
17
20
  const url = new URL(`${this.baseUrl}${path}`);
@@ -63,7 +66,27 @@ export class DropspaceClient {
63
66
  throw error;
64
67
  }
65
68
  }
69
+ /**
70
+ * POST with an Idempotency-Key. The API answers a repeat of the same key within 24 hours
71
+ * with the original response and `Idempotent-Replayed: true`; `replayed` surfaces that so
72
+ * the caller can say "nothing new was created".
73
+ */
74
+ async postIdempotent(path, body, idempotencyKey) {
75
+ const response = await this.send(`${this.baseUrl}${path}`, {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey },
78
+ body: JSON.stringify(body),
79
+ });
80
+ return {
81
+ result: (await response.json()),
82
+ replayed: response.headers.get("Idempotent-Replayed") === "true",
83
+ };
84
+ }
66
85
  async request(url, init) {
86
+ const response = await this.send(url, init);
87
+ return (await response.json());
88
+ }
89
+ async send(url, init) {
67
90
  let response;
68
91
  try {
69
92
  response = await fetch(url, {
@@ -80,14 +103,16 @@ export class DropspaceClient {
80
103
  throw new Error("request timed out after 30s");
81
104
  }
82
105
  if (error instanceof TypeError) {
83
- throw new Error(`network error: ${error.message}. check DROPSPACE_API_URL and your connection`);
106
+ throw new Error(this.hasInjectedBaseUrl
107
+ ? `network error: ${error.message} reaching ${this.baseUrl}`
108
+ : `network error: ${error.message}. check DROPSPACE_API_URL and your connection`);
84
109
  }
85
110
  throw error;
86
111
  }
87
112
  if (!response.ok) {
88
113
  await this.handleError(response);
89
114
  }
90
- return (await response.json());
115
+ return response;
91
116
  }
92
117
  async handleError(response) {
93
118
  let errorMessage;
@@ -31,7 +31,7 @@ export function registerLaunchCommands(program, client) {
31
31
  .option("--persona <id>", "persona ID for tone/style")
32
32
  .option("--dropspace-platforms <list>", "platforms for official dropspace accounts", parseList)
33
33
  .option("--media-mode <mode>", "media mode: images or video")
34
- .option("--platform-contents <json>", 'per-platform content as JSON string. reddit supports metadata.subreddits (string[], max 10, no r/ prefix — one post per subreddit) and metadata.first_comment (max 1500). e.g. \'{"reddit":{"title":"...","content":"...","metadata":{"subreddits":["SideProject"],"first_comment":"..."}}}\'')
34
+ .option("--platform-contents <json>", 'per-platform content as JSON string. reddit supports metadata.subreddits (string[], max 10, no r/ prefix — one post per subreddit) and metadata.first_comment (max 1500). instagram supports instagram_settings.post_type: feed, story, or carousel (every attached image and the video as one carousel, 2 to 10 items). e.g. \'{"reddit":{"title":"...","content":"...","metadata":{"subreddits":["SideProject"],"first_comment":"..."}}}\'')
35
35
  .option("--custom-content <text>", "single content string for all platforms")
36
36
  .option("--reddit-title <title>", "reddit title (with custom-content)")
37
37
  .option("--user-accounts <json>", 'platform→token_id map as JSON. simple key = primary account ("instagram"); composite key targets a specific account when you have several ("instagram:<account_id>", ids from `connections list`) - works for instagram/tiktok/youtube/twitter/reddit. facebook: bare "facebook" for your one Page, or "facebook:page:<page_id>" / "facebook:<page_id>" for a specific one (id from `connections list`); several Pages and no page_id returns a 400. linkedin: "linkedin:personal" or "linkedin:organization:<org_id>"')
@@ -101,7 +101,7 @@ export function registerLaunchCommands(program, client) {
101
101
  .option("--schedule <datetime>", "ISO 8601 schedule datetime (empty to clear)")
102
102
  .option("--status <status>", "status: draft, manual, trigger, scheduled, cancelled")
103
103
  .option("--persona <id>", "persona ID (empty to clear)")
104
- .option("--platform-contents <json>", 'per-platform content as JSON string. reddit supports metadata.subreddits (string[], max 10, no r/ prefix — one post per subreddit) and metadata.first_comment (max 1500). e.g. \'{"reddit":{"title":"...","content":"...","metadata":{"subreddits":["SideProject"],"first_comment":"..."}}}\'')
104
+ .option("--platform-contents <json>", 'per-platform content as JSON string. reddit supports metadata.subreddits (string[], max 10, no r/ prefix — one post per subreddit) and metadata.first_comment (max 1500). instagram supports instagram_settings.post_type: feed, story, or carousel (every attached image and the video as one carousel, 2 to 10 items). e.g. \'{"reddit":{"title":"...","content":"...","metadata":{"subreddits":["SideProject"],"first_comment":"..."}}}\'')
105
105
  .option("--dropspace-platforms <list>", "official dropspace account platforms", parseList)
106
106
  .option("--attribution-mode <mode>", 'how official-account posts credit you: "handle" (default) or "anonymous"')
107
107
  .option("--user-accounts <json>", 'platform→token_id map as JSON. simple key = primary account ("instagram"); composite key targets a specific account when you have several ("instagram:<account_id>", ids from `connections list`) - works for instagram/tiktok/youtube/twitter/reddit. facebook: bare "facebook" for your one Page, or "facebook:page:<page_id>" / "facebook:<page_id>" for a specific one (id from `connections list`); several Pages and no page_id returns a 400. linkedin: "linkedin:personal" or "linkedin:organization:<org_id>"')
@@ -0,0 +1,15 @@
1
+ export interface HostedMcpOptions {
2
+ /** The caller's dropspace API key. The host authenticates it before calling in. */
3
+ apiKey: string;
4
+ /** REST base URL the tools call, e.g. https://api.dropspace.dev */
5
+ baseUrl: string;
6
+ /** Reported as serverInfo.version */
7
+ version: string;
8
+ }
9
+ /**
10
+ * Serve one MCP request over HTTP. Stateless: a fresh server and transport per request,
11
+ * no sessions, JSON responses - so it runs on serverless functions where consecutive
12
+ * requests from one client can land on different instances. Only POST is meaningful;
13
+ * the host must not route GET here (a stateless GET opens an event stream that never ends).
14
+ */
15
+ export declare function handleHostedMcpRequest(request: Request, options: HostedMcpOptions): Promise<Response>;
package/dist/hosted.js ADDED
@@ -0,0 +1,19 @@
1
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
2
+ import { DropspaceClient } from "./client.js";
3
+ import { createDropspaceServer } from "./server.js";
4
+ /**
5
+ * Serve one MCP request over HTTP. Stateless: a fresh server and transport per request,
6
+ * no sessions, JSON responses - so it runs on serverless functions where consecutive
7
+ * requests from one client can land on different instances. Only POST is meaningful;
8
+ * the host must not route GET here (a stateless GET opens an event stream that never ends).
9
+ */
10
+ export async function handleHostedMcpRequest(request, options) {
11
+ const client = new DropspaceClient({ apiKey: options.apiKey, baseUrl: options.baseUrl });
12
+ const server = createDropspaceServer({ client, version: options.version, profile: "hosted" });
13
+ const transport = new WebStandardStreamableHTTPServerTransport({
14
+ sessionIdGenerator: undefined,
15
+ enableJsonResponse: true,
16
+ });
17
+ await server.connect(transport);
18
+ return transport.handleRequest(request);
19
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A content-derived Idempotency-Key for create calls.
3
+ *
4
+ * The REST API dedupes a request that repeats a key within 24 hours (per key + endpoint)
5
+ * and answers with the original response plus `Idempotent-Replayed: true`. Deriving the
6
+ * key from the arguments means a retry after a timeout - by an agent, or by a person
7
+ * pressing "try again" in an MCP client - returns the launch that was already created
8
+ * instead of creating a second one.
9
+ */
10
+ export declare function contentIdempotencyKey(args: unknown): Promise<string>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A content-derived Idempotency-Key for create calls.
3
+ *
4
+ * The REST API dedupes a request that repeats a key within 24 hours (per key + endpoint)
5
+ * and answers with the original response plus `Idempotent-Replayed: true`. Deriving the
6
+ * key from the arguments means a retry after a timeout - by an agent, or by a person
7
+ * pressing "try again" in an MCP client - returns the launch that was already created
8
+ * instead of creating a second one.
9
+ */
10
+ function stable(value) {
11
+ if (Array.isArray(value))
12
+ return value.map(stable);
13
+ if (value !== null && typeof value === "object") {
14
+ const source = value;
15
+ const sorted = {};
16
+ for (const key of Object.keys(source).sort()) {
17
+ if (source[key] !== undefined)
18
+ sorted[key] = stable(source[key]);
19
+ }
20
+ return sorted;
21
+ }
22
+ return value;
23
+ }
24
+ export async function contentIdempotencyKey(args) {
25
+ const bytes = new TextEncoder().encode(JSON.stringify(stable(args)));
26
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
27
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
28
+ }
package/dist/index.js CHANGED
@@ -1,42 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
4
  import { DropspaceClient } from "./client.js";
6
- import { registerLaunchTools } from "./tools/launches.js";
7
- import { registerPersonaTools } from "./tools/personas.js";
8
- import { registerConnectionTools } from "./tools/connections.js";
9
- import { registerKeyTools } from "./tools/keys.js";
10
- import { registerWebhookTools } from "./tools/webhooks.js";
11
- import { registerDropspaceTools } from "./tools/dropspace.js";
12
- import { registerUsageTools } from "./tools/usage.js";
13
- import { registerCreditTools } from "./tools/credits.js";
14
- import { registerMediaTools } from "./tools/media.js";
15
- import { registerBoostTools } from "./tools/boosts.js";
16
- import { registerAutopilotTools } from "./tools/autopilot.js";
17
- import { registerRecommendationTools } from "./tools/recommendations.js";
18
- import { registerRedditTools } from "./tools/reddit.js";
19
- import { registerAudienceTools } from "./tools/audience.js";
5
+ import { createDropspaceServer } from "./server.js";
20
6
  const requireFromHere = createRequire(import.meta.url);
21
7
  const pkg = requireFromHere("../package.json");
22
- const server = new McpServer({
23
- name: "dropspace",
24
- version: pkg.version,
25
- });
26
- const client = new DropspaceClient();
27
- registerLaunchTools(server, client);
28
- registerPersonaTools(server, client);
29
- registerConnectionTools(server, client);
30
- registerKeyTools(server, client);
31
- registerWebhookTools(server, client);
32
- registerDropspaceTools(server, client);
33
- registerUsageTools(server, client);
34
- registerCreditTools(server, client);
35
- registerMediaTools(server, client);
36
- registerBoostTools(server, client);
37
- registerAutopilotTools(server, client);
38
- registerRecommendationTools(server, client);
39
- registerRedditTools(server, client);
40
- registerAudienceTools(server, client);
8
+ const server = createDropspaceServer({ client: new DropspaceClient(), version: pkg.version, profile: "full" });
41
9
  const transport = new StdioServerTransport();
42
10
  await server.connect(transport);
@@ -0,0 +1,10 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DropspaceClient } from "./client.js";
3
+ import { type ToolProfile } from "./tool-policy.js";
4
+ export interface CreateDropspaceServerOptions {
5
+ client: DropspaceClient;
6
+ version: string;
7
+ /** "full" serves every tool (stdio). "hosted" serves the tools marked hosted in tool-policy.ts. */
8
+ profile: ToolProfile;
9
+ }
10
+ export declare function createDropspaceServer({ client, version, profile }: CreateDropspaceServerOptions): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,44 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { isToolEnabled, toolAnnotations } from "./tool-policy.js";
3
+ import { registerLaunchTools } from "./tools/launches.js";
4
+ import { registerPersonaTools } from "./tools/personas.js";
5
+ import { registerConnectionTools } from "./tools/connections.js";
6
+ import { registerKeyTools } from "./tools/keys.js";
7
+ import { registerWebhookTools } from "./tools/webhooks.js";
8
+ import { registerDropspaceTools } from "./tools/dropspace.js";
9
+ import { registerUsageTools } from "./tools/usage.js";
10
+ import { registerCreditTools } from "./tools/credits.js";
11
+ import { registerMediaTools } from "./tools/media.js";
12
+ import { registerBoostTools } from "./tools/boosts.js";
13
+ import { registerAutopilotTools } from "./tools/autopilot.js";
14
+ import { registerRecommendationTools } from "./tools/recommendations.js";
15
+ import { registerRedditTools } from "./tools/reddit.js";
16
+ import { registerAudienceTools } from "./tools/audience.js";
17
+ export function createDropspaceServer({ client, version, profile }) {
18
+ const server = new McpServer({ name: "dropspace", version });
19
+ const registerAnnotated = server.tool.bind(server);
20
+ // Every register*Tools function calls server.tool(name, description, schema, handler).
21
+ // This stand-in is the one place a tool is filtered by profile and given its annotations.
22
+ const registrar = {
23
+ tool(name, description, schema, handler) {
24
+ if (!isToolEnabled(name, profile))
25
+ return;
26
+ registerAnnotated(name, description, schema, toolAnnotations(name), handler);
27
+ },
28
+ };
29
+ registerLaunchTools(registrar, client);
30
+ registerPersonaTools(registrar, client);
31
+ registerConnectionTools(registrar, client);
32
+ registerKeyTools(registrar, client);
33
+ registerWebhookTools(registrar, client);
34
+ registerDropspaceTools(registrar, client);
35
+ registerUsageTools(registrar, client);
36
+ registerCreditTools(registrar, client);
37
+ registerMediaTools(registrar, client);
38
+ registerBoostTools(registrar, client);
39
+ registerAutopilotTools(registrar, client);
40
+ registerRecommendationTools(registrar, client);
41
+ registerRedditTools(registrar, client);
42
+ registerAudienceTools(registrar, client);
43
+ return server;
44
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The one place a tool is classified.
3
+ *
4
+ * class:
5
+ * read - retrieves information; changes nothing.
6
+ * write - changes dropspace data only, reversibly; nothing leaves dropspace.
7
+ * sensitive - publishes, schedules, or deletes on a social platform, deletes a record
8
+ * inside dropspace with no user-facing restore, spends money, handles
9
+ * credentials, or switches on something that will publish later.
10
+ *
11
+ * hosted: whether the hosted endpoint (https://api.dropspace.dev/mcp) serves the tool.
12
+ * The stdio server serves every tool. Left off hosted: credits and boosts (payments and
13
+ * ad spend), API keys and webhooks (developer plumbing and credentials), and reddit
14
+ * engagement.
15
+ *
16
+ * category: the documentation grouping. Every tool count and category list in the docs
17
+ * page, llms.txt, and llms-full.txt is computed from this table.
18
+ *
19
+ * Registering a tool with no entry here throws, and __tests__/server.test.ts fails on
20
+ * any mismatch between this table and what the package registers.
21
+ */
22
+ export type ToolClass = "read" | "write" | "sensitive";
23
+ export type ToolProfile = "full" | "hosted";
24
+ export declare const TOOL_CATEGORIES: readonly ["launches", "posts", "personas", "media", "connections", "boosts", "credits", "API keys", "webhooks", "dropspace", "usage", "autopilot", "recommendations", "reddit engagement", "audience"];
25
+ export type ToolCategory = (typeof TOOL_CATEGORIES)[number];
26
+ export interface ToolPolicy {
27
+ class: ToolClass;
28
+ hosted: boolean;
29
+ category: ToolCategory;
30
+ }
31
+ export declare const TOOL_POLICY: Record<string, ToolPolicy>;
32
+ export declare function toolPolicy(name: string): ToolPolicy;
33
+ export declare function isToolEnabled(name: string, profile: ToolProfile): boolean;
34
+ export declare function toolAnnotations(name: string): {
35
+ readOnlyHint: boolean;
36
+ destructiveHint: boolean;
37
+ };
38
+ /** Tool names in a category, in table order. */
39
+ export declare function toolsInCategory(category: ToolCategory): string[];
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The one place a tool is classified.
3
+ *
4
+ * class:
5
+ * read - retrieves information; changes nothing.
6
+ * write - changes dropspace data only, reversibly; nothing leaves dropspace.
7
+ * sensitive - publishes, schedules, or deletes on a social platform, deletes a record
8
+ * inside dropspace with no user-facing restore, spends money, handles
9
+ * credentials, or switches on something that will publish later.
10
+ *
11
+ * hosted: whether the hosted endpoint (https://api.dropspace.dev/mcp) serves the tool.
12
+ * The stdio server serves every tool. Left off hosted: credits and boosts (payments and
13
+ * ad spend), API keys and webhooks (developer plumbing and credentials), and reddit
14
+ * engagement.
15
+ *
16
+ * category: the documentation grouping. Every tool count and category list in the docs
17
+ * page, llms.txt, and llms-full.txt is computed from this table.
18
+ *
19
+ * Registering a tool with no entry here throws, and __tests__/server.test.ts fails on
20
+ * any mismatch between this table and what the package registers.
21
+ */
22
+ export const TOOL_CATEGORIES = [
23
+ "launches",
24
+ "posts",
25
+ "personas",
26
+ "media",
27
+ "connections",
28
+ "boosts",
29
+ "credits",
30
+ "API keys",
31
+ "webhooks",
32
+ "dropspace",
33
+ "usage",
34
+ "autopilot",
35
+ "recommendations",
36
+ "reddit engagement",
37
+ "audience",
38
+ ];
39
+ const hosted = (category, toolClass) => ({
40
+ class: toolClass,
41
+ hosted: true,
42
+ category,
43
+ });
44
+ const localOnly = (category, toolClass) => ({
45
+ class: toolClass,
46
+ hosted: false,
47
+ category,
48
+ });
49
+ export const TOOL_POLICY = {
50
+ // launches
51
+ list_launches: hosted("launches", "read"),
52
+ get_launch: hosted("launches", "read"),
53
+ get_launch_status: hosted("launches", "read"),
54
+ get_launch_analytics: hosted("launches", "read"),
55
+ get_batch_launch_analytics: hosted("launches", "read"),
56
+ // create and update accept `publish` / `scheduled_date`, so either can put a post out
57
+ create_launch: hosted("launches", "sensitive"),
58
+ update_launch: hosted("launches", "sensitive"),
59
+ // a soft delete with no user-facing restore
60
+ delete_launch: hosted("launches", "sensitive"),
61
+ generate_content: hosted("launches", "write"),
62
+ retry_launch_content: hosted("launches", "write"),
63
+ publish_launch: hosted("launches", "sensitive"),
64
+ retry_launch: hosted("launches", "sensitive"),
65
+ // posts
66
+ delete_post: hosted("posts", "sensitive"),
67
+ delete_all_posts: hosted("posts", "sensitive"),
68
+ // personas
69
+ list_personas: hosted("personas", "read"),
70
+ get_persona: hosted("personas", "read"),
71
+ create_persona: hosted("personas", "write"),
72
+ update_persona: hosted("personas", "write"),
73
+ // a soft delete with no user-facing restore
74
+ delete_persona: hosted("personas", "sensitive"),
75
+ analyze_persona: hosted("personas", "write"),
76
+ finalize_persona: hosted("personas", "write"),
77
+ discard_persona_analysis: hosted("personas", "write"),
78
+ // media
79
+ generate_media: hosted("media", "write"),
80
+ get_media_job: hosted("media", "read"),
81
+ // connections
82
+ list_connections: hosted("connections", "read"),
83
+ // edits the profile ON the platform (youtube channel description)
84
+ update_connection_profile: hosted("connections", "sensitive"),
85
+ // autopilot
86
+ get_autopilot: hosted("autopilot", "read"),
87
+ list_autopilot_review: hosted("autopilot", "read"),
88
+ list_autopilot_runs: hosted("autopilot", "read"),
89
+ // deletes the draft launch with no restore
90
+ reject_autopilot_draft: hosted("autopilot", "sensitive"),
91
+ // enabling autopilot leads to posts with no further per-post step
92
+ configure_autopilot: hosted("autopilot", "sensitive"),
93
+ approve_autopilot_draft: hosted("autopilot", "sensitive"),
94
+ run_autopilot_now: hosted("autopilot", "sensitive"),
95
+ // recommendations
96
+ list_recommendations: hosted("recommendations", "read"),
97
+ // creates a DRAFT launch or a PAUSED boost; nothing publishes or spends
98
+ apply_recommendation: hosted("recommendations", "write"),
99
+ dismiss_recommendation: hosted("recommendations", "write"),
100
+ // audience, usage, status
101
+ get_audience: hosted("audience", "read"),
102
+ get_usage: hosted("usage", "read"),
103
+ get_changelog: hosted("usage", "read"),
104
+ get_dropspace_status: hosted("dropspace", "read"),
105
+ // reddit engagement
106
+ suggest_subreddits: localOnly("reddit engagement", "read"),
107
+ get_engagement_config: localOnly("reddit engagement", "read"),
108
+ get_engagement_standing: localOnly("reddit engagement", "read"),
109
+ list_engagement_comments: localOnly("reddit engagement", "read"),
110
+ update_engagement_config: localOnly("reddit engagement", "write"),
111
+ discover_engagement: localOnly("reddit engagement", "write"),
112
+ reject_engagement_comment: localOnly("reddit engagement", "write"),
113
+ activate_engagement_subreddit: localOnly("reddit engagement", "write"),
114
+ deactivate_engagement_subreddit: localOnly("reddit engagement", "write"),
115
+ approve_engagement_comment: localOnly("reddit engagement", "sensitive"),
116
+ // credits
117
+ get_credit_balance: localOnly("credits", "read"),
118
+ purchase_credit_pack: localOnly("credits", "sensitive"),
119
+ // boosts
120
+ list_boosts: localOnly("boosts", "read"),
121
+ get_boost: localOnly("boosts", "read"),
122
+ get_boost_performance: localOnly("boosts", "read"),
123
+ create_boost: localOnly("boosts", "write"),
124
+ activate_boost: localOnly("boosts", "sensitive"),
125
+ cancel_boost: localOnly("boosts", "sensitive"),
126
+ // api keys
127
+ get_current_api_key: localOnly("API keys", "read"),
128
+ list_api_keys: localOnly("API keys", "read"),
129
+ rename_api_key: localOnly("API keys", "write"),
130
+ create_api_key: localOnly("API keys", "sensitive"),
131
+ revoke_api_key: localOnly("API keys", "sensitive"),
132
+ rotate_api_key: localOnly("API keys", "sensitive"),
133
+ // webhooks
134
+ list_webhooks: localOnly("webhooks", "read"),
135
+ get_webhook: localOnly("webhooks", "read"),
136
+ list_webhook_deliveries: localOnly("webhooks", "read"),
137
+ create_webhook: localOnly("webhooks", "write"),
138
+ update_webhook: localOnly("webhooks", "write"),
139
+ // a hard delete with no restore
140
+ delete_webhook: localOnly("webhooks", "sensitive"),
141
+ test_webhook: localOnly("webhooks", "write"),
142
+ rotate_webhook_secret: localOnly("webhooks", "sensitive"),
143
+ };
144
+ export function toolPolicy(name) {
145
+ const policy = TOOL_POLICY[name];
146
+ if (!policy) {
147
+ throw new Error(`tool "${name}" has no entry in tool-policy.ts - classify it before registering`);
148
+ }
149
+ return policy;
150
+ }
151
+ export function isToolEnabled(name, profile) {
152
+ const policy = toolPolicy(name);
153
+ return profile === "full" || policy.hosted;
154
+ }
155
+ export function toolAnnotations(name) {
156
+ const toolClass = toolPolicy(name).class;
157
+ return { readOnlyHint: toolClass === "read", destructiveHint: toolClass === "sensitive" };
158
+ }
159
+ /** Tool names in a category, in table order. */
160
+ export function toolsInCategory(category) {
161
+ return Object.keys(TOOL_POLICY).filter((name) => TOOL_POLICY[name].category === category);
162
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { contentIdempotencyKey } from "../idempotency.js";
2
3
  // 11 enum values but only 10 distinct platforms — linkedin-community is a LinkedIn variant, not a separate platform.
3
4
  // auto-post (7): facebook, linkedin, twitter, reddit, instagram, tiktok, youtube
4
5
  // guided (3): substack, producthunt, hackernews
@@ -110,7 +111,7 @@ export function registerLaunchTools(server, client) {
110
111
  platform_contents: z
111
112
  .record(z.string(), z.record(z.string(), z.unknown()))
112
113
  .optional()
113
- .describe('per-platform content — each value needs "content" (string). Reddit also requires "title" (max 300) and supports "metadata": { "subreddits": string[] (max 10, names without the r/ prefix — posts the launch once per subreddit, each tracked and retryable on its own; omit to post to the default community), "first_comment": string (max 1500 — posted as a comment under each reddit post once it goes live) }. for multi-account reddit, each connected account can target its own subreddits via "reddit:<account_id>" content keys (falls back to the base "reddit" block); a subreddit is posted by at most one of your accounts per launch (first wins, others skipped as SUBREDDIT_ALREADY_TARGETED). each subreddit has its own rules: many restrict or ban promotional posts and posting where the account has no history gets filtered, so target a few genuinely relevant subreddits rather than blasting. Twitter supports "thread" (string[], max 6, each ≤280 or ≤25,000 for X Premium) instead of "content". TikTok supports "tiktok_settings": { "publish_mode"?: "direct"|"draft" (default "direct"; "draft" uploads to the TikTok inbox to finish and publish in-app), "privacy_level": "PUBLIC_TO_EVERYONE"|"FOLLOWER_OF_CREATOR"|"MUTUAL_FOLLOW_FRIENDS"|"SELF_ONLY" (required before publish in direct mode; not required for draft), "allow_comments"?, "allow_duet"?, "allow_stitch"?, "is_commercial"?, "is_your_brand"?, "is_branded_content"?, "auto_add_music"? (booleans, direct mode), "cover_timestamp_ms"? (integer 0-600000 — which frame TikTok uses as the cover, in ms from the start; VIDEO posts only, photo posts take their cover by index; omit for the first frame) }. YouTube requires video and supports "title" (max 100 chars). character limits: Twitter 280/tweet (25,000 for X Premium), LinkedIn 3000, Instagram 2200, Reddit 3000, Facebook 3000, TikTok 4000, YouTube 5000 (description). X Premium status is auto-detected from the connected account. mutually exclusive with custom_content'),
114
+ .describe('per-platform content — each value needs "content" (string). Instagram takes "instagram_settings": { "post_type": "feed" (default) | "carousel" | "story" } and Facebook takes "facebook_settings": { "post_type": "feed" | "story" } - "carousel" (Instagram only) posts every attached image and the launch video as ONE carousel in media order, 2 to 10 items, where "feed" would post the video as a reel and leave the images out; "story" posts each image as a 24-hour story frame with no caption. Reddit also requires "title" (max 300) and supports "metadata": { "subreddits": string[] (max 10, names without the r/ prefix — posts the launch once per subreddit, each tracked and retryable on its own; omit to post to the default community), "first_comment": string (max 1500 — posted as a comment under each reddit post once it goes live) }. for multi-account reddit, each connected account can target its own subreddits via "reddit:<account_id>" content keys (falls back to the base "reddit" block); a subreddit is posted by at most one of your accounts per launch (first wins, others skipped as SUBREDDIT_ALREADY_TARGETED). each subreddit has its own rules: many restrict or ban promotional posts and posting where the account has no history gets filtered, so target a few genuinely relevant subreddits rather than blasting. Twitter supports "thread" (string[], max 6, each ≤280 or ≤25,000 for X Premium) instead of "content". TikTok supports "tiktok_settings": { "publish_mode"?: "direct"|"draft" (default "direct"; "draft" uploads to the TikTok inbox to finish and publish in-app), "privacy_level": "PUBLIC_TO_EVERYONE"|"FOLLOWER_OF_CREATOR"|"MUTUAL_FOLLOW_FRIENDS"|"SELF_ONLY" (required before publish in direct mode; not required for draft), "allow_comments"?, "allow_duet"?, "allow_stitch"?, "is_commercial"?, "is_your_brand"?, "is_branded_content"?, "auto_add_music"? (booleans, direct mode), "cover_timestamp_ms"? (integer 0-600000 — which frame TikTok uses as the cover, in ms from the start; VIDEO posts only, photo posts take their cover by index; omit for the first frame) }. YouTube requires video and supports "title" (max 100 chars). character limits: Twitter 280/tweet (25,000 for X Premium), LinkedIn 3000, Instagram 2200, Reddit 3000, Facebook 3000, TikTok 4000, YouTube 5000 (description). X Premium status is auto-detected from the connected account. mutually exclusive with custom_content'),
114
115
  custom_content: z
115
116
  .union([z.string().min(1), z.array(z.string()).min(1).max(6)])
116
117
  .optional()
@@ -161,9 +162,20 @@ export function registerLaunchTools(server, client) {
161
162
  };
162
163
  }
163
164
  try {
164
- const result = await client.post("/launches", args);
165
+ // A retry after a timeout (by an agent, or a person pressing "try again") must not
166
+ // create a second launch: the key is derived from the arguments, so the API replays
167
+ // the launch it already created for them.
168
+ const { result, replayed } = await client.postIdempotent("/launches", args, await contentIdempotencyKey(args));
169
+ const text = JSON.stringify(result, null, 2);
165
170
  return {
166
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
171
+ content: [
172
+ {
173
+ type: "text",
174
+ text: replayed
175
+ ? `note: an identical launch was created within the last 24 hours, so dropspace returned it - no new launch was created.\n${text}`
176
+ : text,
177
+ },
178
+ ],
167
179
  };
168
180
  }
169
181
  catch (error) {
@@ -224,7 +236,7 @@ export function registerLaunchTools(server, client) {
224
236
  platform_contents: z
225
237
  .record(z.string(), z.record(z.string(), z.unknown()))
226
238
  .optional()
227
- .describe('per-platform content update (deep-merged with existing). each value can include "content" (string). Twitter supports "thread" (string[]) instead. Reddit "title" is optional on update, and "metadata" accepts "subreddits" (string[], max 10, no r/ prefix — one post per subreddit) and "first_comment" (string, max 1500). TikTok "tiktok_settings" is deep-merged — you can update individual fields (e.g. just "privacy_level") without overwriting the rest. see create_launch for full field details'),
239
+ .describe('per-platform content update (deep-merged with existing). each value can include "content" (string). Instagram takes "instagram_settings": { "post_type": "feed" | "carousel" | "story" } ("carousel" posts every attached image and the launch video as ONE carousel, 2 to 10 items) and Facebook takes "facebook_settings": { "post_type": "feed" | "story" }. Twitter supports "thread" (string[]) instead. Reddit "title" is optional on update, and "metadata" accepts "subreddits" (string[], max 10, no r/ prefix — one post per subreddit) and "first_comment" (string, max 1500). TikTok "tiktok_settings" is deep-merged — you can update individual fields (e.g. just "privacy_level") without overwriting the rest. see create_launch for full field details'),
228
240
  dropspace_platforms: z.array(z.enum(PLATFORMS)).optional().describe("official dropspace account platforms"),
229
241
  attribution_mode: z
230
242
  .enum(["handle", "anonymous"])
@@ -381,7 +393,7 @@ export function registerLaunchTools(server, client) {
381
393
  };
382
394
  }
383
395
  });
384
- server.tool("get_launch_analytics", "get publishing analytics with per-post engagement metrics (likes, views, shares, etc.). metric fields vary by platform: facebook reports reactions, likes (mirroring reactions.total), comments and shares - it has no impressions or reach, and facebook story metrics are unavailable", {
396
+ server.tool("get_launch_analytics", "get publishing analytics with per-post engagement metrics (likes, views, shares, etc.). metric fields vary by platform: facebook reports reactions, likes (mirroring reactions.total), comments and shares - it has no impressions or reach, and facebook story metrics are unavailable. an instagram story reports views, reach, replies and frames only while it is live (24 hours, no backfill); under 5 viewers it returns story_insights_withheld instead of numbers", {
385
397
  id: z.string().uuid().describe("launch ID"),
386
398
  }, async ({ id }) => {
387
399
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jclvsh/dropspace",
3
- "version": "2.22.2",
3
+ "version": "2.24.0",
4
4
  "description": "MCP server and CLI for the Dropspace API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,21 @@
10
10
  "type": "module",
11
11
  "main": "dist/index.js",
12
12
  "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./hosted": {
19
+ "types": "./dist/hosted.d.ts",
20
+ "default": "./dist/hosted.js"
21
+ },
22
+ "./tool-policy": {
23
+ "types": "./dist/tool-policy.d.ts",
24
+ "default": "./dist/tool-policy.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
13
28
  "bin": {
14
29
  "dropspace-mcp": "dist/index.js",
15
30
  "dropspace": "dist/cli.js"
@@ -20,17 +35,21 @@
20
35
  ],
21
36
  "scripts": {
22
37
  "build": "tsc",
38
+ "prepare": "tsc",
23
39
  "prepublishOnly": "npm run build",
24
- "start": "node dist/index.js"
40
+ "start": "node dist/index.js",
41
+ "typecheck": "tsc -p tsconfig.test.json",
42
+ "test": "vitest run"
25
43
  },
26
44
  "dependencies": {
27
- "@modelcontextprotocol/sdk": "^1.11.0",
45
+ "@modelcontextprotocol/sdk": "^1.26.0",
28
46
  "commander": "^14.0.3",
29
47
  "zod": "^3.24.0"
30
48
  },
31
49
  "devDependencies": {
32
50
  "@types/node": "^22.0.0",
33
- "typescript": "^5.7.0"
51
+ "typescript": "^5.7.0",
52
+ "vitest": "^4.0.18"
34
53
  },
35
54
  "engines": {
36
55
  "node": ">=18"