posterly-mcp-server 0.51.0 → 0.52.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
@@ -123,7 +123,7 @@ Add the same server definition to your Cursor MCP settings:
123
123
 
124
124
  ## Available tools
125
125
 
126
- `posterly-mcp-server@0.51.0` exposes 64 tools covering 100 actions. The hosted HTTP endpoint exposes the same catalog minus the three signup tools: 61 tools covering 97 actions.
126
+ `posterly-mcp-server@0.52.0` exposes 64 tools covering 100 actions. The hosted HTTP endpoint exposes the same catalog minus the three signup tools: 61 tools covering 97 actions.
127
127
 
128
128
  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.
129
129
 
@@ -446,7 +446,7 @@ In 0.45.0, the old tools below moved into the tools above. The old names still w
446
446
 
447
447
  This npm/stdio server can read local file paths. When `upload_media` receives a larger local file, it automatically requests a signed upload URL and uploads the raw bytes before returning the public media URL.
448
448
 
449
- The hosted HTTP MCP endpoint cannot do that for local files by itself because MCP tool calls are JSON-only. On hosted HTTP MCP, `upload_media` is for small base64 uploads up to 5MB decoded. For larger media there, use `upload_media` with `url` for public direct media URLs, or call `create_signed_upload` only from clients that can also `PUT` the raw file bytes to the returned `upload_url`.
449
+ The hosted HTTP MCP endpoint cannot do that for local files by itself because MCP tool calls are JSON-only. On hosted HTTP MCP, `upload_media` is for small base64 uploads up to 5MB decoded. For larger media there, use `upload_media` with `url` for public direct media URLs, or call `create_signed_upload` only from clients that can also `PUT` the raw file bytes to the returned `upload_url`. One call covers 1 to 20 files with `files`; each file gets its own slot (or its own error), and slots expire in 15 minutes.
450
450
 
451
451
  ## What the brand tools are for
452
452
 
@@ -154,10 +154,20 @@ export interface ApiPostApproval {
154
154
  requested_at: string | null;
155
155
  completed_at: string | null;
156
156
  }
