Skip to content

Commit 69ef2ec

Browse files
AzymAzym
authored andcommitted
feat: Tumblr platform + platform availability (node 1.6.0, mcp 1.9.0, python 0.7.0)
Syncs the SDKs and docs to the webapp contract changes. Tumblr (15th platform): - `tumblr` added to every platform enum — openapi Channel + PlatformAvailability schemas, node Platform union, MCP PLATFORM_ENUM / platformSpecific / platformContent, postman. - platformSpecific.tumblr is keyed by CHANNEL ID, not a flat object: one Tumblr account can own several blogs and each channel may target a different one. Documented in the platforms guide alongside the media rules (<=30 images OR exactly one video, never both) and the 4096-char NPF text-block split. Platform availability: - GET /api/platforms across openapi, postman, node (`bp.platforms.list()`), python (`bp.channels.list_platforms()`, sync + async) and a `list_platforms` MCP tool. Disabled platforms are INCLUDED with enabled:false, never omitted, so consumers can tell "off right now" from "not supported". - Channel gains platformAvailable / platformState / platformMessage. - PLATFORM_DISABLED (403) documented on post create, bulk create and publish, and explicitly contrasted with FEATURE_DISABLED — the first is temporary and self-resolving, the second needs a plan upgrade. Posts already scheduled when a platform is disabled are HELD, not failed; the guide tells integrators not to delete and recreate them. Also documented: - PATCH /api/posts/{id} was an undocumented route, which led at least one API client to use it as the update verb, silently no-op 90 drafts and conclude draft->scheduled was impossible. It is now in the spec as the narrow recurring-schedule endpoint, and points at PUT for status changes. - platformSpecific prose now covers Reddit and Discord option shapes, which were never documented when those platforms shipped. - README platform count corrected 14 -> 15 (node client JSDoc still said 11). - .gitignore now covers mcp-server build artifacts (.harness/, .smithery/). node + mcp-server build clean; python imports verified.
1 parent 6780d4c commit 69ef2ec

16 files changed

Lines changed: 726 additions & 158 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,7 @@ dist/
2727

2828
# MCP registry credentials — secrets, never commit
2929
.mcpregistry_*token
30+
31+
# Build/tooling artifacts
32+
mcp-server/.harness/
33+
mcp-server/.smithery/

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
# Changelog
22

