posterly-mcp-server 0.45.3 → 0.47.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.
Files changed (45) hide show
  1. package/README.md +6 -4
  2. package/dist/lib/api-client.d.ts +29 -2
  3. package/dist/lib/api-client.js +18 -4
  4. package/dist/lib/version.d.ts +1 -1
  5. package/dist/lib/version.js +1 -1
  6. package/dist/tools/create-media-drop.d.ts +4 -0
  7. package/dist/tools/create-media-drop.js +2 -0
  8. package/dist/tools/create-post.d.ts +6 -6
  9. package/dist/tools/create-posts-batch.d.ts +4 -4
  10. package/dist/tools/create-signed-upload.d.ts +4 -0
  11. package/dist/tools/create-signed-upload.js +2 -1
  12. package/dist/tools/delete-post.d.ts +4 -0
  13. package/dist/tools/delete-post.js +10 -5
  14. package/dist/tools/generate-image.d.ts +4 -0
  15. package/dist/tools/generate-image.js +1 -0
  16. package/dist/tools/generate-video.d.ts +3 -0
  17. package/dist/tools/generate-video.js +1 -0
  18. package/dist/tools/list-analytics.js +4 -1
  19. package/dist/tools/list-posts.d.ts +4 -0
  20. package/dist/tools/list-posts.js +5 -1
  21. package/dist/tools/list-workspace-members.d.ts +10 -0
  22. package/dist/tools/list-workspace-members.js +20 -2
  23. package/dist/tools/manage-workspace-member.d.ts +4 -0
  24. package/dist/tools/manage-workspace-member.js +17 -4
  25. package/dist/tools/update-post.d.ts +14 -2
  26. package/dist/tools/update-post.js +27 -1
  27. package/dist/tools/upload-media.d.ts +4 -0
  28. package/dist/tools/upload-media.js +3 -0
  29. package/dist/tools/validate-post.d.ts +4 -4
  30. package/package.json +1 -1
  31. package/server.json +2 -2
  32. package/src/lib/api-client.ts +43 -3
  33. package/src/lib/version.ts +1 -1
  34. package/src/tools/create-media-drop.ts +3 -1
  35. package/src/tools/create-signed-upload.ts +3 -2
  36. package/src/tools/delete-post.ts +16 -7
  37. package/src/tools/generate-image.ts +2 -0
  38. package/src/tools/generate-video.ts +1 -0
  39. package/src/tools/list-analytics.ts +4 -1
  40. package/src/tools/list-posts.ts +6 -1
  41. package/src/tools/list-workspace-members.ts +16 -2
  42. package/src/tools/manage-workspace-member.ts +16 -4
  43. package/src/tools/update-post.ts +46 -1
  44. package/src/tools/upload-media.ts +7 -2
  45. package/tool-registry.json +46 -0
@@ -9,13 +9,14 @@ export const createSignedUploadTool = {
9
9
  filename: z.string().describe('Filename with extension, e.g. launch-video.mp4'),
10
10
  content_type: z.string().describe('MIME type, e.g. video/mp4'),
11
11
  size: z.number().int().positive().describe('File size in bytes'),
12
+ 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.'),
12
13
  }),
13
14
 