157
+ /** Who or what created a post. Any field is null when it was never recorded. */
158
+ export interface ApiPostCreator {
159
+ user_id: string | null;
160
+ name: string | null;
161
+ source: string | null;
162
+ source_label: string | null;
163
+ }
157
164
  export interface Post {
158
165
  id: number;
159
166
  content: string;
160
167
  scheduled_at: string;
168
+ created_at?: string | null;
169
+ updated_at?: string | null;
170
+ created_by?: ApiPostCreator | null;
161
171
  social_account_id: string;
162
172
  media_url?: string;
163
173
  media_urls?: string[];
@@ -1232,6 +1242,33 @@ export interface XPostingQuotaResponse {
1232
1242
  addon?: Record<string, unknown>;
1233
1243
  plans?: Array<Record<string, unknown>>;
1234
1244
  }
1245
+ export type SignedUploadBatchSlot = {
1246
+ index: number;
1247
+ filename: string;
1248
+ ok: true;
1249
+ upload_url: string;
1250
+ token: string;
1251
+ path: string;
1252
+ public_url: string;
1253
+ expires_at: string;
1254
+ max_size: number;
1255
+ headers?: Record<string, string>;
1256
+ provider?: string;
1257
+ } | {
1258
+ index: number;
1259
+ filename: string;
1260
+ ok: false;
1261
+ error: string;
1262
+ code: string;
1263
+ };
1264
+ export interface SignedUploadBatchResponse {
1265
+ slots: SignedUploadBatchSlot[];
1266
+ summary: {
1267
+ requested: number;
1268
+ ok: number;
1269
+ failed: number;
1270
+ };
1271
+ }
1235
1272
  export declare class PosterlyClient {
1236
1273
  private baseUrl;
1237
1274
  private apiKey;
@@ -1673,6 +1710,11 @@ export declare class PosterlyClient {
1673
1710
  public_url: string;
1674
1711
  headers?: Record<string, string>;
1675
1712
  }>;
1713
+ getSignedUploadUrls(files: Array<{
1714
+ filename: string;
1715
+ content_type: string;
1716
+ size: number;
1717
+ }>, workspaceId?: string): Promise<SignedUploadBatchResponse>;
1676
1718
  createMediaDrop(input?: {
1677
1719
  filename?: string;
1678
1720
  maxFiles?: number;
@@ -673,6 +673,12 @@ export class PosterlyClient {
673
673
  ...(workspaceId ? { workspace_id: workspaceId } : {}),
674
674
  });
675
675
  }
676
+ async getSignedUploadUrls(files, workspaceId) {
677
+ return this.request('POST', '/media/signed-upload', {
678
+ files,
679
+ ...(workspaceId ? { workspace_id: workspaceId } : {}),
680
+ });
681
+ }
676
682
  async createMediaDrop(input) {
677
683
  return this.request('POST', '/media/drop-sessions', {
678
684
  filename: input?.filename,
@@ -68,6 +68,10 @@ export declare function formatCalendarDay(day: string): string;
68
68
  * day before); anything else reads as "from X up to Y".
69
69
  */
70
70
  export declare function formatPostRange(range: PostListRange): string;
71
+ /** ISO 8601 in UTC to the second ("2026-10-07T07:32:54Z"); "unknown" when missing. */
72
+ export declare function formatIsoTimestamp(value: string | null | undefined): string;
73
+ /** "Georgia via Dashboard"; whichever half was never recorded reads as unknown. */
74
+ export declare function formatPostCreator(creator: Post['created_by']): string;
71
75
  /**
72
76
  * One page of list_posts. Always says whether every matching post is shown,
73
77
  * and when it is not, exactly which offset to call next, so an agent never
@@ -304,6 +304,27 @@ export function formatPostRange(range) {
304
304
  const to = range.to ? `up to ${formatDateTimeInZone(range.to, tz)}` : '';
305
305
  return `${[from, to].filter(Boolean).join(' ')} (${tz})`;
306
306
  }
307
+ /** ISO 8601 in UTC to the second ("2026-10-07T07:32:54Z"); "unknown" when missing. */
308
+ export function formatIsoTimestamp(value) {
309
+ if (!value)
310
+ return 'unknown';
311
+ const date = new Date(value);
312
+ if (Number.isNaN(date.getTime()))
313
+ return value;
314
+ return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
315
+ }
316
+ /** "Georgia via Dashboard"; whichever half was never recorded reads as unknown. */
317
+ export function formatPostCreator(creator) {
318
+ const who = creator?.name || (creator?.user_id ? `user ${creator.user_id}` : null);
319
+ const via = creator?.source_label || null;
320
+ if (who && via)
321
+ return `${who} via ${via}`;
322
+ if (who)
323
+ return `${who} (source unknown)`;
324
+ if (via)
325
+ return `via ${via} (account unknown)`;
326
+ return 'unknown';
327
+ }
307
328
  function approvalCell(post) {
308
329
  if (!post.approval || post.approval.status === 'none')
309
330
  return 'none';
@@ -340,7 +361,7 @@ export function formatPostListPage(data, options) {
340
361
  : first === 1
341
362
  ? `**Complete: all ${total} matching posts are shown.**`
342
363
  : `**Complete: this is the last page (posts ${first}-${last} of ${total}).**`;
343
- const table = mdTable(['Status', 'Post ID', `Scheduled (${tz})`, 'Type', 'Account ID', 'Brand ID', 'Approval', 'Caption'],
364
+ const table = mdTable(['Status', 'Post ID', `Scheduled (${tz})`, 'Type', 'Account ID', 'Brand ID', 'Approval', 'Created', 'Created by', 'Caption'],
344
365
  // The API post row carries account and brand IDs only (no joined names);
345
366
  // list_accounts and list_brands, or view=counts, resolve them.
346
367
  posts.map((post) => [
@@ -351,6 +372,8 @@ export function formatPostListPage(data, options) {
351
372
  post.social_account_id != null ? String(post.social_account_id) : 'n/a',
352
373
  post.brand_id || 'none',
353
374
  approvalCell(post),
375
+ formatIsoTimestamp(post.created_at),
376
+ formatPostCreator(post.created_by),
354
377
  compactText(post.content, 80) || '(empty)',
355
378
  ]));
356
379
  return [
@@ -1 +1 @@
1
- export declare const POSTERLY_MCP_VERSION = "0.51.0";
1
+ export declare const POSTERLY_MCP_VERSION = "0.52.0";
@@ -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.51.0';
8
+ export const POSTERLY_MCP_VERSION = '0.52.0';
@@ -1,16 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  export const createMediaDropTool = {
3
3
  name: 'create_media_drop',
4
- description: 'Create a human drop page so ChatGPT or Claude users can upload a laptop file without dashboard login. Returns drop_url (https://www.poster.ly/drop/<token>). Send that URL to the user, wait, then call list_media. This is not the chat paperclip and not create_signed_upload.',
4
+ description: 'Create a human drop page so ChatGPT or Claude users can upload laptop files (up to 20 per link) without dashboard login. Returns drop_url (https://www.poster.ly/drop/<token>). Send that URL to the user, wait, then call list_media. This is not the chat paperclip and not create_signed_upload.',
5
5
  inputSchema: z.object({
6
6
  filename: z.string().optional().describe('Optional filename hint for the user, e.g. launch-video.mp4'),
7
7
  max_files: z
8
8
  .number()
9
9
  .int()
10
10
  .min(1)
11
- .max(10)
11
+ .max(20)
12
12
  .optional()
13
- .describe('How many files the drop can accept. Default 10, max 10.'),
13
+ .describe('How many files the drop can accept. Default 10, max 20.'),
14
14
  workspace_id: z.string().optional().describe('Optional. The workspace this acts in and whose plan pays (from whoami). Needed only when your connection can act in several workspaces.'),
15
15
  }),
16
16
  async execute(client, input) {
@@ -1,28 +1,61 @@
1
1
  import { z } from 'zod';
2
- import type { PosterlyClient } from '../lib/api-client.js';
2
+ import type { PosterlyClient, SignedUploadBatchResponse } from '../lib/api-client.js';
3
+ type SignedUploadInput = {
4
+ filename?: string;
5
+ content_type?: string;
6
+ size?: number;
7
+ files?: Array<{
8
+ filename: string;
9
+ content_type: string;
10
+ size: number;
11
+ }>;
12
+ workspace_id?: string;
13
+ };
14
+ export declare function formatSignedUploadBatch(batch: SignedUploadBatchResponse, requested: Array<{
15
+ content_type: string;
16
+ }>): string;
3
17
  export declare const createSignedUploadTool: {
4
18
  name: string;
5
19
  description: string;
6
20
  inputSchema: z.ZodObject<{
7
- filename: z.ZodString;
8
- content_type: z.ZodString;
9
- size: z.ZodNumber;
21
+ filename: z.ZodOptional<z.ZodString>;
22
+ content_type: z.ZodOptional<z.ZodString>;
23
+ size: z.ZodOptional<z.ZodNumber>;
24
+ files: z.ZodOptional<z.ZodArray<z.ZodObject<{
25
+ filename: z.ZodString;
26
+ content_type: z.ZodString;
27
+ size: z.ZodNumber;
28
+ }, "strip", z.ZodTypeAny, {
29
+ filename: string;
30
+ content_type: string;
31
+ size: number;
32
+ }, {
33
+ filename: string;
34
+ content_type: string;
35
+ size: number;
36
+ }>, "many">>;
10
37
  workspace_id: z.ZodOptional<z.ZodString>;
11
38
  }, "strip", z.ZodTypeAny, {
12
- filename: string;
13
- content_type: string;
14
- size: number;
15
39
  workspace_id?: string | undefined;
40
+ filename?: string | undefined;
41
+ content_type?: string | undefined;
42
+ size?: number | undefined;
43
+ files?: {
44
+ filename: string;
45
+ content_type: string;
46
+ size: number;
47
+ }[] | undefined;
16
48
  }, {
17
- filename: string;
18
- content_type: string;
19
- size: number;
20
49
  workspace_id?: string | undefined;
50
+ filename?: string | undefined;
51
+ content_type?: string | undefined;
52
+ size?: number | undefined;
53
+ files?: {
54
+ filename: string;
55
+ content_type: string;
56
+ size: number;
57
+ }[] | undefined;
21
58
  }>;
22
- execute(client: PosterlyClient, input: {
23
- filename: string;
24
- content_type: string;
25
- size: number;
26
- workspace_id?: string;
27
- }): Promise<string>;
59
+ execute(client: PosterlyClient, input: SignedUploadInput): Promise<string>;
28
60
  };
61
+ export {};
@@ -1,14 +1,71 @@
1
1
  import { z } from 'zod';
2
+ const fileSchema = z.object({
3
+ filename: z.string().describe('Filename with extension, e.g. launch-video.mp4'),
4
+ content_type: z.string().describe('MIME type, e.g. video/mp4'),
5
+ size: z.number().int().positive().describe('File size in bytes'),
6
+ });
7
+ export function formatSignedUploadBatch(batch, requested) {
8
+ const lines = [
9
+ `Signed upload slots: ${batch.summary.requested} requested, ${batch.summary.ok} ready, ${batch.summary.failed} failed.`,
10
+ '',
11
+ ];
12
+ let earliestExpiry = null;
13
+ batch.slots.forEach((slot, position) => {
14
+ const number = position + 1;
15
+ if (slot.ok) {
16
+ const headers = slot.headers || {};
17
+ const headerKeys = Object.keys(headers);
18
+ const contentTypeKey = headerKeys.find((key) => key.toLowerCase() === 'content-type');
19
+ const contentType = contentTypeKey ? headers[contentTypeKey] : requested[slot.index]?.content_type;
20
+ lines.push(`${number}. ${slot.filename}`);
21
+ lines.push(` PUT ${slot.upload_url}`);
22
+ if (contentType)
23
+ lines.push(` Content-Type: ${contentType}`);
24
+ for (const key of headerKeys) {
25
+ if (key === contentTypeKey)
26
+ continue;
27
+ lines.push(` ${key}: ${headers[key]}`);
28
+ }
29
+ lines.push(` Public URL after upload: ${slot.public_url}`);
30
+ if (slot.expires_at && (!earliestExpiry || slot.expires_at < earliestExpiry)) {
31
+ earliestExpiry = slot.expires_at;
32
+ }
33
+ }
34
+ else {
35
+ lines.push(`${number}. ${slot.filename || '(no filename)'}`);
36
+ lines.push(` Failed (${slot.code}): ${slot.error}`);
37
+ }
38
+ lines.push('');
39
+ });
40
+ lines.push(`PUT each file, then pass public_url to create_post / create_posts_batch. Slots expire at ${earliestExpiry ?? 'the expires_at time returned (15 minutes)'}, call again for fresh slots.`);
41
+ return lines.join('\n');
42
+ }
2
43
  export const createSignedUploadTool = {
3
44
  name: 'create_signed_upload',
4
- description: 'Create a signed upload URL for a larger image or video. PUT the raw bytes to the returned upload_url (object storage) with the returned headers, then public_url can be used with create_post.',
45
+ description: 'Create signed upload URLs for larger images or videos, 1 to 20 files per call. For one file pass filename, content_type and size. For several pass files: [{filename, content_type, size}] (up to 20) and get one slot per file, with any file that fails reported in place. PUT the raw bytes of each file to its upload_url (object storage) with the returned headers, then pass public_url to create_post / create_posts_batch. Slots expire in 15 minutes.',
5
46
  inputSchema: z.object({
6
- filename: z.string().describe('Filename with extension, e.g. launch-video.mp4'),
7
- content_type: z.string().describe('MIME type, e.g. video/mp4'),
8
- size: z.number().int().positive().describe('File size in bytes'),
47
+ filename: z.string().optional().describe('Single file: filename with extension, e.g. launch-video.mp4. Omit when using files.'),
48
+ content_type: z.string().optional().describe('Single file: MIME type, e.g. video/mp4. Omit when using files.'),
49
+ size: z.number().int().positive().optional().describe('Single file: size in bytes. Omit when using files.'),
50
+ files: z
51
+ .array(fileSchema)
52
+ .min(1)
53
+ .max(20)
54
+ .optional()
55
+ .describe('Batch: 1 to 20 files, each with filename, content_type and size. Use instead of the single-file fields.'),
9
56
  workspace_id: z.string().optional().describe('Optional. The workspace this acts in and whose plan pays (from whoami). Needed only when your connection can act in several workspaces.'),
10
57
  }),
11
58
  async execute(client, input) {
59
+ if (input.files !== undefined) {
60
+ if (input.filename !== undefined || input.content_type !== undefined || input.size !== undefined) {
61
+ throw new Error('Pass either files or the single-file fields (filename, content_type, size), not both.');
62
+ }
63
+ const batch = await client.getSignedUploadUrls(input.files, input.workspace_id);
64
+ return formatSignedUploadBatch(batch, input.files);
65
+ }
66
+ if (!input.filename || !input.content_type || !input.size) {
67
+ throw new Error('Provide filename, content_type and size for one file, or files for a batch of up to 20.');
68
+ }
12
69
  const signed = await client.getSignedUploadUrl(input.filename, input.content_type, input.size, input.workspace_id);
13
70
  return [
14
71
  'Signed upload created.',
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { dashboardUrlForPost, formatLocalDateTime } from '../lib/format.js';
2
+ import { dashboardUrlForPost, formatIsoTimestamp, formatLocalDateTime, formatPostCreator } from '../lib/format.js';
3
3
  import { validateArgs } from '../lib/tool-runtime.js';
4
4
  export const getPostTool = {
5
5
  name: 'get_post',
6
- description: 'One post by ID. view=post (default): caption, status, scheduled time, media, and platform info. view=missing: whether the post is missing required content, media, account, platform settings, or metadata before it can publish; use it to repair failed or imported posts.',
6
+ description: 'One post by ID. view=post (default): caption, status, scheduled time, when it was created and by whom (account and channel, such as dashboard, API, or MCP), media, and platform info. view=missing: whether the post is missing required content, media, account, platform settings, or metadata before it can publish; use it to repair failed or imported posts.',
7
7
  inputSchema: z.object({
8
8
  post_id: z.number().describe('The post ID to look up'),
9
9
  view: z.enum(['post', 'missing']).optional().describe('post (default) or missing.'),
@@ -28,8 +28,14 @@ async function post(client, input) {
28
28
  `• Caption: ${p.content || '(empty)'}`,
29
29
  `• Type: ${p.post_type || 'unknown'}`,
30
30
  `• Scheduled: ${formatLocalDateTime(p.scheduled_at)}`,
31
- `• View in posterly: ${dashboardUrlForPost({ id: p.id, scheduled_at: p.scheduled_at })}`,
32
31
  ];
32
+ if ('created_at' in p)
33
+ lines.push(`• Created: ${formatIsoTimestamp(p.created_at)}`);
34
+ if ('created_by' in p)
35
+ lines.push(`• Created by: ${formatPostCreator(p.created_by)}`);
36
+ if ('updated_at' in p)
37
+ lines.push(`• Last updated: ${formatIsoTimestamp(p.updated_at)}`);
38
+ lines.push(`• View in posterly: ${dashboardUrlForPost({ id: p.id, scheduled_at: p.scheduled_at })}`);
33
39
  if (p.platform_post_url)
34
40
  lines.push(`• Published URL: ${p.platform_post_url}`);
35
41
  if (p.media_url)
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { formatPagingNote } from '../lib/format.js';
3
3
  export const listMediaTool = {
4
4
  name: 'list_media',
5
- description: 'List the newest media assets for the authenticated user. After create_media_drop, pass drop_session_id to see files the human uploaded on /drop/<token>. Use public_url with validate_post / create_post. The output says whether every matching file is shown; page with offset (offset + limit up to 1000) when it is not.',
5
+ description: 'List the newest media assets for the authenticated user. After create_media_drop, pass drop_session_id to see files the human uploaded on /drop/<token>. Use public_url with validate_post / create_post. A drop listing returns up to 20 files per page by default so a full drop fits one page. The output says whether every matching file is shown; page with offset (offset + limit up to 1000) when it is not.',
6
6
  inputSchema: z.object({
7
7
  limit: z
8
8
  .number()
@@ -10,7 +10,7 @@ export const listMediaTool = {
10
10
  .min(1)
11
11
  .max(50)
12
12
  .optional()
13
- .describe('Number of assets to return (default 10, max 50)'),
13
+ .describe('Number of assets to return (default 10, or 20 when drop_session_id is set; max 50)'),
14
14
  offset: z
15
15
  .number()
16
16
  .int()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
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",
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.51.0",
6
+ "version": "0.52.0",
7
7
  "websiteUrl": "https://www.poster.ly/mcp",
8
8
  "repository": {
9
9
  "url": "https://github.com/awpthorp/posterly-mcp-server",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "posterly-mcp-server",
16
- "version": "0.51.0",
16
+ "version": "0.52.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },