posterly-mcp-server 0.45.0 → 0.45.1
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 +1 -1
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/tools/create-post.d.ts +24 -1
- package/dist/tools/create-post.js +72 -6
- package/dist/tools/create-posts-batch.js +14 -2
- package/dist/tools/validate-post.js +2 -2
- package/package.json +1 -1
- package/scripts/test-payload-builders.mjs +143 -20
- package/server.json +2 -2
- package/src/lib/version.ts +1 -1
- package/src/tools/create-post.ts +84 -9
- package/src/tools/create-posts-batch.ts +13 -2
- package/src/tools/validate-post.ts +2 -1
- package/dist/tools/add-google-business-media.d.ts +0 -42
- package/dist/tools/add-google-business-media.js +0 -34
- package/dist/tools/create-oauth-client.d.ts +0 -40
- package/dist/tools/create-oauth-client.js +0 -20
- package/dist/tools/delete-comment.d.ts +0 -24
- package/dist/tools/delete-comment.js +0 -22
- package/dist/tools/delete-google-business-media.d.ts +0 -32
- package/dist/tools/delete-google-business-media.js +0 -17
- package/dist/tools/delete-google-business-review-reply.d.ts +0 -32
- package/dist/tools/delete-google-business-review-reply.js +0 -17
- package/dist/tools/delete-oauth-client.d.ts +0 -20
- package/dist/tools/delete-oauth-client.js +0 -13
- package/dist/tools/delete-post-group.d.ts +0 -20
- package/dist/tools/delete-post-group.js +0 -13
- package/dist/tools/delete-webhook.d.ts +0 -20
- package/dist/tools/delete-webhook.js +0 -13
- package/dist/tools/get-account-analytics.d.ts +0 -31
- package/dist/tools/get-account-analytics.js +0 -152
- package/dist/tools/get-brand-profile.d.ts +0 -16
- package/dist/tools/get-brand-profile.js +0 -73
- package/dist/tools/get-brand.d.ts +0 -16
- package/dist/tools/get-brand.js +0 -26
- package/dist/tools/get-comment.d.ts +0 -20
- package/dist/tools/get-comment.js +0 -36
- package/dist/tools/get-connect-link.d.ts +0 -28
- package/dist/tools/get-connect-link.js +0 -51
- package/dist/tools/get-connect-session.d.ts +0 -20
- package/dist/tools/get-connect-session.js +0 -54
- package/dist/tools/get-conversation.d.ts +0 -24
- package/dist/tools/get-conversation.js +0 -31
- package/dist/tools/get-google-business-review-link.d.ts +0 -24
- package/dist/tools/get-google-business-review-link.js +0 -19
- package/dist/tools/get-image-job.d.ts +0 -24
- package/dist/tools/get-image-job.js +0 -31
- package/dist/tools/get-learned-voice.d.ts +0 -16
- package/dist/tools/get-learned-voice.js +0 -22
- package/dist/tools/get-mcp-status.d.ts +0 -8
- package/dist/tools/get-mcp-status.js +0 -182
- package/dist/tools/get-performance-profile.d.ts +0 -16
- package/dist/tools/get-performance-profile.js +0 -39
- package/dist/tools/get-platform-schema.d.ts +0 -24
- package/dist/tools/get-platform-schema.js +0 -48
- package/dist/tools/get-post-analytics.d.ts +0 -39
- package/dist/tools/get-post-analytics.js +0 -222
- package/dist/tools/get-post-insights.d.ts +0 -28
- package/dist/tools/get-post-insights.js +0 -39
- package/dist/tools/get-post-missing.d.ts +0 -16
- package/dist/tools/get-post-missing.js +0 -12
- package/dist/tools/get-video-job.d.ts +0 -24
- package/dist/tools/get-video-job.js +0 -31
- package/dist/tools/list-brand-accounts.d.ts +0 -16
- package/dist/tools/list-brand-accounts.js +0 -24
- package/dist/tools/reply-google-business-review.d.ts +0 -36
- package/dist/tools/reply-google-business-review.js +0 -18
- package/dist/tools/reply-to-comment.d.ts +0 -32
- package/dist/tools/reply-to-comment.js +0 -17
- package/dist/tools/send-message.d.ts +0 -28
- package/dist/tools/send-message.js +0 -16
- package/dist/tools/suggest-google-business-review-reply.d.ts +0 -48
- package/dist/tools/suggest-google-business-review-reply.js +0 -27
- package/dist/tools/sync-inbox.d.ts +0 -24
- package/dist/tools/sync-inbox.js +0 -18
- package/dist/tools/test-webhook.d.ts +0 -20
- package/dist/tools/test-webhook.js +0 -14
- package/dist/tools/update-comment.d.ts +0 -28
- package/dist/tools/update-comment.js +0 -21
- package/dist/tools/update-oauth-client.d.ts +0 -44
- package/dist/tools/update-oauth-client.js +0 -21
- package/dist/tools/update-post-release-id.d.ts +0 -28
- package/dist/tools/update-post-release-id.js +0 -18
- package/dist/tools/update-post-status.d.ts +0 -28
- package/dist/tools/update-post-status.js +0 -23
- package/dist/tools/update-webhook.d.ts +0 -40
- package/dist/tools/update-webhook.js +0 -21
- package/dist/tools/upload-media-from-url.d.ts +0 -24
- package/dist/tools/upload-media-from-url.js +0 -26
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ Add the same server definition to your Cursor MCP settings:
|
|
|
122
122
|
|
|
123
123
|
## Available tools
|
|
124
124
|
|
|
125
|
-
`posterly-mcp-server@0.45.
|
|
125
|
+
`posterly-mcp-server@0.45.1` exposes 62 tools covering 94 actions. The hosted HTTP endpoint exposes the same catalog minus the three signup tools: 59 tools covering 91 actions.
|
|
126
126
|
|
|
127
127
|
Many tools pick what to do with one parameter: `view` (reads), `action` (writes), or `type`. A few pick their mode from which parameters you pass (for example `update_post` with `status` changes the status instead of editing). Every tool description names all of its views and actions.
|
|
128
128
|
|
package/dist/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const POSTERLY_MCP_VERSION = "0.45.
|
|
1
|
+
export declare const POSTERLY_MCP_VERSION = "0.45.1";
|
package/dist/lib/version.js
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// tool set (minus the intentional pre-auth signup tools that only this stdio
|
|
6
6
|
// package exposes). `npm run check:mcp-parity` enforces both the version match
|
|
7
7
|
// and the tool-list match, and runs in the pre-commit hook.
|
|
8
|
-
export const POSTERLY_MCP_VERSION = '0.45.
|
|
8
|
+
export const POSTERLY_MCP_VERSION = '0.45.1';
|
|
@@ -2116,7 +2116,30 @@ export declare const createPostInputSchema: z.ZodObject<{
|
|
|
2116
2116
|
}>;
|
|
2117
2117
|
export type CreatePostInput = z.infer<typeof createPostInputSchema>;
|
|
2118
2118
|
export type CreatePostPayloadInput = z.infer<typeof createPostPayloadInputSchema>;
|
|
2119
|
-
export
|
|
2119
|
+
export type AccountPlatformLookup = {
|
|
2120
|
+
found: true;
|
|
2121
|
+
platform: string | null;
|
|
2122
|
+
} | {
|
|
2123
|
+
found: false;
|
|
2124
|
+
detail: string;
|
|
2125
|
+
};
|
|
2126
|
+
export type AccountPlatformResolver = (accountId: string, workspaceId?: string) => Promise<AccountPlatformLookup>;
|
|
2127
|
+
/**
|
|
2128
|
+
* Builds a resolver that answers "what platform is this account_id on?" via
|
|
2129
|
+
* client.listAccounts() (there is no GET /accounts/:id), for thread_posts
|
|
2130
|
+
* calls that gave account_id without platform. Caches one in-flight lookup
|
|
2131
|
+
* per workspace_id (including the unscoped list, keyed ''), so
|
|
2132
|
+
* create_posts_batch never issues more than one accounts request per
|
|
2133
|
+
* distinct workspace_id even though every item in the batch calls this
|
|
2134
|
+
* independently: the cache is populated synchronously (before the first
|
|
2135
|
+
* await) on the first call for a given workspace_id, so concurrent
|
|
2136
|
+
* Promise.all callers for the same workspace_id share the one in-flight
|
|
2137
|
+
* request. A failed lookup (non-2xx, missing accounts:read, network error)
|
|
2138
|
+
* is cached too, so it is reported once per workspace_id rather than
|
|
2139
|
+
* surfacing a fresh error per item.
|
|
2140
|
+
*/
|
|
2141
|
+
export declare function createAccountPlatformResolver(client: PosterlyClient): AccountPlatformResolver;
|
|
2142
|
+
export declare function buildCreatePostPayload(input: CreatePostPayloadInput | CreatePostInput, resolvePlatform: AccountPlatformResolver): Promise<Parameters<PosterlyClient['createPost']>[0]>;
|
|
2120
2143
|
export declare const createPostTool: {
|
|
2121
2144
|
name: string;
|
|
2122
2145
|
description: string;
|
|
@@ -163,17 +163,83 @@ export const createPostPayloadInputSchema = z.object({
|
|
|
163
163
|
export const createPostInputSchema = createPostPayloadInputSchema.extend({
|
|
164
164
|
confirm: z.literal(true).describe('Must be true after explicit user confirmation of the post/account/workspace/schedule.'),
|
|
165
165
|
});
|
|
166
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Builds a resolver that answers "what platform is this account_id on?" via
|
|
168
|
+
* client.listAccounts() (there is no GET /accounts/:id), for thread_posts
|
|
169
|
+
* calls that gave account_id without platform. Caches one in-flight lookup
|
|
170
|
+
* per workspace_id (including the unscoped list, keyed ''), so
|
|
171
|
+
* create_posts_batch never issues more than one accounts request per
|
|
172
|
+
* distinct workspace_id even though every item in the batch calls this
|
|
173
|
+
* independently: the cache is populated synchronously (before the first
|
|
174
|
+
* await) on the first call for a given workspace_id, so concurrent
|
|
175
|
+
* Promise.all callers for the same workspace_id share the one in-flight
|
|
176
|
+
* request. A failed lookup (non-2xx, missing accounts:read, network error)
|
|
177
|
+
* is cached too, so it is reported once per workspace_id rather than
|
|
178
|
+
* surfacing a fresh error per item.
|
|
179
|
+
*/
|
|
180
|
+
export function createAccountPlatformResolver(client) {
|
|
181
|
+
const cache = new Map();
|
|
182
|
+
const lookupForWorkspace = (workspaceKey) => {
|
|
183
|
+
let pending = cache.get(workspaceKey);
|
|
184
|
+
if (!pending) {
|
|
185
|
+
pending = (async () => {
|
|
186
|
+
try {
|
|
187
|
+
const accounts = await client.listAccounts(workspaceKey ? { workspace_id: workspaceKey } : undefined);
|
|
188
|
+
const platforms = new Map();
|
|
189
|
+
for (const account of accounts) {
|
|
190
|
+
if (account && typeof account.platform === 'string' && account.id !== undefined && account.id !== null) {
|
|
191
|
+
platforms.set(String(account.id), account.platform);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, platforms };
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
return { ok: false, detail: error?.message || 'accounts lookup failed' };
|
|
198
|
+
}
|
|
199
|
+
})();
|
|
200
|
+
cache.set(workspaceKey, pending);
|
|
201
|
+
}
|
|
202
|
+
return pending;
|
|
203
|
+
};
|
|
204
|
+
return async (accountId, workspaceId) => {
|
|
205
|
+
const workspaceKey = workspaceId || '';
|
|
206
|
+
const lookup = await lookupForWorkspace(workspaceKey);
|
|
207
|
+
if (!lookup.ok)
|
|
208
|
+
return { found: false, detail: lookup.detail };
|
|
209
|
+
return { found: true, platform: lookup.platforms.get(String(accountId)) ?? null };
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
export async function buildCreatePostPayload(input, resolvePlatform) {
|
|
167
213
|
const { confirm: _confirm, thread_posts, caption, post_type, instagram_settings, platform_settings, ...rest } = input;
|
|
168
214
|
if (thread_posts && thread_posts.length > 0) {
|
|
169
215
|
if (thread_posts.length < 2) {
|
|
170
216
|
throw new Error('thread_posts must contain at least 2 entries');
|
|
171
217
|
}
|
|
172
218
|
const platformHint = (input.platform || '').toLowerCase();
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (!isTwitter && !isThreads
|
|
176
|
-
|
|
219
|
+
let isTwitter = platformHint === 'twitter' || platformHint === 'x';
|
|
220
|
+
let isThreads = platformHint === 'threads';
|
|
221
|
+
if (!isTwitter && !isThreads) {
|
|
222
|
+
if (!input.account_id) {
|
|
223
|
+
throw new Error('thread_posts requires `platform` to be "twitter" or "threads", or pass `account_id` for an X/Threads account.');
|
|
224
|
+
}
|
|
225
|
+
// No platform hint: never guess X. Resolve the account's real platform
|
|
226
|
+
// first, so a Threads account_id builds a threads_thread, not an
|
|
227
|
+
// x_thread that the server then rejects.
|
|
228
|
+
const lookup = await resolvePlatform(input.account_id, input.workspace_id);
|
|
229
|
+
if (!lookup.found) {
|
|
230
|
+
throw new Error(`thread_posts could not resolve account_id's platform (${lookup.detail}). Pass \`platform\` explicitly.`);
|
|
231
|
+
}
|
|
232
|
+
else if (lookup.platform === 'threads') {
|
|
233
|
+
isThreads = true;
|
|
234
|
+
}
|
|
235
|
+
else if (lookup.platform === 'twitter') {
|
|
236
|
+
isTwitter = true;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
throw new Error(lookup.platform
|
|
240
|
+
? `thread_posts requires an X/Twitter or Threads account; account_id resolved to platform "${lookup.platform}".`
|
|
241
|
+
: 'thread_posts requires `platform` to be "twitter" or "threads", or an `account_id` that exists in list_accounts. Pass `platform` explicitly.');
|
|
242
|
+
}
|
|
177
243
|
}
|
|
178
244
|
const arrayKey = isThreads ? 'threads_thread_posts' : 'x_thread_tweets';
|
|
179
245
|
const totalKey = isThreads ? 'threads_thread_total' : 'x_thread_total';
|
|
@@ -227,7 +293,7 @@ export const createPostTool = {
|
|
|
227
293
|
if (input.confirm !== true) {
|
|
228
294
|
throw new Error('Refusing to create post without confirm=true after explicit user confirmation.');
|
|
229
295
|
}
|
|
230
|
-
const payload = buildCreatePostPayload(input);
|
|
296
|
+
const payload = await buildCreatePostPayload(input, createAccountPlatformResolver(client));
|
|
231
297
|
const { thread_posts, instagram_settings, platform_settings } = input;
|
|
232
298
|
const result = await client.createPost(payload);
|
|
233
299
|
const p = result.post;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { dashboardUrlForPostList, formatLocalDateTime } from '../lib/format.js';
|
|
3
|
-
import { buildCreatePostPayload, createPostPayloadInputSchema } from './create-post.js';
|
|
3
|
+
import { buildCreatePostPayload, createAccountPlatformResolver, createPostPayloadInputSchema } from './create-post.js';
|
|
4
4
|
export const createPostsBatchTool = {
|
|
5
5
|
name: 'create_posts_batch',
|
|
6
6
|
description: 'Create 1-25 scheduled or immediate social posts in one API request. This is a DESTRUCTIVE WRITE that creates content on the user\'s real social accounts - once scheduled_at passes, posts may go public and cannot be un-posted.\n\n' +
|
|
@@ -22,7 +22,19 @@ export const createPostsBatchTool = {
|
|
|
22
22
|
if (input.confirm !== true) {
|
|
23
23
|
throw new Error('Refusing to create batch posts without confirm=true after explicit user confirmation.');
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
// One resolver shared across the whole batch: createAccountPlatformResolver
|
|
26
|
+
// caches its accounts lookup per workspace_id, so N items that all need
|
|
27
|
+
// thread_posts platform resolution for the same workspace still cost one
|
|
28
|
+
// listAccounts() call, not N.
|
|
29
|
+
const resolvePlatform = createAccountPlatformResolver(client);
|
|
30
|
+
const payloads = await Promise.all(input.posts.map(async (post, index) => {
|
|
31
|
+
try {
|
|
32
|
+
return await buildCreatePostPayload(post, resolvePlatform);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new Error(`Item ${index}: ${error?.message || 'invalid post input'}`);
|
|
36
|
+
}
|
|
37
|
+
}));
|
|
26
38
|
const result = await client.createPostsBatch({ posts: payloads });
|
|
27
39
|
const createdPosts = result.posts.map((item) => item.post);
|
|
28
40
|
const dashboardUrl = dashboardUrlForPostList(createdPosts);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { buildCreatePostPayload, createPostPayloadInputSchema, } from './create-post.js';
|
|
1
|
+
import { buildCreatePostPayload, createAccountPlatformResolver, createPostPayloadInputSchema, } from './create-post.js';
|
|
2
2
|
export const validatePostInputSchema = createPostPayloadInputSchema;
|
|
3
3
|
export const validatePostTool = {
|
|
4
4
|
name: 'validate_post',
|
|
5
5
|
description: 'Validate and normalize one post without creating it. This does not create records, copy remote media, consume post-item/storage quota, reserve X quota, emit webhooks, or enqueue publication. Third-party media may return remote_media_not_materialized because live create performs storage copy and final byte validation. Use this before showing the final preview and asking for confirmation to call create_post.',
|
|
6
6
|
inputSchema: validatePostInputSchema,
|
|
7
7
|
async execute(client, input) {
|
|
8
|
-
const payload = buildCreatePostPayload(input);
|
|
8
|
+
const payload = await buildCreatePostPayload(input, createAccountPlatformResolver(client));
|
|
9
9
|
const result = await client.validatePost(payload);
|
|
10
10
|
const lines = [
|
|
11
11
|
'Post validation passed.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "posterly-mcp-server",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.1",
|
|
4
4
|
"mcpName": "io.github.awpthorp/posterly",
|
|
5
5
|
"description": "MCP server for posterly: schedule and publish social media posts across 18 platforms from any MCP client (Claude, ChatGPT, Cursor, Windsurf, Cline, and more)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,15 +12,23 @@
|
|
|
12
12
|
*
|
|
13
13
|
* These tests pin the rules the server actually applies, so a future edit that
|
|
14
14
|
* tightens the client past the server fails here.
|
|
15
|
+
*
|
|
16
|
+
* buildCreatePostPayload also takes an AccountPlatformResolver (built from a
|
|
17
|
+
* PosterlyClient by createAccountPlatformResolver) so it can resolve an
|
|
18
|
+
* account_id's real platform for thread_posts when the caller did not pass
|
|
19
|
+
* `platform` (there is no other way to tell an X thread from a Threads
|
|
20
|
+
* thread). fakeClient() below stands in for the real client, and counts
|
|
21
|
+
* calls so the "one lookup per workspace_id" batching behaviour can be
|
|
22
|
+
* asserted directly.
|
|
15
23
|
*/
|
|
16
24
|
|
|
17
25
|
import assert from 'node:assert/strict';
|
|
18
|
-
import { buildCreatePostPayload } from '../dist/tools/create-post.js';
|
|
26
|
+
import { buildCreatePostPayload, createAccountPlatformResolver } from '../dist/tools/create-post.js';
|
|
19
27
|
|
|
20
28
|
let failures = 0;
|
|
21
|
-
function check(name, fn) {
|
|
29
|
+
async function check(name, fn) {
|
|
22
30
|
try {
|
|
23
|
-
fn();
|
|
31
|
+
await fn();
|
|
24
32
|
console.log(`PASS ${name}`);
|
|
25
33
|
} catch (error) {
|
|
26
34
|
failures += 1;
|
|
@@ -30,58 +38,173 @@ function check(name, fn) {
|
|
|
30
38
|
|
|
31
39
|
const IMAGE = 'https://assets.poster.ly/users/test/a.jpg';
|
|
32
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A minimal PosterlyClient stand-in. Throws if listAccounts is called when
|
|
43
|
+
* the test does not expect it (accounts === null), or rejects with `fail`
|
|
44
|
+
* when a lookup-failure test needs a realistic error. Tracks call count so
|
|
45
|
+
* "one lookup per workspace_id" can be asserted.
|
|
46
|
+
*/
|
|
47
|
+
function fakeClient(accounts = null, { fail } = {}) {
|
|
48
|
+
let calls = 0;
|
|
49
|
+
return {
|
|
50
|
+
calls: () => calls,
|
|
51
|
+
async listAccounts() {
|
|
52
|
+
calls += 1;
|
|
53
|
+
if (fail) throw new Error(fail);
|
|
54
|
+
if (accounts === null) {
|
|
55
|
+
throw new Error('listAccounts should not have been called for this input');
|
|
56
|
+
}
|
|
57
|
+
return accounts;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
33
62
|
// The server rule (lib/api-v1-post-create.ts) is "provide caption or media".
|
|
34
63
|
// Media-only posts are valid and must not be rejected by the client.
|
|
35
|
-
check('media-only post is accepted (Instagram Story shape)', () => {
|
|
36
|
-
const payload = buildCreatePostPayload({
|
|
64
|
+
await check('media-only post is accepted (Instagram Story shape)', async () => {
|
|
65
|
+
const payload = await buildCreatePostPayload({
|
|
37
66
|
platform: 'instagram',
|
|
38
67
|
media_url: IMAGE,
|
|
39
68
|
post_type: 'story',
|
|
40
69
|
instagram_settings: { post_type: 'story' },
|
|
41
|
-
});
|
|
70
|
+
}, createAccountPlatformResolver(fakeClient()));
|
|
42
71
|
assert.equal(typeof payload.caption, 'string', 'caption should be sent as a string');
|
|
43
72
|
assert.equal(payload.post_type, 'story');
|
|
44
73
|
});
|
|
45
74
|
|
|
46
|
-
check('media-only post is accepted (Facebook Story shape)', () => {
|
|
47
|
-
const payload = buildCreatePostPayload({
|
|
75
|
+
await check('media-only post is accepted (Facebook Story shape)', async () => {
|
|
76
|
+
const payload = await buildCreatePostPayload({
|
|
48
77
|
platform: 'facebook',
|
|
49
78
|
media_url: IMAGE,
|
|
50
79
|
post_type: 'story',
|
|
51
80
|
platform_settings: { __type: 'facebook', post_type: 'story' },
|
|
52
|
-
});
|
|
81
|
+
}, createAccountPlatformResolver(fakeClient()));
|
|
53
82
|
assert.equal(typeof payload.caption, 'string');
|
|
54
83
|
});
|
|
55
84
|
|
|
56
|
-
check('media_urls-only post is accepted', () => {
|
|
57
|
-
const payload = buildCreatePostPayload(
|
|
85
|
+
await check('media_urls-only post is accepted', async () => {
|
|
86
|
+
const payload = await buildCreatePostPayload(
|
|
87
|
+
{ platform: 'instagram', media_urls: [IMAGE] },
|
|
88
|
+
createAccountPlatformResolver(fakeClient()),
|
|
89
|
+
);
|
|
58
90
|
assert.equal(typeof payload.caption, 'string');
|
|
59
91
|
});
|
|
60
92
|
|
|
61
|
-
check('caption-only post is accepted', () => {
|
|
62
|
-
const payload = buildCreatePostPayload(
|
|
93
|
+
await check('caption-only post is accepted', async () => {
|
|
94
|
+
const payload = await buildCreatePostPayload(
|
|
95
|
+
{ platform: 'linkedin', caption: 'hello' },
|
|
96
|
+
createAccountPlatformResolver(fakeClient()),
|
|
97
|
+
);
|
|
63
98
|
assert.equal(payload.caption, 'hello');
|
|
64
99
|
});
|
|
65
100
|
|
|
66
|
-
check('post with neither caption nor media is rejected', () => {
|
|
67
|
-
assert.
|
|
68
|
-
() => buildCreatePostPayload({ platform: 'instagram' }),
|
|
101
|
+
await check('post with neither caption nor media is rejected', async () => {
|
|
102
|
+
await assert.rejects(
|
|
103
|
+
() => buildCreatePostPayload({ platform: 'instagram' }, createAccountPlatformResolver(fakeClient())),
|
|
69
104
|
/caption or media/i,
|
|
70
105
|
'should reject, and the message should tell the caller media is an option',
|
|
71
106
|
);
|
|
72
107
|
});
|
|
73
108
|
|
|
74
|
-
check('thread_posts still bypasses the caption rule', () => {
|
|
75
|
-
const payload = buildCreatePostPayload({
|
|
109
|
+
await check('thread_posts still bypasses the caption rule', async () => {
|
|
110
|
+
const payload = await buildCreatePostPayload({
|
|
76
111
|
platform: 'twitter',
|
|
77
112
|
thread_posts: ['one', 'two'],
|
|
78
|
-
});
|
|
113
|
+
}, createAccountPlatformResolver(fakeClient()));
|
|
79
114
|
assert.equal(payload.caption, 'one');
|
|
80
115
|
assert.equal(payload.post_type, 'x_thread');
|
|
81
116
|
});
|
|
82
117
|
|
|
118
|
+
// Regression: thread_posts with only account_id (no platform) used to always
|
|
119
|
+
// guess an X thread, so a Threads account_id built x_thread_tweets and the
|
|
120
|
+
// server rejected it with "post_type x_thread requires a Twitter/X account".
|
|
121
|
+
await check('thread_posts + account_id resolves to threads_thread for a Threads account', async () => {
|
|
122
|
+
const payload = await buildCreatePostPayload({
|
|
123
|
+
account_id: 'acct_threads',
|
|
124
|
+
thread_posts: ['one', 'two'],
|
|
125
|
+
}, createAccountPlatformResolver(fakeClient([{ id: 'acct_threads', platform: 'threads', username: 'brand' }])));
|
|
126
|
+
assert.equal(payload.platform, 'threads');
|
|
127
|
+
assert.equal(payload.post_type, 'threads_thread');
|
|
128
|
+
assert.deepEqual(payload.metadata.threads_thread_posts, ['one', 'two']);
|
|
129
|
+
assert.equal(payload.metadata.x_thread_tweets, undefined);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await check('thread_posts + account_id resolves to x_thread for a Twitter/X account', async () => {
|
|
133
|
+
const payload = await buildCreatePostPayload({
|
|
134
|
+
account_id: 'acct_x',
|
|
135
|
+
thread_posts: ['one', 'two'],
|
|
136
|
+
}, createAccountPlatformResolver(fakeClient([{ id: 'acct_x', platform: 'twitter', username: 'brand' }])));
|
|
137
|
+
assert.equal(payload.platform, 'twitter');
|
|
138
|
+
assert.equal(payload.post_type, 'x_thread');
|
|
139
|
+
assert.deepEqual(payload.metadata.x_thread_tweets, ['one', 'two']);
|
|
140
|
+
assert.equal(payload.metadata.threads_thread_posts, undefined);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await check('thread_posts + explicit platform never looks up the account (unchanged request shape)', async () => {
|
|
144
|
+
const payload = await buildCreatePostPayload({
|
|
145
|
+
platform: 'threads',
|
|
146
|
+
account_id: 'acct_threads',
|
|
147
|
+
thread_posts: ['one', 'two'],
|
|
148
|
+
}, createAccountPlatformResolver(fakeClient(null)));
|
|
149
|
+
assert.equal(payload.platform, 'threads');
|
|
150
|
+
assert.equal(payload.post_type, 'threads_thread');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await check('thread_posts + account_id on a non-thread platform is rejected with a clear error', async () => {
|
|
154
|
+
await assert.rejects(
|
|
155
|
+
() => buildCreatePostPayload({
|
|
156
|
+
account_id: 'acct_ig',
|
|
157
|
+
thread_posts: ['one', 'two'],
|
|
158
|
+
}, createAccountPlatformResolver(fakeClient([{ id: 'acct_ig', platform: 'instagram', username: 'brand' }]))),
|
|
159
|
+
/resolved to platform "instagram"/,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await check('thread_posts + account_id that cannot be found is rejected with a clear error', async () => {
|
|
164
|
+
await assert.rejects(
|
|
165
|
+
() => buildCreatePostPayload({
|
|
166
|
+
account_id: 'does-not-exist',
|
|
167
|
+
thread_posts: ['one', 'two'],
|
|
168
|
+
}, createAccountPlatformResolver(fakeClient([{ id: 'acct_threads', platform: 'threads', username: 'brand' }]))),
|
|
169
|
+
/that exists in list_accounts/,
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// The lookup itself can fail (non-2xx, missing accounts:read scope, network
|
|
174
|
+
// error): the caller needs that real cause, not just a generic "pass
|
|
175
|
+
// platform explicitly" that hides why resolution failed.
|
|
176
|
+
await check('thread_posts + account_id surfaces the real lookup failure, not a generic message', async () => {
|
|
177
|
+
await assert.rejects(
|
|
178
|
+
() => buildCreatePostPayload({
|
|
179
|
+
account_id: 'acct_threads',
|
|
180
|
+
thread_posts: ['one', 'two'],
|
|
181
|
+
}, createAccountPlatformResolver(fakeClient(null, { fail: 'Missing required scope: accounts:read' }))),
|
|
182
|
+
/Missing required scope: accounts:read/,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Batching: a batch of items sharing one workspace_id must cost one
|
|
187
|
+
// accounts lookup, not one per item. create_posts_batch builds a single
|
|
188
|
+
// resolver and reuses it across every buildCreatePostPayload call; this
|
|
189
|
+
// pins that the resolver itself (not the caller) is what dedupes.
|
|
190
|
+
await check('one resolver shared across a batch makes exactly one accounts lookup per workspace_id', async () => {
|
|
191
|
+
const client = fakeClient([
|
|
192
|
+
{ id: 'acct_threads', platform: 'threads', username: 'brand' },
|
|
193
|
+
{ id: 'acct_x', platform: 'twitter', username: 'brand2' },
|
|
194
|
+
]);
|
|
195
|
+
const resolvePlatform = createAccountPlatformResolver(client);
|
|
196
|
+
const [threadsPayload, xPayload, thirdPayload] = await Promise.all([
|
|
197
|
+
buildCreatePostPayload({ account_id: 'acct_threads', workspace_id: 'ws_1', thread_posts: ['a', 'b'] }, resolvePlatform),
|
|
198
|
+
buildCreatePostPayload({ account_id: 'acct_x', workspace_id: 'ws_1', thread_posts: ['c', 'd'] }, resolvePlatform),
|
|
199
|
+
buildCreatePostPayload({ account_id: 'acct_threads', workspace_id: 'ws_1', thread_posts: ['e', 'f'] }, resolvePlatform),
|
|
200
|
+
]);
|
|
201
|
+
assert.equal(threadsPayload.platform, 'threads');
|
|
202
|
+
assert.equal(xPayload.platform, 'twitter');
|
|
203
|
+
assert.equal(thirdPayload.platform, 'threads');
|
|
204
|
+
assert.equal(client.calls(), 1, `expected exactly one accounts lookup for one workspace_id, got ${client.calls()}`);
|
|
205
|
+
});
|
|
206
|
+
|
|
83
207
|
if (failures > 0) {
|
|
84
208
|
console.error(`\n${failures} payload-builder test(s) failed.`);
|
|
85
209
|
process.exit(1);
|
|
86
210
|
}
|
|
87
|
-
console.log(`\nOK payload builders honour the server's "caption or media" rule.`);
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.awpthorp/posterly",
|
|
4
4
|
"title": "posterly",
|
|
5
5
|
"description": "Validate, schedule, publish, and analyze social content across 18 platforms with posterly.",
|
|
6
|
-
"version": "0.45.
|
|
6
|
+
"version": "0.45.1",
|
|
7
7
|
"websiteUrl": "https://www.poster.ly/mcp",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/awpthorp/posterly",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
{
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"identifier": "posterly-mcp-server",
|
|
17
|
-
"version": "0.45.
|
|
17
|
+
"version": "0.45.1",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|
package/src/lib/version.ts
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
// tool set (minus the intentional pre-auth signup tools that only this stdio
|
|
6
6
|
// package exposes). `npm run check:mcp-parity` enforces both the version match
|
|
7
7
|
// and the tool-list match, and runs in the pre-commit hook.
|
|
8
|
-
export const POSTERLY_MCP_VERSION = '0.45.
|
|
8
|
+
export const POSTERLY_MCP_VERSION = '0.45.1';
|
package/src/tools/create-post.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { PosterlyClient } from '../lib/api-client.js';
|
|
2
|
+
import type { PosterlyClient, Account } from '../lib/api-client.js';
|
|
3
3
|
import { dashboardUrlForPost, formatLocalDateTime } from '../lib/format.js';
|
|
4
4
|
import { SUPPORTED_PLATFORM_INPUTS, SUPPORTED_PLATFORM_IDS } from '../generated/platform-manifest.js';
|
|
5
5
|
|
|
@@ -173,7 +173,61 @@ export const createPostInputSchema = createPostPayloadInputSchema.extend({
|
|
|
173
173
|
export type CreatePostInput = z.infer<typeof createPostInputSchema>;
|
|
174
174
|
export type CreatePostPayloadInput = z.infer<typeof createPostPayloadInputSchema>;
|
|
175
175
|
|
|
176
|
-
export
|
|
176
|
+
export type AccountPlatformLookup =
|
|
177
|
+
| { found: true; platform: string | null }
|
|
178
|
+
| { found: false; detail: string };
|
|
179
|
+
|
|
180
|
+
export type AccountPlatformResolver = (accountId: string, workspaceId?: string) => Promise<AccountPlatformLookup>;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Builds a resolver that answers "what platform is this account_id on?" via
|
|
184
|
+
* client.listAccounts() (there is no GET /accounts/:id), for thread_posts
|
|
185
|
+
* calls that gave account_id without platform. Caches one in-flight lookup
|
|
186
|
+
* per workspace_id (including the unscoped list, keyed ''), so
|
|
187
|
+
* create_posts_batch never issues more than one accounts request per
|
|
188
|
+
* distinct workspace_id even though every item in the batch calls this
|
|
189
|
+
* independently: the cache is populated synchronously (before the first
|
|
190
|
+
* await) on the first call for a given workspace_id, so concurrent
|
|
191
|
+
* Promise.all callers for the same workspace_id share the one in-flight
|
|
192
|
+
* request. A failed lookup (non-2xx, missing accounts:read, network error)
|
|
193
|
+
* is cached too, so it is reported once per workspace_id rather than
|
|
194
|
+
* surfacing a fresh error per item.
|
|
195
|
+
*/
|
|
196
|
+
export function createAccountPlatformResolver(client: PosterlyClient): AccountPlatformResolver {
|
|
197
|
+
const cache = new Map<string, Promise<{ ok: true; platforms: Map<string, string> } | { ok: false; detail: string }>>();
|
|
198
|
+
const lookupForWorkspace = (workspaceKey: string) => {
|
|
199
|
+
let pending = cache.get(workspaceKey);
|
|
200
|
+
if (!pending) {
|
|
201
|
+
pending = (async () => {
|
|
202
|
+
try {
|
|
203
|
+
const accounts: Account[] = await client.listAccounts(workspaceKey ? { workspace_id: workspaceKey } : undefined);
|
|
204
|
+
const platforms = new Map<string, string>();
|
|
205
|
+
for (const account of accounts) {
|
|
206
|
+
if (account && typeof account.platform === 'string' && account.id !== undefined && account.id !== null) {
|
|
207
|
+
platforms.set(String(account.id), account.platform);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { ok: true as const, platforms };
|
|
211
|
+
} catch (error: any) {
|
|
212
|
+
return { ok: false as const, detail: error?.message || 'accounts lookup failed' };
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
cache.set(workspaceKey, pending);
|
|
216
|
+
}
|
|
217
|
+
return pending;
|
|
218
|
+
};
|
|
219
|
+
return async (accountId, workspaceId) => {
|
|
220
|
+
const workspaceKey = workspaceId || '';
|
|
221
|
+
const lookup = await lookupForWorkspace(workspaceKey);
|
|
222
|
+
if (!lookup.ok) return { found: false, detail: lookup.detail };
|
|
223
|
+
return { found: true, platform: lookup.platforms.get(String(accountId)) ?? null };
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function buildCreatePostPayload(
|
|
228
|
+
input: CreatePostPayloadInput | CreatePostInput,
|
|
229
|
+
resolvePlatform: AccountPlatformResolver,
|
|
230
|
+
): Promise<Parameters<PosterlyClient['createPost']>[0]> {
|
|
177
231
|
const { confirm: _confirm, thread_posts, caption, post_type, instagram_settings, platform_settings, ...rest } = input as CreatePostInput & CreatePostPayloadInput;
|
|
178
232
|
|
|
179
233
|
if (thread_posts && thread_posts.length > 0) {
|
|
@@ -181,12 +235,33 @@ export function buildCreatePostPayload(input: CreatePostPayloadInput | CreatePos
|
|
|
181
235
|
throw new Error('thread_posts must contain at least 2 entries');
|
|
182
236
|
}
|
|
183
237
|
const platformHint = (input.platform || '').toLowerCase();
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (!isTwitter && !isThreads
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
238
|
+
let isTwitter = platformHint === 'twitter' || platformHint === 'x';
|
|
239
|
+
let isThreads = platformHint === 'threads';
|
|
240
|
+
if (!isTwitter && !isThreads) {
|
|
241
|
+
if (!input.account_id) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
'thread_posts requires `platform` to be "twitter" or "threads", or pass `account_id` for an X/Threads account.',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
// No platform hint: never guess X. Resolve the account's real platform
|
|
247
|
+
// first, so a Threads account_id builds a threads_thread, not an
|
|
248
|
+
// x_thread that the server then rejects.
|
|
249
|
+
const lookup = await resolvePlatform(input.account_id, input.workspace_id);
|
|
250
|
+
if (!lookup.found) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`thread_posts could not resolve account_id's platform (${lookup.detail}). Pass \`platform\` explicitly.`,
|
|
253
|
+
);
|
|
254
|
+
} else if (lookup.platform === 'threads') {
|
|
255
|
+
isThreads = true;
|
|
256
|
+
} else if (lookup.platform === 'twitter') {
|
|
257
|
+
isTwitter = true;
|
|
258
|
+
} else {
|
|
259
|
+
throw new Error(
|
|
260
|
+
lookup.platform
|
|
261
|
+
? `thread_posts requires an X/Twitter or Threads account; account_id resolved to platform "${lookup.platform}".`
|
|
262
|
+
: 'thread_posts requires `platform` to be "twitter" or "threads", or an `account_id` that exists in list_accounts. Pass `platform` explicitly.',
|
|
263
|
+
);
|
|
264
|
+
}
|
|
190
265
|
}
|
|
191
266
|
const arrayKey = isThreads ? 'threads_thread_posts' : 'x_thread_tweets';
|
|
192
267
|
const totalKey = isThreads ? 'threads_thread_total' : 'x_thread_total';
|
|
@@ -250,7 +325,7 @@ export const createPostTool = {
|
|
|
250
325
|
if (input.confirm !== true) {
|
|
251
326
|
throw new Error('Refusing to create post without confirm=true after explicit user confirmation.');
|
|
252
327
|
}
|
|
253
|
-
const payload = buildCreatePostPayload(input);
|
|
328
|
+
const payload = await buildCreatePostPayload(input, createAccountPlatformResolver(client));
|
|
254
329
|
const { thread_posts, instagram_settings, platform_settings } = input;
|
|
255
330
|
|
|
256
331
|
const result = await client.createPost(payload);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { PosterlyClient } from '../lib/api-client.js';
|
|
3
3
|
import { dashboardUrlForPostList, formatLocalDateTime } from '../lib/format.js';
|
|
4
|
-
import { buildCreatePostPayload, createPostPayloadInputSchema, type CreatePostPayloadInput } from './create-post.js';
|
|
4
|
+
import { buildCreatePostPayload, createAccountPlatformResolver, createPostPayloadInputSchema, type CreatePostPayloadInput } from './create-post.js';
|
|
5
5
|
|
|
6
6
|
export const createPostsBatchTool = {
|
|
7
7
|
name: 'create_posts_batch',
|
|
@@ -26,7 +26,18 @@ export const createPostsBatchTool = {
|
|
|
26
26
|
if (input.confirm !== true) {
|
|
27
27
|
throw new Error('Refusing to create batch posts without confirm=true after explicit user confirmation.');
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
// One resolver shared across the whole batch: createAccountPlatformResolver
|
|
30
|
+
// caches its accounts lookup per workspace_id, so N items that all need
|
|
31
|
+
// thread_posts platform resolution for the same workspace still cost one
|
|
32
|
+
// listAccounts() call, not N.
|
|
33
|
+
const resolvePlatform = createAccountPlatformResolver(client);
|
|
34
|
+
const payloads = await Promise.all(input.posts.map(async (post, index) => {
|
|
35
|
+
try {
|
|
36
|
+
return await buildCreatePostPayload(post, resolvePlatform);
|
|
37
|
+
} catch (error: any) {
|
|
38
|
+
throw new Error(`Item ${index}: ${error?.message || 'invalid post input'}`);
|
|
39
|
+
}
|
|
40
|
+
}));
|
|
30
41
|
const result = await client.createPostsBatch({ posts: payloads });
|
|
31
42
|
|
|
32
43
|
const createdPosts = result.posts.map((item) => item.post);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PosterlyClient } from '../lib/api-client.js';
|
|
2
2
|
import {
|
|
3
3
|
buildCreatePostPayload,
|
|
4
|
+
createAccountPlatformResolver,
|
|
4
5
|
createPostPayloadInputSchema,
|
|
5
6
|
} from './create-post.js';
|
|
6
7
|
|
|
@@ -15,7 +16,7 @@ export const validatePostTool = {
|
|
|
15
16
|
inputSchema: validatePostInputSchema,
|
|
16
17
|
|
|
17
18
|
async execute(client: PosterlyClient, input: ValidatePostInput) {
|
|
18
|
-
const payload = buildCreatePostPayload(input);
|
|
19
|
+
const payload = await buildCreatePostPayload(input, createAccountPlatformResolver(client));
|
|
19
20
|
const result = await client.validatePost(payload);
|
|
20
21
|
|
|
21
22
|
const lines = [
|