14
15
  async execute(
15
16
  client: PosterlyClient,
16
- input: { filename: string; content_type: string; size: number },
17
+ input: { filename: string; content_type: string; size: number; workspace_id?: string },
17
18
  ) {
18
- const signed = await client.getSignedUploadUrl(input.filename, input.content_type, input.size);
19
+ const signed = await client.getSignedUploadUrl(input.filename, input.content_type, input.size, input.workspace_id);
19
20
  return [
20
21
  'Signed upload created.',
21
22
  `• Upload URL: ${signed.upload_url}`,
@@ -6,6 +6,7 @@ import { previewThenConfirm, validateArgs, type ToolContext } from '../lib/tool-
6
6
  type DeletePostInput = {
7
7
  post_id?: number;
8
8
  group_id?: string;
9
+ confirm_published?: boolean;
9
10
  confirm?: boolean;
10
11
  preview_id?: string;
11
12
  };
@@ -13,10 +14,14 @@ type DeletePostInput = {
13
14
  export const deletePostTool = {
14
15
  name: 'delete_post',
15
16
  description:
16
- 'Delete posts. DESTRUCTIVE and IRREVERSIBLE: deleted posts and captions cannot be recovered, and published or currently publishing posts are never deleted. post_id: delete one scheduled or draft post; fetch it with `get_post` first, show the user what will be deleted (caption, account, scheduled time), and pass confirm=true only after explicit confirmation ("yes delete it", "remove it"). group_id: delete every draft, scheduled, failed, or paused post matching a caller-defined group_id, post_group_id, api_group_id, or release_id; preview then confirm: call without confirm to get a preview and preview_id, show it, then call again with confirm=true and that preview_id. Never delete several posts one by one without listing each and confirming the full list.',
17
+ 'Delete posts. DESTRUCTIVE and IRREVERSIBLE: deleted posts and captions cannot be recovered. post_id: delete one post; fetch it with `get_post` first, show the user what will be deleted (caption, account, scheduled time), and pass confirm=true only after explicit confirmation ("yes delete it", "remove it"). A post that is currently publishing is never deleted. A published post is taken down only when the user also explicitly confirms the live removal and you pass confirm_published=true. Instagram and Facebook are attempted when the stored id and token allow it. Every other platform, or a live URL with no id, returns platform_delete_unsupported and the posterly row is kept. A published post with no live URL and no platform id is removed from posterly only. group_id: delete every draft, scheduled, failed, or paused post matching a caller-defined group_id, post_group_id, api_group_id, or release_id. A group delete never removes published posts. Preview then confirm: call without confirm to get a preview and preview_id, show it, then call again with confirm=true and that preview_id. Never delete several posts one by one without listing each and confirming the full list.',
17
18
  inputSchema: z.object({
18
19
  post_id: z.number().optional().describe('Delete this one post.'),
19
- group_id: z.string().min(1).max(160).optional().describe('Delete every unpublished post in this group or release instead.'),
20
+ group_id: z.string().min(1).max(160).optional().describe('Delete every unpublished post in this group or release instead. Never deletes published posts.'),
21
+ confirm_published: z
22
+ .boolean()
23
+ .optional()
24
+ .describe('With post_id only. True only after the user explicitly confirms taking a live Instagram or Facebook post down. Without it, a published post is refused and kept.'),
20
25
  confirm: z
21
26
  .boolean()
22
27
  .optional()
@@ -44,7 +49,7 @@ export const deletePostTool = {
44
49
  // GET /api/v1/posts has no group filter, so the preview says in plain
45
50
  // words which posts match instead of listing them.
46
51
  describe: async () => [
47
- `Deletes every draft, scheduled, failed, or paused post whose group_id, post_group_id, api_group_id, or release_id is ${code(args.group_id)}. Published posts are never deleted. This cannot be undone.`,
52
+ `Deletes every draft, scheduled, failed, or paused post whose group_id, post_group_id, api_group_id, or release_id is ${code(args.group_id)}. A group delete never removes published posts. This cannot be undone.`,
48
53
  'To see the posts first, call list_posts and check each post\'s group or release ID.',
49
54
  ].join('\n\n'),
50
55
  run: async () => {
@@ -53,17 +58,21 @@ export const deletePostTool = {
53
58
  },
54
59
  });
55
60
  }
56
- return deleteOne(client, args as { post_id: number; confirm?: boolean });
61
+ return deleteOne(client, args as { post_id: number; confirm?: boolean; confirm_published?: boolean });
57
62
  },
58
63
  };
59
64
 
60
- async function deleteOne(client: PosterlyClient, input: { post_id: number; confirm?: boolean }): Promise<string> {
65
+ async function deleteOne(
66
+ client: PosterlyClient,
67
+ input: { post_id: number; confirm?: boolean; confirm_published?: boolean },
68
+ ): Promise<string> {
61
69
  if (input.confirm !== true) {
62
70
  throw new Error('Refusing to delete post without confirm=true after explicit user confirmation.');
63
71
  }
64
- const result = await client.deletePost(input.post_id);
72
+ const result = await client.deletePost(input.post_id, { confirmPublished: input.confirm_published === true });
73
+ const platformDelete = result.platform_delete ? ` Platform delete: ${result.platform_delete}.` : '';
65
74
  return [
66
- `Post #${result.id} deleted successfully.`,
75
+ `Post #${result.id} deleted successfully.${platformDelete}`,
67
76
  'It has been removed from the posterly queue.',
68
77
  `View queue in posterly: ${dashboardUrlForPosts()}`,
69
78
  '',
@@ -65,6 +65,7 @@ export const generateImageTool = {
65
65
  .max(14)
66
66
  .optional()
67
67
  .describe('Google only. Up to 14 HTTPS image URLs used as references (logo, product, style lock). Same as dashboard reference images.'),
68
+ 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.'),
68
69
  confirm: z.literal(true).describe('Must be true after explicit user confirmation of subject, style, aspect ratio, quality, and credit use.'),
69
70
  }),
70
71
 
@@ -81,6 +82,7 @@ export const generateImageTool = {
81
82
  quality?: 'low' | 'medium' | 'high';
82
83
  thinking_level?: 'minimal' | 'high';
83
84
  reference_image_urls?: string[];
85
+ workspace_id?: string;
84
86
  confirm: true;
85
87
  }
86
88
  ) {
@@ -21,6 +21,7 @@ export const generateVideoTool = {
21
21
  })).max(3).optional(),
22
22
  generate_audio: z.boolean().default(true).optional().describe('Google can disable generated audio. xAI Grok includes audio and requires this to remain true.'),
23
23
  source_video_url: z.string().url().optional(),
24
+ 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.'),
24
25
  confirm: z.literal(true).describe('Must be true after explicit user confirmation of prompt, model, duration, resolution, and credit cost.'),
25
26
  }),
26
27
 
@@ -29,7 +29,7 @@ type ListAnalyticsInput = {
29
29
  export const listAnalyticsTool = {
30
30
  name: 'list_analytics',
31
31
  description:
32
- 'Analytics for connected social accounts (Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads). view=accounts (account_id): daily snapshots and a period summary with platform-native display_metrics labels. view=posts (account_id): per-post likes, comments/replies, reach, impressions/views, saves, shares, reposts, quotes, plays, clicks, and watch time, most recent first, with limit/offset. view=insights: per-post feedback insights with a performance tier (great/good/mixed/poor), diagnosis, next action, and baseline, filtered by account_id, post_id, or checkpoint (1h, 6h, 24h, 72h, 7d); read-only, Pro plan or higher. presentation (accounts and posts): compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
32
+ 'Analytics for connected social accounts (Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads). view=accounts (account_id): daily snapshots and a period summary with platform-native display_metrics labels. Google Business Profile account analytics includes Calls, Website clicks, and Directions along with Profile Views, Search Views, Maps Views, Customer Actions, and Posts. view=posts (account_id): per-post likes, comments/replies, reach, impressions/views, saves, shares, reposts, quotes, plays, clicks, and watch time, most recent first, with limit/offset. view=insights: per-post feedback insights with a performance tier (great/good/mixed/poor), diagnosis, next action, and baseline, filtered by account_id, post_id, or checkpoint (1h, 6h, 24h, 72h, 7d); read-only, Pro plan or higher. presentation (accounts and posts): compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
33
33
  inputSchema: z.object({
34
34
  view: z.enum(['accounts', 'posts', 'insights']).describe('accounts, posts, or insights.'),
35
35
  account_id: z
@@ -194,6 +194,9 @@ function accountMetricRows(summary: AccountAnalyticsSummary, platform: string):
194
194
  ['Search Views', formatNumber(metrics.search_views ?? summary.total_reach)],
195
195
  ['Maps Views', formatNumber(metrics.maps_views ?? summary.total_profile_views)],
196
196
  ['Customer Actions', formatNumber(metrics.customer_actions ?? summary.total_accounts_engaged)],
197
+ ['Calls', formatNumber(metrics.call_clicks ?? summary.total_call_clicks)],
198
+ ['Directions', formatNumber(metrics.direction_requests ?? summary.total_direction_requests)],
199
+ ['Website clicks', formatNumber(metrics.website_clicks ?? summary.total_website_clicks)],
197
200
  ['Posts', formatNumber(metrics.posts ?? summary.current_media_count)],
198
201
  ];
199
202
  }
@@ -6,7 +6,7 @@ import { SUPPORTED_PLATFORM_INPUTS, SUPPORTED_PLATFORM_IDS } from '../generated/
6
6
  export const listPostsTool = {
7
7
  name: 'list_posts',
8
8
  description:
9
- 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, account_id, workspace_id, approval_status, or brand_id. Returns posts in your workspaces, teammates\' too, per your role there (brand-locked seats only see their assigned brands). If workspace_id is omitted, this searches every workspace you belong to.',
9
+ 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, account_id, workspace_id, approval_status, approval_waiting_on, or brand_id. approval_waiting_on=client is the same queue as approval_status=pending_client. Each post includes approval.pending_client_approval. Returns posts in your workspaces, teammates\' too, per your role there (brand-locked seats only see their assigned brands). If workspace_id is omitted, this searches every workspace you belong to.',
10
10
  inputSchema: z.object({
11
11
  status: z
12
12
  .string()
@@ -34,6 +34,10 @@ export const listPostsTool = {
34
34
  .enum(['none', 'pending_client', 'approved', 'changes_requested', 'rejected'])
35
35
  .optional()
36
36
  .describe('Filter by client review approval status. pending_client means the post is waiting on the client.'),
37
+ approval_waiting_on: z
38
+ .enum(['client', 'team'])
39
+ .optional()
40
+ .describe('client: approval_status pending_client. team: changes_requested or rejected. Conflicts with a non-overlapping approval_status.'),
37
41
  brand_id: z
38
42
  .string()
39
43
  .optional()
@@ -49,6 +53,7 @@ export const listPostsTool = {
49
53
  limit?: number;
50
54
  workspace_id?: string;
51
55
  approval_status?: string;
56
+ approval_waiting_on?: 'client' | 'team';
52
57
  brand_id?: string;
53
58
  }
54
59
  ) {
@@ -7,6 +7,19 @@ export function formatBrandIds(value: string[] | null | undefined): string {
7
7
  return value.length === 0 ? 'every brand' : value.map((id) => code(id)).join(', ');
8
8
  }
9
9
 
10
+ /** API and AI app access for one member (issue #1170). */
11
+ export function formatApiAccess(member: { role?: string | null; api_access?: boolean; api_usage?: { key_count: number; last_used_at: string | null; connected_apps: string[] } }): string {
12
+ if (member.api_access === undefined) return '';
13
+ const base = member.role === 'owner' ? 'owner' : member.api_access ? 'on' : 'off';
14
+ const usage = member.api_usage;
15
+ if (!usage) return base;
16
+ const parts: string[] = [];
17
+ if (usage.key_count > 0) parts.push(`${usage.key_count} key${usage.key_count === 1 ? '' : 's'}`);
18
+ if (usage.connected_apps.length > 0) parts.push(usage.connected_apps.join(', '));
19
+ if (usage.last_used_at) parts.push(`last used ${usage.last_used_at}`);
20
+ return parts.length > 0 ? `${base} (${parts.join('; ')})` : base;
21
+ }
22
+
10
23
  function formatWorkspaceMembers(result: WorkspaceMembersResponse): string {
11
24
  const members = result.members || [];
12
25
  const invites = result.invites || [];
@@ -14,7 +27,7 @@ function formatWorkspaceMembers(result: WorkspaceMembersResponse): string {
14
27
  mdTitle(`Team (${members.length} members, ${invites.length} pending invites)`, `Workspace ${result.workspace_id}`),
15
28
  mdSection('Members', members.length > 0
16
29
  ? mdTable(
17
- ['Name', 'Member ID', 'Email', 'Role', 'Status', 'Brands'],
30
+ ['Name', 'Member ID', 'Email', 'Role', 'Status', 'Brands', 'API access'],
18
31
  members.map((member) => [
19
32
  member.name || member.email || member.id,
20
33
  code(member.id),
@@ -22,6 +35,7 @@ function formatWorkspaceMembers(result: WorkspaceMembersResponse): string {
22
35
  member.role,
23
36
  member.status,
24
37
  formatBrandIds(member.brand_ids),
38
+ formatApiAccess(member),
25
39
  ]),
26
40
  )
27
41
  : 'No members found.'),
@@ -48,7 +62,7 @@ function formatWorkspaceMembers(result: WorkspaceMembersResponse): string {
48
62
  export const listWorkspaceMembersTool = {
49
63
  name: 'list_workspace_members',
50
64
  description:
51
- 'List the people in a workspace: members (with role, status and brand access) and pending invites, plus seats in use. Needs an API key with the Manage team permission. Any workspace member can list; brand-limited teammates only see brand ids they can access.',
65
+ 'List the people in a workspace: members (with role, status, brand access and API access) and pending invites, plus seats in use. API access says whether a teammate can use this workspace\'s API plan from their own keys and AI apps; owners and admins also see each member\'s key count, connected AI apps and last use. Needs an API key with the Manage team permission. Any workspace member can list; brand-limited teammates only see brand ids they can access.',
52
66
  inputSchema: z.object({
53
67
  workspace_id: z.string().describe('Workspace ID (from whoami).'),
54
68
  }),
@@ -5,7 +5,7 @@ import type {
5
5
  WorkspaceMemberRole,
6
6
  } from '../lib/api-client.js';
7
7
  import { code, mdKeyValue, mdSection, mdTitle } from '../lib/format.js';
8
- import { formatBrandIds } from './list-workspace-members.js';
8
+ import { formatApiAccess, formatBrandIds } from './list-workspace-members.js';
9
9
 
10
10
  const ACTIONS = ['invite', 'update', 'remove'] as const;
11
11
  const ROLES = ['publisher', 'editor', 'viewer'] as const;
@@ -19,6 +19,7 @@ export type ManageWorkspaceMemberInput = {
19
19
  resend?: boolean;
20
20
  member_id?: string;
21
21
  invite_id?: string;
22
+ api_access?: boolean;
22
23
  confirm?: boolean;
23
24
  preview_id?: string;
24
25
  };
@@ -49,6 +50,9 @@ function formatChange(result: WorkspaceMemberChangeResponse): string {
49
50
  ['Seats', preview.counts_toward_seats && preview.seat_limit != null
50
51
  ? `${preview.seats_used} in use of ${preview.seat_limit === -1 ? 'unlimited' : preview.seat_limit}`
51
52
  : ''],
53
+ ['API access', typeof preview.api_access === 'boolean'
54
+ ? `${preview.previous_api_access ? 'on' : 'off'} -> ${preview.api_access ? 'on' : 'off'}`
55
+ : ''],
52
56
  ])),
53
57
  mdSection('Preview ID', `${code(result.preview_id || '')} (expires ${result.preview_expires_at || 'soon'})`),
54
58
  mdSection('Next step', 'Show this preview to the user. Only after they explicitly approve, call manage_workspace_member again with the same fields plus confirm: true and this preview_id.'),
@@ -64,6 +68,7 @@ function formatChange(result: WorkspaceMemberChangeResponse): string {
64
68
  ['Role', subject?.role],
65
69
  ['Status', subject?.status],
66
70
  ['Brands', subject?.brand_ids ? formatBrandIds(subject.brand_ids) : ''],
71
+ ['API access', result.member ? formatApiAccess(result.member) : ''],
67
72
  ['Invite email sent', typeof result.email_sent === 'boolean' ? (result.email_sent ? 'yes' : 'no, ask them to check spam or resend later') : ''],
68
73
  ['Workspace owner notified', result.owner_notified ? 'yes' : ''],
69
74
  ])),
@@ -88,14 +93,19 @@ export function validateManageWorkspaceMemberInput(input: ManageWorkspaceMemberI
88
93
  if (!has(raw, 'email') || !has(raw, 'role')) return 'action=invite needs email and role (publisher, editor or viewer).';
89
94
  return null;
90
95
  }
91
- const notAllowed = input.action === 'update' ? ['email', 'resend'] : ['email', 'resend', 'role', 'brand_ids'];
96
+ const notAllowed = input.action === 'update' ? ['email', 'resend'] : ['email', 'resend', 'role', 'brand_ids', 'api_access'];
92
97
  const extra = notAllowed.filter((key) => has(raw, key));
93
98
  if (extra.length) return `action=${input.action} does not take ${extra.join(', ')}.`;
94
99
  if (has(raw, 'member_id') === has(raw, 'invite_id')) {
95
100
  return `action=${input.action} needs exactly one of member_id or invite_id (from list_workspace_members).`;
96
101
  }
102
+ if (input.action === 'update' && has(raw, 'api_access')) {
103
+ if (has(raw, 'invite_id')) return 'api_access can only be set on a member (member_id), not a pending invite.';
104
+ if (has(raw, 'role') || has(raw, 'brand_ids')) return 'Change api_access in its own call, without role or brand_ids.';
105
+ return null;
106
+ }
97
107
  if (input.action === 'update' && !has(raw, 'role') && !has(raw, 'brand_ids')) {
98
- return 'action=update needs role, brand_ids, or both.';
108
+ return 'action=update needs role, brand_ids, or both; or api_access on its own.';
99
109
  }
100
110
  return null;
101
111
  }
@@ -103,7 +113,7 @@ export function validateManageWorkspaceMemberInput(input: ManageWorkspaceMemberI
103
113
  export const manageWorkspaceMemberTool = {
104
114
  name: 'manage_workspace_member',
105
115
  description:
106
- 'Invite, update, or remove a workspace colleague. This SENDS EMAILS and CHANGES WHO CAN ACCESS the workspace. Needs an API key with the Manage team permission, and the caller must be a workspace owner or admin. ALWAYS preview first: call without confirm, show the returned preview summary to the user, and only call again with confirm: true plus the returned preview_id after the user explicitly approves. action=invite: email and role required (publisher, editor or viewer), optional brand_ids and resend; always sends a pending invite email, never adds anyone instantly. action=update: member_id or invite_id (from list_workspace_members) plus role and/or brand_ids. action=remove: member_id (removes the teammate and revokes their API keys for this workspace) or invite_id (cancels a pending invite). Admin seats can only be granted in the posterly dashboard. The workspace owner is emailed about every change.',
116
+ 'Invite, update, or remove a workspace colleague. This SENDS EMAILS and CHANGES WHO CAN ACCESS the workspace. Needs an API key with the Manage team permission, and the caller must be a workspace owner or admin. ALWAYS preview first: call without confirm, show the returned preview summary to the user, and only call again with confirm: true plus the returned preview_id after the user explicitly approves. action=invite: email and role required (publisher, editor or viewer), optional brand_ids and resend; always sends a pending invite email, never adds anyone instantly. action=update: member_id or invite_id (from list_workspace_members) plus role and/or brand_ids; or member_id plus api_access (true or false, on its own) to turn API and AI app access on or off for that teammate, which lets them use the workspace API plan from their own keys and AI apps (off by default; you cannot change your own; only the owner can change it for an admin; turning it off stops their keys, AI app connections and webhooks for this workspace). action=remove: member_id (removes the teammate and revokes their API keys for this workspace) or invite_id (cancels a pending invite). Admin seats can only be granted in the posterly dashboard. The workspace owner is emailed about every change.',
107
117
  inputSchema: z.object({
108
118
  action: z.enum(ACTIONS).describe('invite, update, or remove.'),
109
119
  workspace_id: z.string().describe('Workspace ID (from whoami).'),
@@ -113,6 +123,7 @@ export const manageWorkspaceMemberTool = {
113
123
  resend: z.boolean().optional().describe('action=invite only: email an unchanged pending invite again.'),
114
124
  member_id: z.string().optional().describe('action=update or action=remove: a member ID from list_workspace_members.'),
115
125
  invite_id: z.string().optional().describe('action=update or action=remove: a pending invite ID from list_workspace_members.'),
126
+ api_access: z.boolean().optional().describe('action=update with member_id only, on its own: true lets the teammate use this workspace\'s API plan from their own keys and AI apps; false turns it off.'),
116
127
  confirm: z.boolean().optional().describe('Leave out to get a preview. Set true only after the user explicitly approved the preview, together with preview_id.'),
117
128
  preview_id: z.string().optional().describe('The preview_id returned by the preview call. Required with confirm: true.'),
118
129
  }),
@@ -141,6 +152,7 @@ export const manageWorkspaceMemberTool = {
141
152
  result = await client.updateWorkspaceMember(workspaceId, target, {
142
153
  ...(input.role !== undefined ? { role: input.role } : {}),
143
154
  ...(input.brand_ids !== undefined ? { brand_ids: input.brand_ids } : {}),
155
+ ...(input.api_access !== undefined ? { api_access: input.api_access } : {}),
144
156
  ...confirmFields,
145
157
  });
146
158
  } else {
@@ -127,12 +127,15 @@ type UpdatePostInput = EditInput & {
127
127
  status?: 'scheduled' | 'paused' | 'draft';
128
128
  release_id?: string;
129
129
  group_id?: string;
130
+ approval_action?: 'request' | 'approve' | 'request_changes' | 'reject';
131
+ approval_axis?: 'asset' | 'caption';
132
+ approval_note?: string;
130
133
  };
131
134
 
132
135
  export const updatePostTool = {
133
136
  name: 'update_post',
134
137
  description:
135
- 'Change a scheduled or draft post. DESTRUCTIVE WRITE. Edit (default): caption, media, schedule, post_type, or settings; overwrites the existing post; cannot edit published or currently publishing posts; if a client has already approved the post, a caption edit reopens caption approval, a media or thumbnail edit reopens asset approval, and a reschedule, account change or settings change reopens both. status (scheduled, paused, or draft, with scheduled_at when scheduling a draft): pause, resume, or move back to draft; send it on its own. release_id (with optional group_id): set or repair external release/group IDs; send it on its own.\n\n' +
138
+ 'Change a scheduled or draft post. DESTRUCTIVE WRITE. Edit (default): caption, media, schedule, post_type, or settings; overwrites the existing post; cannot edit published or currently publishing posts; if a client has already approved the post, a caption edit reopens caption approval, a media or thumbnail edit reopens asset approval, and a reschedule, account change or settings change reopens both. status (scheduled, paused, or draft, with scheduled_at when scheduling a draft): pause, resume, or move back to draft; send it on its own. release_id (with optional group_id): set or repair external release/group IDs; send it on its own. approval_action (request, approve, request_changes, or reject): change client approval. Only a workspace owner or admin may do this. The actor is the API key user, never a brand guest. request emails the brand reviewers. approve, request_changes, and reject are team-side decisions. request_changes needs approval_note. Optional approval_axis is asset or caption; omit it for both. Send approval_action on its own.\n\n' +
136
139
  'REQUIRED BEFORE CALLING: Show the user a side-by-side of the CURRENT post (caption, scheduled time, media, status) and the PROPOSED change, and get explicit confirmation ("yes update it", "apply those changes") before calling. Do not auto-edit posts based on a general instruction - each change needs its own confirmation.',
137
140
  inputSchema: z.object({
138
141
  post_id: z.number().describe('The post ID to update'),
@@ -170,6 +173,18 @@ export const updatePostTool = {
170
173
  .optional()
171
174
  .describe('Set an external release ID instead of editing the post. Send with post_id and optional group_id only.'),
172
175
  group_id: z.string().min(1).max(160).optional().describe('With release_id only: an external group/campaign ID.'),
176
+ approval_action: z
177
+ .enum(['request', 'approve', 'request_changes', 'reject'])
178
+ .optional()
179
+ .describe('Change client approval instead of editing the post. Owner or admin only. The actor is the API key user, never a brand guest. Send on its own.'),
180
+ approval_axis: z
181
+ .enum(['asset', 'caption'])
182
+ .optional()
183
+ .describe('With approval_action approve, request_changes, or reject: which axis to decide. Omit for both. Not allowed with request.'),
184
+ approval_note: z
185
+ .string()
186
+ .optional()
187
+ .describe('Note stored with the approval change. Required for request_changes.'),
173
188
  confirm: z.literal(true).describe('Must be true after explicit user confirmation of the proposed change.'),
174
189
  }),
175
190
 
@@ -193,6 +208,15 @@ export const updatePostTool = {
193
208
  const { mode, args } = validateArgs(ctx, 'update_post', input);
194
209
  if (mode === 'status') return updateStatus(client, args as { post_id: number; status: 'scheduled' | 'paused' | 'draft'; scheduled_at?: string; confirm: boolean });
195
210
  if (mode === 'release_id') return updateReleaseId(client, args as { post_id: number; release_id: string; group_id?: string; confirm: true });
211
+ if (mode === 'approval') {
212
+ return updateApproval(client, args as {
213
+ post_id: number;
214
+ approval_action: 'request' | 'approve' | 'request_changes' | 'reject';
215
+ approval_axis?: 'asset' | 'caption';
216
+ approval_note?: string;
217
+ confirm: true;
218
+ });
219
+ }
196
220
  return editPost(client, args as EditInput);
197
221
  },
198
222
  };
@@ -241,6 +265,27 @@ async function updateStatus(
241
265
  return `Post #${input.post_id} status changed from ${transition.old_status || 'unknown'} to ${transition.new_status || input.status}.`;
242
266
  }
243
267
 
268
+ async function updateApproval(
269
+ client: PosterlyClient,
270
+ input: {
271
+ post_id: number;
272
+ approval_action: 'request' | 'approve' | 'request_changes' | 'reject';
273
+ approval_axis?: 'asset' | 'caption';
274
+ approval_note?: string;
275
+ confirm: true;
276
+ },
277
+ ): Promise<string> {
278
+ if (input.confirm !== true) {
279
+ throw new Error('Refusing to change client approval without confirm=true after explicit user confirmation. Only a workspace owner or admin can do this, and the actor is the API key user, never a brand guest.');
280
+ }
281
+ const result = await client.setPostApproval(input.post_id, {
282
+ action: input.approval_action,
283
+ axis: input.approval_axis,
284
+ note: input.approval_note,
285
+ });
286
+ return JSON.stringify(result, null, 2);
287
+ }
288
+
244
289
  async function updateReleaseId(
245
290
  client: PosterlyClient,
246
291
  input: { post_id: number; release_id: string; group_id?: string; confirm: true },
@@ -9,6 +9,7 @@ type UploadMediaInput = {
9
9
  url?: string;
10
10
  filename?: string;
11
11
  content_type?: string;
12
+ workspace_id?: string;
12
13
  };
13
14
 
14
15
  export const uploadMediaTool = {
@@ -38,6 +39,7 @@ export const uploadMediaTool = {
38
39
  .string()
39
40
  .optional()
40
41
  .describe('MIME type (auto-detected from filename if omitted)'),
42
+ 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.'),
41
43
  }),
42
44
  /** The old tools' exact input schemas, for alias calls (see alias-transport.ts). */
43
45
  legacySchemas: {
@@ -50,7 +52,7 @@ export const uploadMediaTool = {
50
52
 
51
53
  async execute(client: PosterlyClient, input: UploadMediaInput, ctx: ToolContext) {
52
54
  const { mode, args } = validateArgs(ctx, 'upload_media', input);
53
- if (mode === 'url') return uploadFromUrl(client, args as { url: string; filename?: string; content_type?: string });
55
+ if (mode === 'url') return uploadFromUrl(client, args as { url: string; filename?: string; content_type?: string; workspace_id?: string });
54
56
  return uploadFile(client, args as UploadMediaInput & { filename: string });
55
57
  },
56
58
  };
@@ -62,6 +64,7 @@ async function uploadFile(
62
64
  base64_data?: string;
63
65
  filename: string;
64
66
  content_type?: string;
67
+ workspace_id?: string;
65
68
  },
66
69
  ): Promise<string> {
67
70
  if (!input.file_path && !input.base64_data) {
@@ -73,6 +76,7 @@ async function uploadFile(
73
76
  base64Data: input.base64_data,
74
77
  filename: input.filename,
75
78
  contentType: input.content_type,
79
+ workspaceId: input.workspace_id,
76
80
  });
77
81
 
78
82
  return mdSuccess('Media uploaded', [
@@ -83,12 +87,13 @@ async function uploadFile(
83
87
 
84
88
  async function uploadFromUrl(
85
89
  client: PosterlyClient,
86
- input: { url: string; filename?: string; content_type?: string },
90
+ input: { url: string; filename?: string; content_type?: string; workspace_id?: string },
87
91
  ): Promise<string> {
88
92
  const result = await client.uploadMediaFromUrl({
89
93
  url: input.url,
90
94
  filename: input.filename,
91
95
  contentType: input.content_type,
96
+ workspaceId: input.workspace_id,
92
97
  });
93
98
  return [
94
99
  'Remote media uploaded successfully.',
@@ -736,6 +736,43 @@
736
736
  "type": "string"
737
737
  }
738
738
  }
739
+ },
740
+ "approval": {
741
+ "label": "Request, approve, request changes, or reject client review",
742
+ "destructive": true,
743
+ "idempotent": true,
744
+ "openWorld": false,
745
+ "confirm": "confirm",
746
+ "when": [
747
+ "approval_action"
748
+ ],
749
+ "params": {
750
+ "post_id": {
751
+ "type": "integer",
752
+ "min": 1,
753
+ "required": true
754
+ },
755
+ "approval_action": {
756
+ "type": "string",
757
+ "enum": [
758
+ "request",
759
+ "approve",
760
+ "request_changes",
761
+ "reject"
762
+ ],
763
+ "required": true
764
+ },
765
+ "approval_axis": {
766
+ "type": "string",
767
+ "enum": [
768
+ "asset",
769
+ "caption"
770
+ ]
771
+ },
772
+ "approval_note": {
773
+ "type": "string"
774
+ }
775
+ }
739
776
  }
740
777
  }
741
778
  },
@@ -760,6 +797,9 @@
760
797
  "type": "integer",
761
798
  "min": 1,
762
799
  "required": true
800
+ },
801
+ "confirm_published": {
802
+ "type": "boolean"
763
803
  }
764
804
  }
765
805
  },
@@ -823,6 +863,9 @@
823
863
  },
824
864
  "content_type": {
825
865
  "type": "string"
866
+ },
867
+ "workspace_id": {
868
+ "type": "string"
826
869
  }
827
870
  }
828
871
  },
@@ -845,6 +888,9 @@
845
888
  },
846
889
  "content_type": {
847
890
  "type": "string"
891
+ },
892
+ "workspace_id": {
893
+ "type": "string"
848
894
  }
849
895
  }
850
896
  }