3+
## 2026-07-25 — Tumblr platform + platform availability (mcp-server 1.9.0, node SDK 1.6.0, python SDK 0.7.0)
4+
5+
### Added
6+
7+
- **Tumblr is now supported — 15 platforms.** `tumblr` is a valid value everywhere `platform` appears (post channels, `platformContent`, `platformSpecific`, `postTypeOverrides`, the `Channel` schema). Post type is `post`; content limit 32,768 characters.
8+
- **`platformSpecific.tumblr` is keyed by channel ID**, because one Tumblr account can own several blogs and each connected channel may target a different one. Fields: `blogName` (defaults to the blog the channel was connected as), `title` (rendered as a heading), `tags` (array, no leading `#`), `link`, `sourceUrl`. List a channel's blogs with `GET /api/channels/{id}/options`.
9+
- Tumblr media rules: up to **30 images**, **or exactly one video** — a video cannot be mixed with images in the same post. Content over 4,096 characters is split across multiple Neue Post Format text blocks automatically.
10+
- **`GET /api/platforms`** — returns every supported platform with its current availability (`state`, `reason`, `canConnect`, `canPublish`, `message`). Disabled platforms are **included** with `enabled: false` rather than omitted, so callers can tell "switched off right now" from "not supported". `envVar` is returned only to organization owners/admins. Exposed as `bp.platforms.list()` (node), `bp.channels.list_platforms()` (python, sync + async), and the `list_platforms` MCP tool.
11+
- **`platformAvailable`, `platformState`, `platformMessage` on the `Channel` object** — a channel can be perfectly healthy while its platform is switched off server-side.
12+
- **`PLATFORM_DISABLED` (403)** on `POST /api/posts`, bulk create, and `POST /api/posts/{id}/publish` when a target platform is disabled server-side. Distinct from `FEATURE_DISABLED` (not on the org's plan): `PLATFORM_DISABLED` is temporary and resolves on its own, and posts already scheduled when a platform is disabled are **held, not failed** — they publish automatically once it returns. Do not delete and recreate them.
13+
- New "Platform Availability" section in the platforms guide covering the three states (`on` / `connect_off` / `off`) and both error codes.
14+
15+
### Documented
16+
17+
- **`PATCH /api/posts/{id}` is now in the spec** — it was previously an undocumented route. It only accepts `recurringScheduleId` (pass `null` to detach a repeat schedule); every other field is now rejected with 400 and `unsupportedFields`. **To edit a post — including moving a draft to `scheduled` — use `PUT /api/posts/{id}`** with `{"status": "scheduled", "scheduledAt": "<future ISO 8601>"}`. Previously PATCH silently ignored those fields and still returned 200, which read as success.
18+
- `platformSpecific` prose now documents the **Reddit** (`subreddit`, `title`, `flairId`, …) and **Discord** (`channelId`) option shapes, which were never described when those platforms landed.
19+
320
## 2026-07-24 — approval gating for automated sources (mcp-server 1.8.0, node SDK 1.5.0, python SDK 0.6.0)
421

522
### Added

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**The free social media API built for automation, AI agents, and LLMs.**
44

5-
Programmatically publish to 14 platforms from a single API call. Built for developers, AI agents, LLMs, and agentic workflows that need reliable social media automation without browser sessions or manual interaction.
5+
Programmatically publish to 15 platforms from a single API call. Built for developers, AI agents, LLMs, and agentic workflows that need reliable social media automation without browser sessions or manual interaction.
66

77
```python
88
from bulkpublish import BulkPublish
@@ -23,7 +23,7 @@ Most social media tools are built for humans clicking buttons. BulkPublish is bu
2323
- **Headless by design** — No browser, no UI, no OAuth pop-ups at runtime. Connect accounts once in the dashboard, then automate everything via API.
2424
- **AI-native** — MCP server for Claude, tool definitions for GPT and LangChain, structured JSON responses that LLMs parse reliably.
2525
- **Agentic-ready** — Deterministic API with clear error codes. AI agents can create posts, check status, retry failures, and read analytics autonomously.
26-
- **14 platforms, one endpoint** — Facebook, Instagram, X/Twitter, TikTok, YouTube, Threads, Bluesky, Pinterest, Google Business Profile, LinkedIn, Mastodon, Reddit, Discord, Telegram.
26+
- **15 platforms, one endpoint** — Facebook, Instagram, X/Twitter, TikTok, YouTube, Threads, Bluesky, Pinterest, Google Business Profile, LinkedIn, Mastodon, Reddit, Discord, Telegram, Tumblr.
2727

2828
## Use Cases
2929

@@ -214,7 +214,7 @@ More examples in [`examples/`](examples/) — including [Python automation scrip
214214

215215
## Features
216216

217-
- **14 platforms** — Facebook, Instagram, X/Twitter, TikTok, YouTube, Threads, Bluesky, Pinterest, Google Business Profile, LinkedIn, Mastodon, Reddit, Discord, Telegram
217+
- **15 platforms** — Facebook, Instagram, X/Twitter, TikTok, YouTube, Threads, Bluesky, Pinterest, Google Business Profile, LinkedIn, Mastodon, Reddit, Discord, Telegram, Tumblr
218218
- **Scheduling** — Schedule posts for any future time with timezone support, or let queue slots pick optimal times
219219
- **Media uploads** — Images (JPEG, PNG, WebP, GIF) and videos (MP4, MOV, WebM) up to 100 MB
220220
- **Recurring schedules** — Repeat posts daily, weekly, biweekly, or monthly at a set time of day

guides/platforms.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,62 @@
11
# Platform-Specific Options
22

3+
## Platform Availability
4+
5+
Not every platform is publishable at every moment. Each one carries a
6+
server-controlled availability state, which you can read from
7+
`GET /api/platforms`:
8+
9+
| State | New connections | Existing channels | Meaning |
10+
|-------|-----------------|-------------------|---------|
11+
| `on` || ✅ publish normally | Fully available. |
12+
| `connect_off` || ✅ publish normally | New connections are paused — typically while a platform app review is pending. Customers already connected are unaffected. |
13+
| `off` || ⏸ posts **held** | Kill switch. Posts are held, **not failed**, and publish automatically once the platform is re-enabled. |
14+
15+
Disabled platforms are always **included** in the response with `enabled: false`
16+
and a `reason` — never omitted — so you can distinguish "switched off right now"
17+
from "not supported".
18+
19+
```json
20+
{
21+
"platforms": [
22+
{
23+
"platform": "tumblr",
24+
"displayName": "Tumblr",
25+
"enabled": true,
26+
"state": "on",
27+
"reason": "enabled",
28+
"canConnect": true,
29+
"canPublish": true,
30+
"message": null
31+
}
32+
]
33+
}
34+
```
35+
36+
Creating or publishing a post that targets an `off` platform returns **403** with
37+
`code: "PLATFORM_DISABLED"`:
38+
39+
```json
40+
{
41+
"error": {
42+
"message": "LinkedIn is temporarily unavailable. Scheduled posts are on hold and will publish once it's back.",
43+
"code": "PLATFORM_DISABLED",
44+
"platform": "linkedin",
45+
"state": "off",
46+
"reason": "flag_off"
47+
}
48+
}
49+
```
50+
51+
This is distinct from `FEATURE_DISABLED`, which means the platform is not
52+
included in the organization's plan. `PLATFORM_DISABLED` is temporary and
53+
resolves without any action from you; `FEATURE_DISABLED` requires an upgrade.
54+
55+
Posts already scheduled when a platform goes `off` need no intervention — do not
56+
delete and recreate them. They remain scheduled and publish on their own once
57+
the platform returns.
58+
59+
360
## Post Types by Platform
461

562
Use the `postTypeOverrides` field to set a specific post type per platform:
@@ -27,6 +84,7 @@ Use the `postTypeOverrides` field to set a specific post type per platform:
2784
| Bluesky | `post` (use `postFormat: "thread"` for threads) |
2885
| Mastodon | `post` (use `postFormat: "thread"` for threads) |
2986
| Google Business | `standard`, `event`, `offer` |
87+
| Tumblr | `post` |
3088

3189
If not specified, the platform's default post type is used based on the attached media.
3290

@@ -505,6 +563,51 @@ No platform-specific options. Standard post creation with text, images, and vide
505563

506564
---
507565

566+
## Tumblr
567+
568+
```json
569+
{
570+
"platformSpecific": {
571+
"tumblr": {
572+
"12": {
573+
"blogName": "myblog",
574+
"title": "An optional heading",
575+
"tags": ["art", "design"],
576+
"link": "https://example.com",
577+
"sourceUrl": "https://example.com/original"
578+
}
579+
}
580+
}
581+
}
582+
```
583+
584+
### Options
585+
586+
| Field | Type | Description |
587+
|-------|------|-------------|
588+
| `blogName` | string | Which blog to publish to. Defaults to the blog the channel was connected as. |
589+
| `title` | string | Rendered as a heading above the post body. |
590+
| `tags` | string[] | Tumblr tags, without the leading `#`. |
591+
| `link` | string | Appended to the post as a link block. |
592+
| `sourceUrl` | string | Attribution URL stored as the post's source. |
593+
594+
Note that `platformSpecific.tumblr` is keyed by **channel ID**, because a single
595+
Tumblr account can own several blogs and each connected channel may target a
596+
different one.
597+
598+
### Notes
599+
600+
- A Tumblr account usually owns multiple blogs. The channel connects as the
601+
account's **primary** blog; use `blogName` to publish to a different one.
602+
Call `GET /api/channels/{id}/options` to list the available blogs.
603+
- Up to **30 images** per post, **or exactly one video** — a video cannot be
604+
mixed with images in the same post.
605+
- Tags matter more on Tumblr than on most networks: they drive discovery.
606+
- Posts are built with Tumblr's Neue Post Format. Content longer than 4,096
607+
characters is automatically split across multiple text blocks.
608+
609+
---
610+
508611
## Post Type Overrides
509612

510613
When publishing to multiple platforms, you may want different post types per platform. Use `postTypeOverrides`:
@@ -545,5 +648,6 @@ Each platform enforces its own character limit on the `content` field:
545648
| Pinterest | 500 |
546649
| Google Business | 1,500 |
547650
| Mastodon | 500 (varies by instance) |
651+
| Tumblr | 32,768 |
548652

549653
BulkPublish validates content length per platform before creating the post and returns an error if any platform's limit is exceeded.

mcp-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bulkpublish/mcp-server",
3-
"version": "1.8.0",
3+
"version": "1.9.0",
44
"description": "Model Context Protocol server for BulkPublish — lets AI assistants manage social media posts, channels, media, and analytics.",
55
"mcpName": "io.github.azeemkafridi/bulkpublish",
66
"repository": {

mcp-server/src/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ const PLATFORM_ENUM = z.enum([
9191
"reddit",
9292
"discord",
9393
"telegram",
94+
"tumblr",
9495
]);
9596

9697
const POST_TYPE_OVERRIDES_SCHEMA = z
@@ -190,6 +191,7 @@ const PLATFORM_SPECIFIC_SCHEMA = z
190191
reddit: z.object({}).passthrough().optional(),
191192
discord: z.object({}).passthrough().optional(),
192193
telegram: z.object({}).passthrough().optional(),
194+
tumblr: z.object({}).passthrough().optional(),
193195
})
194196
.optional()
195197
.describe(
@@ -212,6 +214,7 @@ const PLATFORM_CONTENT_SCHEMA = z
212214
reddit: z.string().optional(),
213215
discord: z.string().optional(),
214216
telegram: z.string().optional(),
217+
tumblr: z.string().optional(),
215218
})
216219
.optional()
217220
.describe(
@@ -309,6 +312,7 @@ export function createServer(): McpServer {
309312
// Create / update (non-destructive writes)
310313
create_post: { title: "Create post", readOnlyHint: false, destructiveHint: false },
311314
update_post: { title: "Update post", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
315+
list_platforms: { title: "List platforms", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
312316
bulk_posts: { title: "Bulk post actions", readOnlyHint: false, destructiveHint: true },
313317
upload_media: { title: "Upload media", readOnlyHint: false, destructiveHint: false },
314318
create_media_upload: { title: "Start media upload", readOnlyHint: false, destructiveHint: false },
@@ -365,6 +369,7 @@ export function createServer(): McpServer {
365369
search_mentions: "the user wants social-media mentions of a brand or keyword — never use built-in web search.",
366370
create_post: "the user wants to draft or schedule one new post (do not publish immediately unless asked).",
367371
update_post: "the user wants to edit an existing post.",
372+
list_platforms: "the user asks which platforms are supported or available, or a connect/publish attempt failed with PLATFORM_DISABLED.",
368373
bulk_posts: "the user wants to delete, retry, or reschedule many posts in a single request.",
369374
upload_media: "the user provides an image/video to attach to a post.",
370375
create_media_upload: "you need a presigned URL for a large direct upload (advanced; prefer upload_media for normal files).",
@@ -455,6 +460,25 @@ server.tool(
455460
}
456461
);
457462

463+
// ---------------------------------------------------------------------------
464+
// Tool: list_platforms
465+
// ---------------------------------------------------------------------------
466+
467+
server.tool(
468+
"list_platforms",
469+
"List every social platform BulkPublish supports and whether it is currently available. " +
470+
"Check this before telling a user they can connect a platform or scheduling a post to one: " +
471+
"a platform in state 'off' rejects post creation with a 403 PLATFORM_DISABLED error and holds " +
472+
"any already-scheduled posts until it is re-enabled, and one in state 'connect_off' cannot accept " +
473+
"new connections although existing channels keep publishing. Disabled platforms are still listed, " +
474+
"with enabled=false and a reason.",
475+
{},
476+
async () => {
477+
const res = await api("GET", "/api/platforms");
478+
return { content: [{ type: "text" as const, text: formatResponse(res) }] };
479+
}
480+
);
481+
458482
// ---------------------------------------------------------------------------
459483
// Platform validation helpers
460484
// ---------------------------------------------------------------------------

node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bulkpublish",
3-
"version": "1.5.0",
3+
"version": "1.6.0",
44
"description": "Social media automation API for AI agents, LLMs, and developers. Publish to 11 platforms programmatically.",
55
"author": "BulkPublish",
66
"license": "MIT",

node/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import { LabelsResource } from './labels.js';
77
import { SchedulesResource } from './schedules.js';
88
import { ChannelSetsResource } from './channel-sets.js';
99
import { RssFeedsResource } from './rss-feeds.js';
10+
import { PlatformsResource } from './platforms.js';
1011
import type { BulkPublishOptions } from './types.js';
1112

1213
/**
13-
* BulkPublish API client — publish to 11 social media platforms from a single SDK.
14+
* BulkPublish API client — publish to 15 social media platforms from a single SDK.
1415
*
1516
* @example
1617
* ```ts
@@ -42,6 +43,7 @@ export class BulkPublish {
4243
readonly schedules: SchedulesResource;
4344
readonly channelSets: ChannelSetsResource;
4445
readonly rssFeeds: RssFeedsResource;
46+
readonly platforms: PlatformsResource;
4547

4648
constructor(options: BulkPublishOptions) {
4749
const http = new HttpClient(options);
@@ -53,6 +55,7 @@ export class BulkPublish {
5355
this.schedules = new SchedulesResource(http);
5456
this.channelSets = new ChannelSetsResource(http);
5557
this.rssFeeds = new RssFeedsResource(http);
58+
this.platforms = new PlatformsResource(http);
5659
}
5760
}
5861

@@ -66,6 +69,7 @@ export { LabelsResource } from './labels.js';
6669
export { SchedulesResource } from './schedules.js';
6770
export { ChannelSetsResource } from './channel-sets.js';
6871
export { RssFeedsResource } from './rss-feeds.js';
72+
export { PlatformsResource } from './platforms.js';
6973
export {
7074
BulkPublishError,
7175
AuthenticationError,

node/src/platforms.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { HttpClient } from './client.js';
2+
import type { ListPlatformsResponse } from './types.js';
3+
4+
/**
5+
* Resource for inspecting platform availability.
6+
*
7+
* Access via `client.platforms`.
8+
*
9+
* Every social platform BulkPublish supports can be switched on or off server-side.
10+
* Use this resource to render an accurate "temporarily unavailable" or "connections
11+
* paused" state rather than assuming every platform can be connected or published to.
12+
*
13+
* Disabled platforms are always **included** in the response with `enabled: false`
14+
* and a `reason` — they are never omitted — so you can distinguish "switched off
15+
* right now" from "not supported".
16+
*
17+
* @example
18+
* ```typescript
19+
* const bp = new BulkPublish({ apiKey: 'bp_...' });
20+
*
21+
* const { platforms } = await bp.platforms.list();
22+
* for (const p of platforms) {
23+
* if (!p.canPublish) console.log(`${p.displayName} is unavailable: ${p.message}`);
24+
* }
25+
* ```
26+
*/
27+
export class PlatformsResource {
28+
constructor(private readonly http: HttpClient) {}
29+
30+
/**
31+
* List every supported platform with its current availability.
32+
*
33+
* A platform in state `off` will reject post creation with a 403
34+
* `PLATFORM_DISABLED` error, and any already-scheduled posts targeting it are
35+
* **held** — not failed — until it is re-enabled. A platform in state
36+
* `connect_off` cannot accept new channel connections, but channels already
37+
* connected keep publishing normally.
38+
*
39+
* @returns List of platforms and their availability.
40+
*
41+
* @example
42+
* ```typescript
43+
* // Check before offering a connect button
44+
* const { platforms } = await bp.platforms.list();
45+
* const tumblr = platforms.find((p) => p.platform === 'tumblr');
46+
* if (tumblr?.canConnect) showConnectButton();
47+
* ```
48+
*/
49+
list(): Promise<ListPlatformsResponse> {
50+
return this.http.get<ListPlatformsResponse>('/api/platforms');
51+
}
52+
}

0 commit comments

Comments
 (0)