posterly-mcp-server 0.22.0 → 0.23.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.
Files changed (39) hide show
  1. package/README.md +8 -1
  2. package/dist/index.js +40 -0
  3. package/dist/lib/api-client.d.ts +58 -0
  4. package/dist/lib/api-client.js +20 -0
  5. package/dist/lib/format.d.ts +6 -1
  6. package/dist/lib/format.js +44 -0
  7. package/dist/lib/version.d.ts +1 -1
  8. package/dist/lib/version.js +8 -1
  9. package/dist/tools/add-google-business-media.d.ts +42 -0
  10. package/dist/tools/add-google-business-media.js +34 -0
  11. package/dist/tools/audit-google-business-profile.js +1 -1
  12. package/dist/tools/delete-google-business-media.d.ts +32 -0
  13. package/dist/tools/delete-google-business-media.js +17 -0
  14. package/dist/tools/delete-google-business-review-reply.js +1 -1
  15. package/dist/tools/get-brand-profile.js +8 -2
  16. package/dist/tools/get-google-business-review-link.js +2 -2
  17. package/dist/tools/get-learned-voice.d.ts +16 -0
  18. package/dist/tools/get-learned-voice.js +22 -0
  19. package/dist/tools/list-google-business-media.d.ts +24 -0
  20. package/dist/tools/list-google-business-media.js +25 -0
  21. package/dist/tools/list-google-business-reviews.js +1 -1
  22. package/dist/tools/reply-google-business-review.js +1 -1
  23. package/dist/tools/suggest-google-business-review-reply.js +1 -1
  24. package/package.json +1 -1
  25. package/src/index.ts +60 -0
  26. package/src/lib/api-client.ts +72 -0
  27. package/src/lib/format.ts +46 -1
  28. package/src/lib/version.ts +8 -1
  29. package/src/tools/add-google-business-media.ts +50 -0
  30. package/src/tools/audit-google-business-profile.ts +1 -1
  31. package/src/tools/delete-google-business-media.ts +30 -0
  32. package/src/tools/delete-google-business-review-reply.ts +1 -1
  33. package/src/tools/get-brand-profile.ts +13 -2
  34. package/src/tools/get-google-business-review-link.ts +2 -2
  35. package/src/tools/get-learned-voice.ts +31 -0
  36. package/src/tools/list-google-business-media.ts +33 -0
  37. package/src/tools/list-google-business-reviews.ts +1 -1
  38. package/src/tools/reply-google-business-review.ts +1 -1
  39. package/src/tools/suggest-google-business-review-reply.ts +1 -1
package/README.md CHANGED
@@ -108,7 +108,7 @@ Add the same server definition to your Cursor MCP settings:
108
108
 
109
109
  ## Available tools
110
110
 
111
- `posterly-mcp-server@0.20.8` exposes 59 tools.
111
+ `posterly-mcp-server@0.23.0` exposes 63 tools.
112
112
 
113
113
  Public setup tools work before `POSTERLY_API_KEY` exists:
114
114
 
@@ -138,6 +138,7 @@ Authenticated tools require `POSTERLY_API_KEY`:
138
138
  - `get_brand`
139
139
  - `list_brand_accounts`
140
140
  - `get_brand_profile`
141
+ - `get_learned_voice` (read the voice learned from one account's real published captions)
141
142
  - `list_posts`
142
143
  - `get_post`
143
144
  - `get_post_missing`
@@ -167,6 +168,12 @@ Authenticated tools require `POSTERLY_API_KEY`:
167
168
  - `suggest_google_business_review_reply`
168
169
  - `reply_google_business_review` (post/update a public GBP review reply after explicit confirmation)
169
170
  - `delete_google_business_review_reply` (delete a GBP review reply after explicit confirmation)
171
+ - `list_google_business_media` (list the photos/videos on a GBP profile gallery)
172
+ - `add_google_business_media` (add a photo/video to a GBP profile gallery from a public URL, after explicit confirmation)
173
+ - `delete_google_business_media` (remove a photo/video from a GBP profile gallery after explicit confirmation)
174
+
175
+ > Google Business tip: pass `account_id` (the integer `id` from `list_accounts`) to every GBP tool. If you use `location_id` instead, it is the numeric `location_id` from `list_accounts` (the GBP location id), not the `ChIJ...` Place ID that `get_google_business_review_link` returns. Passing the Place ID resolves to no account and returns "Google Business Profile account not found".
176
+
170
177
  - `list_activity`
171
178
  - `list_webhooks`
172
179
  - `create_webhook` (create a webhook after explicit confirmation)
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { listBrandsTool } from './tools/list-brands.js';
13
13
  import { getBrandTool } from './tools/get-brand.js';
14
14
  import { listBrandAccountsTool } from './tools/list-brand-accounts.js';
15
15
  import { getBrandProfileTool } from './tools/get-brand-profile.js';
16
+ import { getLearnedVoiceTool } from './tools/get-learned-voice.js';
16
17
  import { createPostTool } from './tools/create-post.js';
17
18
  import { createPostsBatchTool } from './tools/create-posts-batch.js';
18
19
  import { findSlotTool } from './tools/find-slot.js';
@@ -41,6 +42,9 @@ import { auditGoogleBusinessProfileTool } from './tools/audit-google-business-pr
41
42
  import { suggestGoogleBusinessReviewReplyTool } from './tools/suggest-google-business-review-reply.js';
42
43
  import { replyGoogleBusinessReviewTool } from './tools/reply-google-business-review.js';
43
44
  import { deleteGoogleBusinessReviewReplyTool } from './tools/delete-google-business-review-reply.js';
45
+ import { listGoogleBusinessMediaTool } from './tools/list-google-business-media.js';
46
+ import { addGoogleBusinessMediaTool } from './tools/add-google-business-media.js';
47
+ import { deleteGoogleBusinessMediaTool } from './tools/delete-google-business-media.js';
44
48
  import { listActivityTool } from './tools/list-activity.js';
45
49
  import { listWebhooksTool } from './tools/list-webhooks.js';
46
50
  import { createWebhookTool } from './tools/create-webhook.js';
@@ -275,6 +279,15 @@ server.tool(getBrandProfileTool.name, getBrandProfileTool.description, getBrandP
275
279
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
276
280
  }
277
281
  });
282
+ server.tool(getLearnedVoiceTool.name, getLearnedVoiceTool.description, getLearnedVoiceTool.inputSchema.shape, async (input) => {
283
+ try {
284
+ const text = await getLearnedVoiceTool.execute(client, input);
285
+ return { content: [{ type: 'text', text }] };
286
+ }
287
+ catch (err) {
288
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
289
+ }
290
+ });
278
291
  server.tool(createPostTool.name, createPostTool.description, createPostTool.inputSchema.shape, async (input) => {
279
292
  try {
280
293
  const text = await createPostTool.execute(client, input);
@@ -536,6 +549,33 @@ server.tool(deleteGoogleBusinessReviewReplyTool.name, deleteGoogleBusinessReview
536
549
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
537
550
  }
538
551
  });
552
+ server.tool(listGoogleBusinessMediaTool.name, listGoogleBusinessMediaTool.description, listGoogleBusinessMediaTool.inputSchema.shape, async (input) => {
553
+ try {
554
+ const text = await listGoogleBusinessMediaTool.execute(client, input);
555
+ return { content: [{ type: 'text', text }] };
556
+ }
557
+ catch (err) {
558
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
559
+ }
560
+ });
561
+ server.tool(addGoogleBusinessMediaTool.name, addGoogleBusinessMediaTool.description, addGoogleBusinessMediaTool.inputSchema.shape, async (input) => {
562
+ try {
563
+ const text = await addGoogleBusinessMediaTool.execute(client, input);
564
+ return { content: [{ type: 'text', text }] };
565
+ }
566
+ catch (err) {
567
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
568
+ }
569
+ });
570
+ server.tool(deleteGoogleBusinessMediaTool.name, deleteGoogleBusinessMediaTool.description, deleteGoogleBusinessMediaTool.inputSchema.shape, async (input) => {
571
+ try {
572
+ const text = await deleteGoogleBusinessMediaTool.execute(client, input);
573
+ return { content: [{ type: 'text', text }] };
574
+ }
575
+ catch (err) {
576
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
577
+ }
578
+ });
539
579
  server.tool(listActivityTool.name, listActivityTool.description, listActivityTool.inputSchema.shape, async (input) => {
540
580
  try {
541
581
  const text = await listActivityTool.execute(client, input);
@@ -40,6 +40,20 @@ export interface BrandProfile {
40
40
  created_at?: string | null;
41
41
  updated_at?: string | null;
42
42
  source: 'canonical' | 'legacy';
43
+ learned_voice?: LearnedVoice[];
44
+ }
45
+ export interface LearnedVoice {
46
+ social_account_id: number;
47
+ platform: string;
48
+ username: string | null;
49
+ narrative_summary: string | null;
50
+ summary: string | null;
51
+ traits: string[];
52
+ style_guidelines: string[];
53
+ observations: Record<string, unknown>;
54
+ sample_post_ids: number[];
55
+ post_count: number;
56
+ derived_at: string | null;
43
57
  }
44
58
  export interface Post {
45
59
  id: number;
@@ -372,6 +386,28 @@ export interface GoogleBusinessReviewsResponse {
372
386
  total: number;
373
387
  locations: Array<Record<string, unknown>>;
374
388
  }
389
+ export interface GoogleBusinessMediaItem {
390
+ name: string;
391
+ mediaFormat: 'PHOTO' | 'VIDEO' | 'MEDIA_FORMAT_UNSPECIFIED';
392
+ category: string | null;
393
+ googleUrl: string | null;
394
+ thumbnailUrl: string | null;
395
+ createTime: string | null;
396
+ widthPixels: number | null;
397
+ heightPixels: number | null;
398
+ attribution: {
399
+ profileName?: string;
400
+ profilePhotoUrl?: string;
401
+ profileUrl?: string;
402
+ } | null;
403
+ account_id?: number;
404
+ location_id?: string | null;
405
+ }
406
+ export interface GoogleBusinessMediaResponse {
407
+ media: GoogleBusinessMediaItem[];
408
+ total: number;
409
+ locations: Array<Record<string, unknown>>;
410
+ }
375
411
  export interface GoogleBusinessReviewLinkResponse {
376
412
  reviewLink: string;
377
413
  placeId: string;
@@ -658,6 +694,9 @@ export declare class PosterlyClient {
658
694
  getBrandProfile(id: string): Promise<{
659
695
  brand_profile: BrandProfile;
660
696
  }>;
697
+ getAccountLearnedVoice(id: string | number): Promise<{
698
+ learned_voice: LearnedVoice | null;
699
+ }>;
661
700
  listPosts(params?: {
662
701
  status?: string;
663
702
  platform?: string;
@@ -820,6 +859,25 @@ export declare class PosterlyClient {
820
859
  location_id?: string;
821
860
  review_name: string;
822
861
  }): Promise<Record<string, any>>;
862
+ listGoogleBusinessMedia(params?: {
863
+ workspace_id?: string;
864
+ account_id?: number;
865
+ location_id?: string;
866
+ }): Promise<GoogleBusinessMediaResponse>;
867
+ addGoogleBusinessMedia(data: {
868
+ workspace_id?: string;
869
+ account_id?: number;
870
+ location_id?: string;
871
+ source_url: string;
872
+ category: string;
873
+ media_format?: 'PHOTO' | 'VIDEO';
874
+ }): Promise<Record<string, any>>;
875
+ deleteGoogleBusinessMedia(data: {
876
+ workspace_id?: string;
877
+ account_id?: number;
878
+ location_id?: string;
879
+ media_name: string;
880
+ }): Promise<Record<string, any>>;
823
881
  listActivity(params?: {
824
882
  workspace_id?: string;
825
883
  post_id?: number;
@@ -179,6 +179,9 @@ export class PosterlyClient {
179
179
  async getBrandProfile(id) {
180
180
  return this.request('GET', `/brands/${encodeURIComponent(id)}/profile`);
181
181
  }
182
+ async getAccountLearnedVoice(id) {
183
+ return this.request('GET', `/accounts/${encodeURIComponent(String(id))}/learned-voice`);
184
+ }
182
185
  async listPosts(params) {
183
186
  const searchParams = new URLSearchParams();
184
187
  if (params?.status)
@@ -333,6 +336,23 @@ export class PosterlyClient {
333
336
  async deleteGoogleBusinessReviewReply(data) {
334
337
  return this.request('DELETE', '/google-business/reviews/reply', data);
335
338
  }
339
+ async listGoogleBusinessMedia(params) {
340
+ const searchParams = new URLSearchParams();
341
+ if (params?.workspace_id)
342
+ searchParams.set('workspace_id', params.workspace_id);
343
+ if (params?.account_id)
344
+ searchParams.set('account_id', String(params.account_id));
345
+ if (params?.location_id)
346
+ searchParams.set('location_id', params.location_id);
347
+ const qs = searchParams.toString();
348
+ return this.request('GET', `/google-business/media${qs ? `?${qs}` : ''}`);
349
+ }
350
+ async addGoogleBusinessMedia(data) {
351
+ return this.request('POST', '/google-business/media', data);
352
+ }
353
+ async deleteGoogleBusinessMedia(data) {
354
+ return this.request('DELETE', '/google-business/media', data);
355
+ }
336
356
  async listActivity(params) {
337
357
  const searchParams = new URLSearchParams();
338
358
  if (params?.workspace_id)
@@ -1,4 +1,4 @@
1
- import type { Account, Post } from './api-client.js';
1
+ import type { Account, LearnedVoice, Post } from './api-client.js';
2
2
  type CellValue = string | number | boolean | null | undefined;
3
3
  export declare const DASHBOARD_CALENDAR_URL: string;
4
4
  export declare const DASHBOARD_TABLE_URL: string;
@@ -27,6 +27,11 @@ export declare function formatDateTime(value: string | null | undefined): string
27
27
  export declare function formatDate(value: string | null | undefined): string;
28
28
  export declare function formatBytes(value: number | null | undefined): string;
29
29
  export declare function compactText(value: string | null | undefined, maxLength?: number): string;
30
+ /**
31
+ * Render one account's learned voice (derived from its real published captions)
32
+ * as a Markdown block. Returns null when there is no learned voice to show.
33
+ */
34
+ export declare function mdLearnedVoice(learned: LearnedVoice | null | undefined): string | null;
30
35
  export declare function statusLabel(status: string | null | undefined): string;
31
36
  export declare function code(value: CellValue): string;
32
37
  export {};
@@ -136,6 +136,50 @@ export function compactText(value, maxLength = 90) {
136
136
  return '';
137
137
  return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text;
138
138
  }
139
+ /**
140
+ * Render one account's learned voice (derived from its real published captions)
141
+ * as a Markdown block. Returns null when there is no learned voice to show.
142
+ */
143
+ export function mdLearnedVoice(learned) {
144
+ if (!learned)
145
+ return null;
146
+ const obs = (learned.observations || {});
147
+ const num = (key) => {
148
+ const value = obs[key];
149
+ return typeof value === 'number' ? value : undefined;
150
+ };
151
+ const topEmojis = Array.isArray(obs.top_emojis) ? obs.top_emojis : [];
152
+ const accountLabel = learned.username
153
+ ? `@${learned.username}${learned.platform ? ` (${learned.platform})` : ''}`
154
+ : `account ${learned.social_account_id}`;
155
+ const habits = [];
156
+ const avgLen = num('avg_caption_length');
157
+ if (avgLen != null)
158
+ habits.push(`~${avgLen} chars/post`);
159
+ const emojiRate = num('emoji_rate');
160
+ if (emojiRate != null) {
161
+ habits.push(emojiRate >= 0.5 ? `~${emojiRate} emoji/post${topEmojis.length ? ` (${topEmojis.slice(0, 3).join(' ')})` : ''}` : 'rarely uses emoji');
162
+ }
163
+ const hashtagAvg = num('hashtag_avg');
164
+ if (hashtagAvg != null && hashtagAvg >= 0.5)
165
+ habits.push(`~${Math.round(hashtagAvg)} hashtags/post`);
166
+ const questionRate = num('question_rate');
167
+ if (questionRate != null && questionRate >= 0.3)
168
+ habits.push('often opens with a question');
169
+ const lineBreakRate = num('line_break_rate');
170
+ if (lineBreakRate != null && lineBreakRate >= 0.5)
171
+ habits.push('uses line breaks');
172
+ const body = mdKeyValue([
173
+ ['Account', accountLabel],
174
+ ['Summary', compactText(learned.narrative_summary || learned.summary, 240)],
175
+ ['Traits', learned.traits?.length ? learned.traits.join(', ') : undefined],
176
+ ['Style guidelines', learned.style_guidelines?.length ? learned.style_guidelines.join(' • ') : undefined],
177
+ ['Observed habits', habits.length ? habits.join(', ') : undefined],
178
+ ['Learned from', `${learned.post_count} posts`],
179
+ ['Last updated', learned.derived_at ? formatDateTime(learned.derived_at) : undefined],
180
+ ]);
181
+ return body || null;
182
+ }
139
183
  export function statusLabel(status) {
140
184
  const value = String(status || 'unknown').toLowerCase();
141
185
  if (['created', 'success', 'succeeded', 'completed', 'published', 'active'].includes(value))
@@ -1 +1 @@
1
- export declare const POSTERLY_MCP_VERSION = "0.20.8";
1
+ export declare const POSTERLY_MCP_VERSION = "0.23.1";
@@ -1 +1,8 @@
1
- export const POSTERLY_MCP_VERSION = '0.20.8';
1
+ // Version of the npm stdio MCP package (posterly-mcp-server).
2
+ //
3
+ // MUST stay in lockstep with the hosted HTTP endpoint version in
4
+ // lib/mcp/version.ts. The two MCP surfaces share one version namespace and
5
+ // tool set (minus the intentional pre-auth signup tools that only this stdio
6
+ // package exposes). `npm run check:mcp-parity` enforces both the version match
7
+ // and the tool-list match, and runs in the pre-commit hook.
8
+ export const POSTERLY_MCP_VERSION = '0.23.1';
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ declare const CATEGORIES: readonly ["COVER", "PROFILE", "LOGO", "EXTERIOR", "INTERIOR", "PRODUCT", "AT_WORK", "FOOD_AND_DRINK", "MENU", "COMMON_AREA", "ROOMS", "TEAMS", "ADDITIONAL"];
4
+ export declare const addGoogleBusinessMediaTool: {
5
+ name: string;
6
+ description: string;
7
+ inputSchema: z.ZodObject<{
8
+ workspace_id: z.ZodOptional<z.ZodString>;
9
+ account_id: z.ZodOptional<z.ZodNumber>;
10
+ location_id: z.ZodOptional<z.ZodString>;
11
+ source_url: z.ZodString;
12
+ category: z.ZodEnum<["COVER", "PROFILE", "LOGO", "EXTERIOR", "INTERIOR", "PRODUCT", "AT_WORK", "FOOD_AND_DRINK", "MENU", "COMMON_AREA", "ROOMS", "TEAMS", "ADDITIONAL"]>;
13
+ media_format: z.ZodOptional<z.ZodEnum<["PHOTO", "VIDEO"]>>;
14
+ confirm: z.ZodLiteral<true>;
15
+ }, "strip", z.ZodTypeAny, {
16
+ confirm: true;
17
+ source_url: string;
18
+ category: "COVER" | "PROFILE" | "LOGO" | "EXTERIOR" | "INTERIOR" | "PRODUCT" | "AT_WORK" | "FOOD_AND_DRINK" | "MENU" | "COMMON_AREA" | "ROOMS" | "TEAMS" | "ADDITIONAL";
19
+ workspace_id?: string | undefined;
20
+ account_id?: number | undefined;
21
+ location_id?: string | undefined;
22
+ media_format?: "PHOTO" | "VIDEO" | undefined;
23
+ }, {
24
+ confirm: true;
25
+ source_url: string;
26
+ category: "COVER" | "PROFILE" | "LOGO" | "EXTERIOR" | "INTERIOR" | "PRODUCT" | "AT_WORK" | "FOOD_AND_DRINK" | "MENU" | "COMMON_AREA" | "ROOMS" | "TEAMS" | "ADDITIONAL";
27
+ workspace_id?: string | undefined;
28
+ account_id?: number | undefined;
29
+ location_id?: string | undefined;
30
+ media_format?: "PHOTO" | "VIDEO" | undefined;
31
+ }>;
32
+ execute(client: PosterlyClient, input: {
33
+ workspace_id?: string;
34
+ account_id?: number;
35
+ location_id?: string;
36
+ source_url: string;
37
+ category: (typeof CATEGORIES)[number];
38
+ media_format?: "PHOTO" | "VIDEO";
39
+ confirm: true;
40
+ }): Promise<any>;
41
+ };
42
+ export {};
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ const CATEGORIES = [
3
+ 'COVER',
4
+ 'PROFILE',
5
+ 'LOGO',
6
+ 'EXTERIOR',
7
+ 'INTERIOR',
8
+ 'PRODUCT',
9
+ 'AT_WORK',
10
+ 'FOOD_AND_DRINK',
11
+ 'MENU',
12
+ 'COMMON_AREA',
13
+ 'ROOMS',
14
+ 'TEAMS',
15
+ 'ADDITIONAL',
16
+ ];
17
+ export const addGoogleBusinessMediaTool = {
18
+ name: 'add_google_business_media',
19
+ description: 'Add a photo or video to a Google Business Profile gallery (the media shown on Maps and Search). Provide a public https source_url first by uploading via upload_media_from_url or upload_media. COVER and PROFILE are single-slot and replace the existing one. WRITE: show the location, category, and media URL to the user, then pass confirm=true only after explicit confirmation.',
20
+ inputSchema: z.object({
21
+ workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
22
+ account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
23
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
24
+ source_url: z.string().url().describe('Public https URL of the photo or video (from upload_media / upload_media_from_url).'),
25
+ category: z.enum(CATEGORIES).describe('Which gallery category to add the media to.'),
26
+ media_format: z.enum(['PHOTO', 'VIDEO']).optional().describe('Defaults to PHOTO. Use VIDEO for videos (Google caps profile videos at 30s / 75MB).'),
27
+ confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
28
+ }),
29
+ async execute(client, input) {
30
+ const { confirm: _confirm, ...payload } = input;
31
+ const result = await client.addGoogleBusinessMedia(payload);
32
+ return result.message || 'Media added to the Google Business profile.';
33
+ },
34
+ };
@@ -5,7 +5,7 @@ export const auditGoogleBusinessProfileTool = {
5
5
  inputSchema: z.object({
6
6
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
7
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
8
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
8
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
9
9
  }),
10
10
  async execute(client, input) {
11
11
  const result = await client.auditGoogleBusinessProfile(input);
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const deleteGoogleBusinessMediaTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ workspace_id: z.ZodOptional<z.ZodString>;
8
+ account_id: z.ZodOptional<z.ZodNumber>;
9
+ location_id: z.ZodOptional<z.ZodString>;
10
+ media_name: z.ZodString;
11
+ confirm: z.ZodLiteral<true>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ confirm: true;
14
+ media_name: string;
15
+ workspace_id?: string | undefined;
16
+ account_id?: number | undefined;
17
+ location_id?: string | undefined;
18
+ }, {
19
+ confirm: true;
20
+ media_name: string;
21
+ workspace_id?: string | undefined;
22
+ account_id?: number | undefined;
23
+ location_id?: string | undefined;
24
+ }>;
25
+ execute(client: PosterlyClient, input: {
26
+ workspace_id?: string;
27
+ account_id?: number;
28
+ location_id?: string;
29
+ media_name: string;
30
+ confirm: true;
31
+ }): Promise<any>;
32
+ };
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+ export const deleteGoogleBusinessMediaTool = {
3
+ name: 'delete_google_business_media',
4
+ description: 'Remove a photo or video from a Google Business Profile gallery. Get media_name from list_google_business_media. This permanently deletes the item from the public profile. WRITE: show the exact media item and location to the user, then pass confirm=true only after explicit confirmation.',
5
+ inputSchema: z.object({
6
+ workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
+ account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
8
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
9
+ media_name: z.string().min(1).describe('Full media resource name from list_google_business_media (accounts/.../locations/.../media/...).'),
10
+ confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
11
+ }),
12
+ async execute(client, input) {
13
+ const { confirm: _confirm, ...payload } = input;
14
+ const result = await client.deleteGoogleBusinessMedia(payload);
15
+ return result.message || 'Media removed from the Google Business profile.';
16
+ },
17
+ };
@@ -5,7 +5,7 @@ export const deleteGoogleBusinessReviewReplyTool = {
5
5
  inputSchema: z.object({
6
6
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
7
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
8
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
8
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
9
9
  review_name: z.string().min(1).describe('Full Google review resource name.'),
10
10
  confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
11
11
  }),
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { code, compactText, formatDateTime, mdKeyValue, mdSection, mdTitle } from '../lib/format.js';
2
+ import { code, compactText, formatDateTime, mdLearnedVoice, mdKeyValue, mdSection, mdTitle } from '../lib/format.js';
3
3
  export const getBrandProfileTool = {
4
4
  name: 'get_brand_profile',
5
5
  description: 'Get the extended brand profile for a brand/client. Returns voice/tone guidance, audience, keywords, dos and don’ts, visual notes, and other saved brand context.',
@@ -38,10 +38,16 @@ export const getBrandProfileTool = {
38
38
  ['Voice examples', formatStructuredValue(profile.voice_examples)],
39
39
  ])),
40
40
  ].filter(Boolean).join('\n\n');
41
+ const learnedVoices = (Array.isArray(profile.learned_voice) ? profile.learned_voice : []);
42
+ const learnedBody = learnedVoices.length
43
+ ? mdSection('Learned voice (from published captions)', learnedVoices.map((voice) => mdLearnedVoice(voice)).filter(Boolean).join('\n\n'))
44
+ : '';
41
45
  return [
42
46
  mdTitle(`Brand profile: ${profile.brand_name}`),
43
47
  profileBody || 'No extended brand profile fields have been saved yet.',
44
- ].join('\n\n');
48
+ learnedBody ||
49
+ '_No learned voice yet. posterly learns each account’s voice automatically once it has enough published posts._',
50
+ ].filter(Boolean).join('\n\n');
45
51
  },
46
52
  };
47
53
  function formatStructuredValue(value) {
@@ -1,11 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  export const getGoogleBusinessReviewLinkTool = {
3
3
  name: 'get_google_business_review_link',
4
- description: 'Get the direct public Google review link for a connected Google Business Profile location. Requires account_id or location_id.',
4
+ description: 'Get the direct public Google review link for a connected Google Business Profile location. Requires account_id or location_id. Returns reviewLink plus the Google Place ID (ChIJ...); that Place ID is output only and must NOT be passed back as location_id to other tools.',
5
5
  inputSchema: z.object({
6
6
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
7
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
8
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
8
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID this tool returns. Prefer account_id.'),
9
9
  }),
10
10
  async execute(client, input) {
11
11
  const result = await client.getGoogleBusinessReviewLink(input);
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const getLearnedVoiceTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ account_id: z.ZodUnion<[z.ZodString, z.ZodNumber]>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ account_id: string | number;
10
+ }, {
11
+ account_id: string | number;
12
+ }>;
13
+ execute(client: PosterlyClient, input: {
14
+ account_id: string | number;
15
+ }): Promise<string>;
16
+ };
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ import { mdLearnedVoice, mdTitle } from '../lib/format.js';
3
+ export const getLearnedVoiceTool = {
4
+ name: 'get_learned_voice',
5
+ description: 'Get the voice posterly has learned from a connected account’s real published captions (the "learned captions" shown on the dashboard). Returns a narrative summary, voice traits, style guidelines, and observed posting habits. Keyed by social account ID (from list_accounts or list_brand_accounts). Returns nothing until the account has enough published posts to learn from. Read-only; this never overwrites the saved brand profile.',
6
+ inputSchema: z.object({
7
+ account_id: z
8
+ .union([z.string(), z.number()])
9
+ .describe('The social account ID to inspect (from list_accounts or list_brand_accounts).'),
10
+ }),
11
+ async execute(client, input) {
12
+ const { learned_voice } = await client.getAccountLearnedVoice(input.account_id);
13
+ const body = mdLearnedVoice(learned_voice);
14
+ if (!body) {
15
+ return [
16
+ mdTitle('No learned voice yet'),
17
+ 'posterly has not learned a voice for this account yet. It learns automatically once the account has enough published posts (the captions are analyzed; the saved brand profile is never overwritten).',
18
+ ].join('\n\n');
19
+ }
20
+ return [mdTitle('Learned voice (from published captions)'), body].join('\n\n');
21
+ },
22
+ };
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const listGoogleBusinessMediaTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ workspace_id: z.ZodOptional<z.ZodString>;
8
+ account_id: z.ZodOptional<z.ZodNumber>;
9
+ location_id: z.ZodOptional<z.ZodString>;
10
+ }, "strip", z.ZodTypeAny, {
11
+ workspace_id?: string | undefined;
12
+ account_id?: number | undefined;
13
+ location_id?: string | undefined;
14
+ }, {
15
+ workspace_id?: string | undefined;
16
+ account_id?: number | undefined;
17
+ location_id?: string | undefined;
18
+ }>;
19
+ execute(client: PosterlyClient, input: {
20
+ workspace_id?: string;
21
+ account_id?: number;
22
+ location_id?: string;
23
+ }): Promise<string>;
24
+ };
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+ export const listGoogleBusinessMediaTool = {
3
+ name: 'list_google_business_media',
4
+ description: 'List the photos and videos on a Google Business Profile gallery (the media shown on Maps and Search) for one location/account or every accessible GBP location. This is separate from media attached to a post. Returns each item\'s resource name, category, format, and URLs.',
5
+ inputSchema: z.object({
6
+ workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
+ account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
8
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
9
+ }),
10
+ async execute(client, input) {
11
+ const result = await client.listGoogleBusinessMedia(input);
12
+ if (result.media.length === 0) {
13
+ return 'No photos or videos found on the Google Business profile for your filters.';
14
+ }
15
+ const lines = [`Google Business profile media (${result.media.length}):`];
16
+ for (const item of result.media.slice(0, 50)) {
17
+ const kind = item.mediaFormat === 'VIDEO' ? 'video' : 'photo';
18
+ const category = item.category || 'uncategorised';
19
+ const attribution = item.attribution?.profileName ? ` by ${item.attribution.profileName}` : '';
20
+ lines.push(`- [${kind}] ${category}${attribution} :: ${item.name}`);
21
+ }
22
+ lines.push('', 'Use the resource name (after ::) as media_name to delete an item.');
23
+ return lines.join('\n');
24
+ },
25
+ };
@@ -5,7 +5,7 @@ export const listGoogleBusinessReviewsTool = {
5
5
  inputSchema: z.object({
6
6
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
7
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
8
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
8
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
9
9
  rating: z.number().int().min(1).max(5).optional().describe('Filter to a 1-5 star rating.'),
10
10
  unanswered: z.boolean().optional().describe('Only return reviews without owner replies.'),
11
11
  }),
@@ -5,7 +5,7 @@ export const replyGoogleBusinessReviewTool = {
5
5
  inputSchema: z.object({
6
6
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
7
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
8
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
8
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
9
9
  review_name: z.string().min(1).describe('Full Google review resource name.'),
10
10
  comment: z.string().min(1).max(4096).describe('Exact owner reply text to post.'),
11
11
  confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
@@ -5,7 +5,7 @@ export const suggestGoogleBusinessReviewReplyTool = {
5
5
  inputSchema: z.object({
6
6
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
7
7
  account_id: z.number().int().positive().optional().describe('Optional Google Business account ID for brand voice context.'),
8
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
8
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
9
9
  review_text: z.string().optional().describe('Review text. Leave empty for rating-only reviews.'),
10
10
  star_rating: z.number().min(1).max(5).describe('Review rating from 1 to 5.'),
11
11
  reviewer_name: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "description": "MCP server for posterly: schedule and publish social media posts across 11 platforms from any MCP client (Claude, ChatGPT, Cursor, Windsurf, Cline, and more)",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.poster.ly/mcp",
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import { listBrandsTool } from './tools/list-brands.js';
14
14
  import { getBrandTool } from './tools/get-brand.js';
15
15
  import { listBrandAccountsTool } from './tools/list-brand-accounts.js';
16
16
  import { getBrandProfileTool } from './tools/get-brand-profile.js';
17
+ import { getLearnedVoiceTool } from './tools/get-learned-voice.js';
17
18
  import { createPostTool } from './tools/create-post.js';
18
19
  import { createPostsBatchTool } from './tools/create-posts-batch.js';
19
20
  import { findSlotTool } from './tools/find-slot.js';
@@ -42,6 +43,9 @@ import { auditGoogleBusinessProfileTool } from './tools/audit-google-business-pr
42
43
  import { suggestGoogleBusinessReviewReplyTool } from './tools/suggest-google-business-review-reply.js';
43
44
  import { replyGoogleBusinessReviewTool } from './tools/reply-google-business-review.js';
44
45
  import { deleteGoogleBusinessReviewReplyTool } from './tools/delete-google-business-review-reply.js';
46
+ import { listGoogleBusinessMediaTool } from './tools/list-google-business-media.js';
47
+ import { addGoogleBusinessMediaTool } from './tools/add-google-business-media.js';
48
+ import { deleteGoogleBusinessMediaTool } from './tools/delete-google-business-media.js';
45
49
  import { listActivityTool } from './tools/list-activity.js';
46
50
  import { listWebhooksTool } from './tools/list-webhooks.js';
47
51
  import { createWebhookTool } from './tools/create-webhook.js';
@@ -394,6 +398,20 @@ server.tool(
394
398
  }
395
399
  );
396
400
 
401
+ server.tool(
402
+ getLearnedVoiceTool.name,
403
+ getLearnedVoiceTool.description,
404
+ getLearnedVoiceTool.inputSchema.shape,
405
+ async (input) => {
406
+ try {
407
+ const text = await getLearnedVoiceTool.execute(client, input as any);
408
+ return { content: [{ type: 'text' as const, text }] };
409
+ } catch (err: any) {
410
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
411
+ }
412
+ }
413
+ );
414
+
397
415
  server.tool(
398
416
  createPostTool.name,
399
417
  createPostTool.description,
@@ -800,6 +818,48 @@ server.tool(
800
818
  }
801
819
  );
802
820
 
821
+ server.tool(
822
+ listGoogleBusinessMediaTool.name,
823
+ listGoogleBusinessMediaTool.description,
824
+ listGoogleBusinessMediaTool.inputSchema.shape,
825
+ async (input) => {
826
+ try {
827
+ const text = await listGoogleBusinessMediaTool.execute(client, input as any);
828
+ return { content: [{ type: 'text' as const, text }] };
829
+ } catch (err: any) {
830
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
831
+ }
832
+ }
833
+ );
834
+
835
+ server.tool(
836
+ addGoogleBusinessMediaTool.name,
837
+ addGoogleBusinessMediaTool.description,
838
+ addGoogleBusinessMediaTool.inputSchema.shape,
839
+ async (input) => {
840
+ try {
841
+ const text = await addGoogleBusinessMediaTool.execute(client, input as any);
842
+ return { content: [{ type: 'text' as const, text }] };
843
+ } catch (err: any) {
844
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
845
+ }
846
+ }
847
+ );
848
+
849
+ server.tool(
850
+ deleteGoogleBusinessMediaTool.name,
851
+ deleteGoogleBusinessMediaTool.description,
852
+ deleteGoogleBusinessMediaTool.inputSchema.shape,
853
+ async (input) => {
854
+ try {
855
+ const text = await deleteGoogleBusinessMediaTool.execute(client, input as any);
856
+ return { content: [{ type: 'text' as const, text }] };
857
+ } catch (err: any) {
858
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
859
+ }
860
+ }
861
+ );
862
+
803
863
  server.tool(
804
864
  listActivityTool.name,
805
865
  listActivityTool.description,
@@ -45,6 +45,21 @@ export interface BrandProfile {
45
45
  created_at?: string | null;
46
46
  updated_at?: string | null;
47
47
  source: 'canonical' | 'legacy';
48
+ learned_voice?: LearnedVoice[];
49
+ }
50
+
51
+ export interface LearnedVoice {
52
+ social_account_id: number;
53
+ platform: string;
54
+ username: string | null;
55
+ narrative_summary: string | null;
56
+ summary: string | null;
57
+ traits: string[];
58
+ style_guidelines: string[];
59
+ observations: Record<string, unknown>;
60
+ sample_post_ids: number[];
61
+ post_count: number;
62
+ derived_at: string | null;
48
63
  }
49
64
 
50
65
  export interface Post {
@@ -369,6 +384,26 @@ export interface GoogleBusinessReviewsResponse {
369
384
  locations: Array<Record<string, unknown>>;
370
385
  }
371
386
 
387
+ export interface GoogleBusinessMediaItem {
388
+ name: string;
389
+ mediaFormat: 'PHOTO' | 'VIDEO' | 'MEDIA_FORMAT_UNSPECIFIED';
390
+ category: string | null;
391
+ googleUrl: string | null;
392
+ thumbnailUrl: string | null;
393
+ createTime: string | null;
394
+ widthPixels: number | null;
395
+ heightPixels: number | null;
396
+ attribution: { profileName?: string; profilePhotoUrl?: string; profileUrl?: string } | null;
397
+ account_id?: number;
398
+ location_id?: string | null;
399
+ }
400
+
401
+ export interface GoogleBusinessMediaResponse {
402
+ media: GoogleBusinessMediaItem[];
403
+ total: number;
404
+ locations: Array<Record<string, unknown>>;
405
+ }
406
+
372
407
  export interface GoogleBusinessReviewLinkResponse {
373
408
  reviewLink: string;
374
409
  placeId: string;
@@ -847,6 +882,10 @@ export class PosterlyClient {
847
882
  return this.request('GET', `/brands/${encodeURIComponent(id)}/profile`);
848
883
  }
849
884
 
885
+ async getAccountLearnedVoice(id: string | number): Promise<{ learned_voice: LearnedVoice | null }> {
886
+ return this.request('GET', `/accounts/${encodeURIComponent(String(id))}/learned-voice`);
887
+ }
888
+
850
889
  async listPosts(params?: {
851
890
  status?: string;
852
891
  platform?: string;
@@ -1117,6 +1156,39 @@ export class PosterlyClient {
1117
1156
  return this.request('DELETE', '/google-business/reviews/reply', data);
1118
1157
  }
1119
1158
 
1159
+ async listGoogleBusinessMedia(params?: {
1160
+ workspace_id?: string;
1161
+ account_id?: number;
1162
+ location_id?: string;
1163
+ }): Promise<GoogleBusinessMediaResponse> {
1164
+ const searchParams = new URLSearchParams();
1165
+ if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
1166
+ if (params?.account_id) searchParams.set('account_id', String(params.account_id));
1167
+ if (params?.location_id) searchParams.set('location_id', params.location_id);
1168
+ const qs = searchParams.toString();
1169
+ return this.request('GET', `/google-business/media${qs ? `?${qs}` : ''}`);
1170
+ }
1171
+
1172
+ async addGoogleBusinessMedia(data: {
1173
+ workspace_id?: string;
1174
+ account_id?: number;
1175
+ location_id?: string;
1176
+ source_url: string;
1177
+ category: string;
1178
+ media_format?: 'PHOTO' | 'VIDEO';
1179
+ }): Promise<Record<string, any>> {
1180
+ return this.request('POST', '/google-business/media', data);
1181
+ }
1182
+
1183
+ async deleteGoogleBusinessMedia(data: {
1184
+ workspace_id?: string;
1185
+ account_id?: number;
1186
+ location_id?: string;
1187
+ media_name: string;
1188
+ }): Promise<Record<string, any>> {
1189
+ return this.request('DELETE', '/google-business/media', data);
1190
+ }
1191
+
1120
1192
  async listActivity(params?: {
1121
1193
  workspace_id?: string;
1122
1194
  post_id?: number;
package/src/lib/format.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Account, Post } from './api-client.js';
1
+ import type { Account, LearnedVoice, Post } from './api-client.js';
2
2
 
3
3
  type CellValue = string | number | boolean | null | undefined;
4
4
 
@@ -154,6 +154,51 @@ export function compactText(value: string | null | undefined, maxLength = 90): s
154
154
  return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text;
155
155
  }
156
156
 
157
+ /**
158
+ * Render one account's learned voice (derived from its real published captions)
159
+ * as a Markdown block. Returns null when there is no learned voice to show.
160
+ */
161
+ export function mdLearnedVoice(learned: LearnedVoice | null | undefined): string | null {
162
+ if (!learned) return null;
163
+
164
+ const obs = (learned.observations || {}) as Record<string, unknown>;
165
+ const num = (key: string): number | undefined => {
166
+ const value = obs[key];
167
+ return typeof value === 'number' ? value : undefined;
168
+ };
169
+ const topEmojis = Array.isArray(obs.top_emojis) ? (obs.top_emojis as string[]) : [];
170
+
171
+ const accountLabel = learned.username
172
+ ? `@${learned.username}${learned.platform ? ` (${learned.platform})` : ''}`
173
+ : `account ${learned.social_account_id}`;
174
+
175
+ const habits: string[] = [];
176
+ const avgLen = num('avg_caption_length');
177
+ if (avgLen != null) habits.push(`~${avgLen} chars/post`);
178
+ const emojiRate = num('emoji_rate');
179
+ if (emojiRate != null) {
180
+ habits.push(emojiRate >= 0.5 ? `~${emojiRate} emoji/post${topEmojis.length ? ` (${topEmojis.slice(0, 3).join(' ')})` : ''}` : 'rarely uses emoji');
181
+ }
182
+ const hashtagAvg = num('hashtag_avg');
183
+ if (hashtagAvg != null && hashtagAvg >= 0.5) habits.push(`~${Math.round(hashtagAvg)} hashtags/post`);
184
+ const questionRate = num('question_rate');
185
+ if (questionRate != null && questionRate >= 0.3) habits.push('often opens with a question');
186
+ const lineBreakRate = num('line_break_rate');
187
+ if (lineBreakRate != null && lineBreakRate >= 0.5) habits.push('uses line breaks');
188
+
189
+ const body = mdKeyValue([
190
+ ['Account', accountLabel],
191
+ ['Summary', compactText(learned.narrative_summary || learned.summary, 240)],
192
+ ['Traits', learned.traits?.length ? learned.traits.join(', ') : undefined],
193
+ ['Style guidelines', learned.style_guidelines?.length ? learned.style_guidelines.join(' • ') : undefined],
194
+ ['Observed habits', habits.length ? habits.join(', ') : undefined],
195
+ ['Learned from', `${learned.post_count} posts`],
196
+ ['Last updated', learned.derived_at ? formatDateTime(learned.derived_at) : undefined],
197
+ ]);
198
+
199
+ return body || null;
200
+ }
201
+
157
202
  export function statusLabel(status: string | null | undefined): string {
158
203
  const value = String(status || 'unknown').toLowerCase();
159
204
  if (['created', 'success', 'succeeded', 'completed', 'published', 'active'].includes(value)) return `✅ ${status}`;
@@ -1 +1,8 @@
1
- export const POSTERLY_MCP_VERSION = '0.20.8';
1
+ // Version of the npm stdio MCP package (posterly-mcp-server).
2
+ //
3
+ // MUST stay in lockstep with the hosted HTTP endpoint version in
4
+ // lib/mcp/version.ts. The two MCP surfaces share one version namespace and
5
+ // tool set (minus the intentional pre-auth signup tools that only this stdio
6
+ // package exposes). `npm run check:mcp-parity` enforces both the version match
7
+ // and the tool-list match, and runs in the pre-commit hook.
8
+ export const POSTERLY_MCP_VERSION = '0.23.1';
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ const CATEGORIES = [
5
+ 'COVER',
6
+ 'PROFILE',
7
+ 'LOGO',
8
+ 'EXTERIOR',
9
+ 'INTERIOR',
10
+ 'PRODUCT',
11
+ 'AT_WORK',
12
+ 'FOOD_AND_DRINK',
13
+ 'MENU',
14
+ 'COMMON_AREA',
15
+ 'ROOMS',
16
+ 'TEAMS',
17
+ 'ADDITIONAL',
18
+ ] as const;
19
+
20
+ export const addGoogleBusinessMediaTool = {
21
+ name: 'add_google_business_media',
22
+ description:
23
+ 'Add a photo or video to a Google Business Profile gallery (the media shown on Maps and Search). Provide a public https source_url first by uploading via upload_media_from_url or upload_media. COVER and PROFILE are single-slot and replace the existing one. WRITE: show the location, category, and media URL to the user, then pass confirm=true only after explicit confirmation.',
24
+ inputSchema: z.object({
25
+ workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
26
+ account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
27
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
28
+ source_url: z.string().url().describe('Public https URL of the photo or video (from upload_media / upload_media_from_url).'),
29
+ category: z.enum(CATEGORIES).describe('Which gallery category to add the media to.'),
30
+ media_format: z.enum(['PHOTO', 'VIDEO']).optional().describe('Defaults to PHOTO. Use VIDEO for videos (Google caps profile videos at 30s / 75MB).'),
31
+ confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
32
+ }),
33
+
34
+ async execute(
35
+ client: PosterlyClient,
36
+ input: {
37
+ workspace_id?: string;
38
+ account_id?: number;
39
+ location_id?: string;
40
+ source_url: string;
41
+ category: (typeof CATEGORIES)[number];
42
+ media_format?: 'PHOTO' | 'VIDEO';
43
+ confirm: true;
44
+ },
45
+ ) {
46
+ const { confirm: _confirm, ...payload } = input;
47
+ const result = await client.addGoogleBusinessMedia(payload);
48
+ return result.message || 'Media added to the Google Business profile.';
49
+ },
50
+ };
@@ -8,7 +8,7 @@ export const auditGoogleBusinessProfileTool = {
8
8
  inputSchema: z.object({
9
9
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
10
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
11
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
11
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
12
12
  }),
13
13
 
14
14
  async execute(
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const deleteGoogleBusinessMediaTool = {
5
+ name: 'delete_google_business_media',
6
+ description:
7
+ 'Remove a photo or video from a Google Business Profile gallery. Get media_name from list_google_business_media. This permanently deletes the item from the public profile. WRITE: show the exact media item and location to the user, then pass confirm=true only after explicit confirmation.',
8
+ inputSchema: z.object({
9
+ workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
+ account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
11
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
12
+ media_name: z.string().min(1).describe('Full media resource name from list_google_business_media (accounts/.../locations/.../media/...).'),
13
+ confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
14
+ }),
15
+
16
+ async execute(
17
+ client: PosterlyClient,
18
+ input: {
19
+ workspace_id?: string;
20
+ account_id?: number;
21
+ location_id?: string;
22
+ media_name: string;
23
+ confirm: true;
24
+ },
25
+ ) {
26
+ const { confirm: _confirm, ...payload } = input;
27
+ const result = await client.deleteGoogleBusinessMedia(payload);
28
+ return result.message || 'Media removed from the Google Business profile.';
29
+ },
30
+ };
@@ -8,7 +8,7 @@ export const deleteGoogleBusinessReviewReplyTool = {
8
8
  inputSchema: z.object({
9
9
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
10
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
11
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
11
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
12
12
  review_name: z.string().min(1).describe('Full Google review resource name.'),
13
13
  confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
14
14
  }),
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
- import { code, compactText, formatDateTime, mdKeyValue, mdSection, mdTitle } from '../lib/format.js';
3
+ import type { LearnedVoice } from '../lib/api-client.js';
4
+ import { code, compactText, formatDateTime, mdLearnedVoice, mdKeyValue, mdSection, mdTitle } from '../lib/format.js';
4
5
 
5
6
  export const getBrandProfileTool = {
6
7
  name: 'get_brand_profile',
@@ -47,10 +48,20 @@ export const getBrandProfileTool = {
47
48
  ])),
48
49
  ].filter(Boolean).join('\n\n');
49
50
 
51
+ const learnedVoices = (Array.isArray(profile.learned_voice) ? profile.learned_voice : []) as LearnedVoice[];
52
+ const learnedBody = learnedVoices.length
53
+ ? mdSection(
54
+ 'Learned voice (from published captions)',
55
+ learnedVoices.map((voice) => mdLearnedVoice(voice)).filter(Boolean).join('\n\n'),
56
+ )
57
+ : '';
58
+
50
59
  return [
51
60
  mdTitle(`Brand profile: ${profile.brand_name}`),
52
61
  profileBody || 'No extended brand profile fields have been saved yet.',
53
- ].join('\n\n');
62
+ learnedBody ||
63
+ '_No learned voice yet. posterly learns each account’s voice automatically once it has enough published posts._',
64
+ ].filter(Boolean).join('\n\n');
54
65
  },
55
66
  };
56
67
 
@@ -4,11 +4,11 @@ import type { PosterlyClient } from '../lib/api-client.js';
4
4
  export const getGoogleBusinessReviewLinkTool = {
5
5
  name: 'get_google_business_review_link',
6
6
  description:
7
- 'Get the direct public Google review link for a connected Google Business Profile location. Requires account_id or location_id.',
7
+ 'Get the direct public Google review link for a connected Google Business Profile location. Requires account_id or location_id. Returns reviewLink plus the Google Place ID (ChIJ...); that Place ID is output only and must NOT be passed back as location_id to other tools.',
8
8
  inputSchema: z.object({
9
9
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
10
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
11
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
11
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID this tool returns. Prefer account_id.'),
12
12
  }),
13
13
 
14
14
  async execute(
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ import { mdLearnedVoice, mdTitle } from '../lib/format.js';
4
+
5
+ export const getLearnedVoiceTool = {
6
+ name: 'get_learned_voice',
7
+ description:
8
+ 'Get the voice posterly has learned from a connected account’s real published captions (the "learned captions" shown on the dashboard). Returns a narrative summary, voice traits, style guidelines, and observed posting habits. Keyed by social account ID (from list_accounts or list_brand_accounts). Returns nothing until the account has enough published posts to learn from. Read-only; this never overwrites the saved brand profile.',
9
+ inputSchema: z.object({
10
+ account_id: z
11
+ .union([z.string(), z.number()])
12
+ .describe('The social account ID to inspect (from list_accounts or list_brand_accounts).'),
13
+ }),
14
+
15
+ async execute(
16
+ client: PosterlyClient,
17
+ input: { account_id: string | number },
18
+ ) {
19
+ const { learned_voice } = await client.getAccountLearnedVoice(input.account_id);
20
+
21
+ const body = mdLearnedVoice(learned_voice);
22
+ if (!body) {
23
+ return [
24
+ mdTitle('No learned voice yet'),
25
+ 'posterly has not learned a voice for this account yet. It learns automatically once the account has enough published posts (the captions are analyzed; the saved brand profile is never overwritten).',
26
+ ].join('\n\n');
27
+ }
28
+
29
+ return [mdTitle('Learned voice (from published captions)'), body].join('\n\n');
30
+ },
31
+ };
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const listGoogleBusinessMediaTool = {
5
+ name: 'list_google_business_media',
6
+ description:
7
+ 'List the photos and videos on a Google Business Profile gallery (the media shown on Maps and Search) for one location/account or every accessible GBP location. This is separate from media attached to a post. Returns each item\'s resource name, category, format, and URLs.',
8
+ inputSchema: z.object({
9
+ workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
+ account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
11
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
12
+ }),
13
+
14
+ async execute(
15
+ client: PosterlyClient,
16
+ input: { workspace_id?: string; account_id?: number; location_id?: string },
17
+ ) {
18
+ const result = await client.listGoogleBusinessMedia(input);
19
+ if (result.media.length === 0) {
20
+ return 'No photos or videos found on the Google Business profile for your filters.';
21
+ }
22
+
23
+ const lines = [`Google Business profile media (${result.media.length}):`];
24
+ for (const item of result.media.slice(0, 50)) {
25
+ const kind = item.mediaFormat === 'VIDEO' ? 'video' : 'photo';
26
+ const category = item.category || 'uncategorised';
27
+ const attribution = item.attribution?.profileName ? ` by ${item.attribution.profileName}` : '';
28
+ lines.push(`- [${kind}] ${category}${attribution} :: ${item.name}`);
29
+ }
30
+ lines.push('', 'Use the resource name (after ::) as media_name to delete an item.');
31
+ return lines.join('\n');
32
+ },
33
+ };
@@ -8,7 +8,7 @@ export const listGoogleBusinessReviewsTool = {
8
8
  inputSchema: z.object({
9
9
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
10
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
11
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
11
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
12
12
  rating: z.number().int().min(1).max(5).optional().describe('Filter to a 1-5 star rating.'),
13
13
  unanswered: z.boolean().optional().describe('Only return reviews without owner replies.'),
14
14
  }),
@@ -8,7 +8,7 @@ export const replyGoogleBusinessReviewTool = {
8
8
  inputSchema: z.object({
9
9
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
10
  account_id: z.number().int().positive().optional().describe('Google Business account ID from list_accounts.'),
11
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
11
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
12
12
  review_name: z.string().min(1).describe('Full Google review resource name.'),
13
13
  comment: z.string().min(1).max(4096).describe('Exact owner reply text to post.'),
14
14
  confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
@@ -8,7 +8,7 @@ export const suggestGoogleBusinessReviewReplyTool = {
8
8
  inputSchema: z.object({
9
9
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
10
  account_id: z.number().int().positive().optional().describe('Optional Google Business account ID for brand voice context.'),
11
- location_id: z.string().optional().describe('Google Business location/platform_user_id.'),
11
+ location_id: z.string().optional().describe('Google Business numeric location id (the location_id/platform_user_id from list_accounts, e.g. "197940849675145390"). NOT the ChIJ... Place ID returned by get_google_business_review_link. Prefer account_id.'),
12
12
  review_text: z.string().optional().describe('Review text. Leave empty for rating-only reviews.'),
13
13
  star_rating: z.number().min(1).max(5).describe('Review rating from 1 to 5.'),
14
14
  reviewer_name: z.string().optional(),