posterly-mcp-server 0.23.0 → 0.23.2

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 (40) hide show
  1. package/README.md +12 -2
  2. package/dist/index.js +10 -0
  3. package/dist/lib/api-client.d.ts +30 -0
  4. package/dist/lib/api-client.js +73 -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.js +1 -1
  10. package/dist/tools/audit-google-business-profile.js +1 -1
  11. package/dist/tools/delete-google-business-media.js +1 -1
  12. package/dist/tools/delete-google-business-review-reply.js +1 -1
  13. package/dist/tools/get-account-analytics.js +1 -1
  14. package/dist/tools/get-brand-profile.js +8 -2
  15. package/dist/tools/get-google-business-review-link.js +2 -2
  16. package/dist/tools/get-learned-voice.d.ts +16 -0
  17. package/dist/tools/get-learned-voice.js +22 -0
  18. package/dist/tools/get-post-analytics.js +1 -1
  19. package/dist/tools/list-google-business-media.js +1 -1
  20. package/dist/tools/list-google-business-reviews.js +1 -1
  21. package/dist/tools/reply-google-business-review.js +1 -1
  22. package/dist/tools/suggest-google-business-review-reply.js +1 -1
  23. package/package.json +1 -1
  24. package/src/index.ts +15 -0
  25. package/src/lib/api-client.ts +117 -0
  26. package/src/lib/format.ts +46 -1
  27. package/src/lib/version.ts +8 -1
  28. package/src/tools/add-google-business-media.ts +1 -1
  29. package/src/tools/audit-google-business-profile.ts +1 -1
  30. package/src/tools/delete-google-business-media.ts +1 -1
  31. package/src/tools/delete-google-business-review-reply.ts +1 -1
  32. package/src/tools/get-account-analytics.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/get-post-analytics.ts +1 -1
  37. package/src/tools/list-google-business-media.ts +1 -1
  38. package/src/tools/list-google-business-reviews.ts +1 -1
  39. package/src/tools/reply-google-business-review.ts +1 -1
  40. 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.23.0` exposes 62 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`
@@ -170,6 +171,9 @@ Authenticated tools require `POSTERLY_API_KEY`:
170
171
  - `list_google_business_media` (list the photos/videos on a GBP profile gallery)
171
172
  - `add_google_business_media` (add a photo/video to a GBP profile gallery from a public URL, after explicit confirmation)
172
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
+
173
177
  - `list_activity`
174
178
  - `list_webhooks`
175
179
  - `create_webhook` (create a webhook after explicit confirmation)
@@ -178,7 +182,13 @@ Authenticated tools require `POSTERLY_API_KEY`:
178
182
  - `test_webhook` (send a signed test delivery after explicit confirmation)
179
183
  - `get_x_posting_quota`
180
184
 
181
- Analytics tools currently support Instagram, LinkedIn, Google Business Profile, Pinterest, and YouTube.
185
+ Analytics tools currently support Instagram, Facebook Pages, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads.
186
+
187
+ ## Media uploads
188
+
189
+ This npm/stdio server can read local file paths. When `upload_media` receives a larger local file, it automatically requests a signed upload URL and uploads the raw bytes before returning the public media URL.
190
+
191
+ The hosted HTTP MCP endpoint cannot do that for local files by itself because MCP tool calls are JSON-only. On hosted HTTP MCP, `upload_media` is for small base64 uploads up to 5MB decoded. For larger media there, use `upload_media_from_url` for public direct media URLs, or call `create_signed_upload` only from clients that can also `PUT` the raw file bytes to the returned `upload_url`.
182
192
 
183
193
  ## What the brand tools are for
184
194
 
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';
@@ -278,6 +279,15 @@ server.tool(getBrandProfileTool.name, getBrandProfileTool.description, getBrandP
278
279
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
279
280
  }
280
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
+ });
281
291
  server.tool(createPostTool.name, createPostTool.description, createPostTool.inputSchema.shape, async (input) => {
282
292
  try {
283
293
  const text = await createPostTool.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;
@@ -680,6 +694,9 @@ export declare class PosterlyClient {
680
694
  getBrandProfile(id: string): Promise<{
681
695
  brand_profile: BrandProfile;
682
696
  }>;
697
+ getAccountLearnedVoice(id: string | number): Promise<{
698
+ learned_voice: LearnedVoice | null;
699
+ }>;
683
700
  listPosts(params?: {
684
701
  status?: string;
685
702
  platform?: string;
@@ -950,3 +967,16 @@ export declare class PosterlyClient {
950
967
  }>;
951
968
  }
952
969
  export declare function resolvePosterlyBaseUrl(baseUrl?: string): string;
970
+ type PosterlyBaseUrlInspection = {
971
+ valid: boolean;
972
+ secure: boolean;
973
+ protocol?: string;
974
+ host?: string;
975
+ trustedPosterlyHost: boolean;
976
+ localDevelopmentHost: boolean;
977
+ warnings: string[];
978
+ message: string;
979
+ };
980
+ export declare function inspectPosterlyBaseUrl(baseUrl: string): PosterlyBaseUrlInspection;
981
+ export declare function validatePosterlyBaseUrlForKeyUse(baseUrl: string): void;
982
+ export {};
@@ -6,6 +6,9 @@ export class PosterlyClient {
6
6
  constructor(apiKey, baseUrl) {
7
7
  this.apiKey = apiKey || process.env.POSTERLY_API_KEY || '';
8
8
  this.baseUrl = resolvePosterlyBaseUrl(baseUrl);
9
+ if (this.apiKey) {
10
+ validatePosterlyBaseUrlForKeyUse(this.baseUrl);
11
+ }
9
12
  }
10
13
  hasApiKey() {
11
14
  return Boolean(this.apiKey);
@@ -179,6 +182,9 @@ export class PosterlyClient {
179
182
  async getBrandProfile(id) {
180
183
  return this.request('GET', `/brands/${encodeURIComponent(id)}/profile`);
181
184
  }
185
+ async getAccountLearnedVoice(id) {
186
+ return this.request('GET', `/accounts/${encodeURIComponent(String(id))}/learned-voice`);
187
+ }
182
188
  async listPosts(params) {
183
189
  const searchParams = new URLSearchParams();
184
190
  if (params?.status)
@@ -482,6 +488,73 @@ export function resolvePosterlyBaseUrl(baseUrl) {
482
488
  return configured.replace(/\/api\/v1\/?$/, '').replace(/\/$/, '');
483
489
  }
484
490
  }
491
+ const TRUSTED_POSTERLY_HOSTS = new Set(['poster.ly', 'www.poster.ly']);
492
+ function flagEnabled(name) {
493
+ const value = (process.env[name] || '').trim().toLowerCase();
494
+ return value === 'true' || value === '1' || value === 'yes';
495
+ }
496
+ function normalizeHostname(hostname) {
497
+ return hostname.toLowerCase().replace(/^\[|\]$/g, '');
498
+ }
499
+ function isLocalHost(hostname) {
500
+ const normalized = normalizeHostname(hostname);
501
+ return (normalized === 'localhost' ||
502
+ normalized.endsWith('.localhost') ||
503
+ normalized === '127.0.0.1' ||
504
+ normalized === '::1');
505
+ }
506
+ export function inspectPosterlyBaseUrl(baseUrl) {
507
+ try {
508
+ const url = new URL(baseUrl);
509
+ const hostname = normalizeHostname(url.hostname);
510
+ const local = isLocalHost(hostname);
511
+ const trustedPosterlyHost = TRUSTED_POSTERLY_HOSTS.has(hostname);
512
+ const secure = url.protocol === 'https:' || (url.protocol === 'http:' && local);
513
+ const warnings = [];
514
+ if (!trustedPosterlyHost) {
515
+ warnings.push(local ? 'local_development_host' : 'custom_host');
516
+ }
517
+ if (!secure) {
518
+ warnings.push('insecure_non_local_url');
519
+ }
520
+ return {
521
+ valid: true,
522
+ secure,
523
+ protocol: url.protocol.replace(/:$/, ''),
524
+ host: url.host,
525
+ trustedPosterlyHost,
526
+ localDevelopmentHost: local,
527
+ warnings,
528
+ message: secure
529
+ ? 'Base URL is secure for API-key requests.'
530
+ : 'Non-local HTTP URLs are blocked for API-key requests.',
531
+ };
532
+ }
533
+ catch {
534
+ return {
535
+ valid: false,
536
+ secure: false,
537
+ trustedPosterlyHost: false,
538
+ localDevelopmentHost: false,
539
+ warnings: ['invalid_url'],
540
+ message: 'Base URL must include a scheme and host, for example https://www.poster.ly.',
541
+ };
542
+ }
543
+ }
544
+ export function validatePosterlyBaseUrlForKeyUse(baseUrl) {
545
+ const check = inspectPosterlyBaseUrl(baseUrl);
546
+ if (!check.valid) {
547
+ throw new Error(`${check.message} Received: ${baseUrl}`);
548
+ }
549
+ if (!check.secure && !flagEnabled('POSTERLY_ALLOW_INSECURE_URL')) {
550
+ throw new Error(`Refusing to send an API key to ${baseUrl}. Use https, http://localhost for local development, or set POSTERLY_ALLOW_INSECURE_URL=true only for a trusted private development endpoint.`);
551
+ }
552
+ if (!check.trustedPosterlyHost &&
553
+ !check.localDevelopmentHost &&
554
+ !flagEnabled('POSTERLY_ALLOW_CUSTOM_BASE_URL')) {
555
+ throw new Error(`Refusing to send an API key to custom host ${baseUrl}. Set POSTERLY_ALLOW_CUSTOM_BASE_URL=true only for a trusted Posterly deployment.`);
556
+ }
557
+ }
485
558
  function parseJson(text) {
486
559
  try {
487
560
  return text ? JSON.parse(text) : {};
@@ -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.23.0";
1
+ export declare const POSTERLY_MCP_VERSION = "0.23.1";
@@ -1 +1,8 @@
1
- export const POSTERLY_MCP_VERSION = '0.23.0';
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';
@@ -20,7 +20,7 @@ export const addGoogleBusinessMediaTool = {
20
20
  inputSchema: z.object({
21
21
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
22
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 location/platform_user_id. Provide account_id or location_id.'),
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
24
  source_url: z.string().url().describe('Public https URL of the photo or video (from upload_media / upload_media_from_url).'),
25
25
  category: z.enum(CATEGORIES).describe('Which gallery category to add the media to.'),
26
26
  media_format: z.enum(['PHOTO', 'VIDEO']).optional().describe('Defaults to PHOTO. Use VIDEO for videos (Google caps profile videos at 30s / 75MB).'),
@@ -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);
@@ -5,7 +5,7 @@ export const deleteGoogleBusinessMediaTool = {
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. Provide account_id or location_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
  media_name: z.string().min(1).describe('Full media resource name from list_google_business_media (accounts/.../locations/.../media/...).'),
10
10
  confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
11
11
  }),
@@ -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
  }),
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
3
3
  export const getAccountAnalyticsTool = {
4
4
  name: 'get_account_analytics',
5
- description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
5
+ description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
6
6
  inputSchema: z.object({
7
7
  account_id: z
8
8
  .number()
@@ -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
+ };
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
3
3
  export const getPostAnalyticsTool = {
4
4
  name: 'get_post_analytics',
5
- description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
5
+ description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
6
6
  inputSchema: z.object({
7
7
  account_id: z
8
8
  .number()
@@ -5,7 +5,7 @@ export const listGoogleBusinessMediaTool = {
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.listGoogleBusinessMedia(input);
@@ -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.23.0",
3
+ "version": "0.23.2",
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';
@@ -397,6 +398,20 @@ server.tool(
397
398
  }
398
399
  );
399
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
+
400
415
  server.tool(
401
416
  createPostTool.name,
402
417
  createPostTool.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 {
@@ -623,6 +638,9 @@ export class PosterlyClient {
623
638
  constructor(apiKey?: string, baseUrl?: string) {
624
639
  this.apiKey = apiKey || process.env.POSTERLY_API_KEY || '';
625
640
  this.baseUrl = resolvePosterlyBaseUrl(baseUrl);
641
+ if (this.apiKey) {
642
+ validatePosterlyBaseUrlForKeyUse(this.baseUrl);
643
+ }
626
644
  }
627
645
 
628
646
  hasApiKey(): boolean {
@@ -867,6 +885,10 @@ export class PosterlyClient {
867
885
  return this.request('GET', `/brands/${encodeURIComponent(id)}/profile`);
868
886
  }
869
887
 
888
+ async getAccountLearnedVoice(id: string | number): Promise<{ learned_voice: LearnedVoice | null }> {
889
+ return this.request('GET', `/accounts/${encodeURIComponent(String(id))}/learned-voice`);
890
+ }
891
+
870
892
  async listPosts(params?: {
871
893
  status?: string;
872
894
  platform?: string;
@@ -1364,6 +1386,101 @@ export function resolvePosterlyBaseUrl(baseUrl?: string): string {
1364
1386
  }
1365
1387
  }
1366
1388
 
1389
+ const TRUSTED_POSTERLY_HOSTS = new Set(['poster.ly', 'www.poster.ly']);
1390
+
1391
+ type PosterlyBaseUrlInspection = {
1392
+ valid: boolean;
1393
+ secure: boolean;
1394
+ protocol?: string;
1395
+ host?: string;
1396
+ trustedPosterlyHost: boolean;
1397
+ localDevelopmentHost: boolean;
1398
+ warnings: string[];
1399
+ message: string;
1400
+ };
1401
+
1402
+ function flagEnabled(name: string): boolean {
1403
+ const value = (process.env[name] || '').trim().toLowerCase();
1404
+ return value === 'true' || value === '1' || value === 'yes';
1405
+ }
1406
+
1407
+ function normalizeHostname(hostname: string): string {
1408
+ return hostname.toLowerCase().replace(/^\[|\]$/g, '');
1409
+ }
1410
+
1411
+ function isLocalHost(hostname: string): boolean {
1412
+ const normalized = normalizeHostname(hostname);
1413
+ return (
1414
+ normalized === 'localhost' ||
1415
+ normalized.endsWith('.localhost') ||
1416
+ normalized === '127.0.0.1' ||
1417
+ normalized === '::1'
1418
+ );
1419
+ }
1420
+
1421
+ export function inspectPosterlyBaseUrl(baseUrl: string): PosterlyBaseUrlInspection {
1422
+ try {
1423
+ const url = new URL(baseUrl);
1424
+ const hostname = normalizeHostname(url.hostname);
1425
+ const local = isLocalHost(hostname);
1426
+ const trustedPosterlyHost = TRUSTED_POSTERLY_HOSTS.has(hostname);
1427
+ const secure = url.protocol === 'https:' || (url.protocol === 'http:' && local);
1428
+ const warnings: string[] = [];
1429
+
1430
+ if (!trustedPosterlyHost) {
1431
+ warnings.push(local ? 'local_development_host' : 'custom_host');
1432
+ }
1433
+ if (!secure) {
1434
+ warnings.push('insecure_non_local_url');
1435
+ }
1436
+
1437
+ return {
1438
+ valid: true,
1439
+ secure,
1440
+ protocol: url.protocol.replace(/:$/, ''),
1441
+ host: url.host,
1442
+ trustedPosterlyHost,
1443
+ localDevelopmentHost: local,
1444
+ warnings,
1445
+ message: secure
1446
+ ? 'Base URL is secure for API-key requests.'
1447
+ : 'Non-local HTTP URLs are blocked for API-key requests.',
1448
+ };
1449
+ } catch {
1450
+ return {
1451
+ valid: false,
1452
+ secure: false,
1453
+ trustedPosterlyHost: false,
1454
+ localDevelopmentHost: false,
1455
+ warnings: ['invalid_url'],
1456
+ message: 'Base URL must include a scheme and host, for example https://www.poster.ly.',
1457
+ };
1458
+ }
1459
+ }
1460
+
1461
+ export function validatePosterlyBaseUrlForKeyUse(baseUrl: string): void {
1462
+ const check = inspectPosterlyBaseUrl(baseUrl);
1463
+ if (!check.valid) {
1464
+ throw new Error(`${check.message} Received: ${baseUrl}`);
1465
+ }
1466
+
1467
+ if (!check.secure && !flagEnabled('POSTERLY_ALLOW_INSECURE_URL')) {
1468
+ throw new Error(
1469
+ `Refusing to send an API key to ${baseUrl}. Use https, http://localhost for local development, or set POSTERLY_ALLOW_INSECURE_URL=true only for a trusted private development endpoint.`
1470
+ );
1471
+ }
1472
+
1473
+ if (
1474
+ !check.trustedPosterlyHost &&
1475
+ !check.localDevelopmentHost &&
1476
+ !flagEnabled('POSTERLY_ALLOW_CUSTOM_BASE_URL')
1477
+ ) {
1478
+ throw new Error(
1479
+ `Refusing to send an API key to custom host ${baseUrl}. Set POSTERLY_ALLOW_CUSTOM_BASE_URL=true only for a trusted Posterly deployment.`
1480
+ );
1481
+ }
1482
+ }
1483
+
1367
1484
  function parseJson(text: string): any {
1368
1485
  try {
1369
1486
  return text ? JSON.parse(text) : {};
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.23.0';
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';
@@ -24,7 +24,7 @@ export const addGoogleBusinessMediaTool = {
24
24
  inputSchema: z.object({
25
25
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
26
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 location/platform_user_id. Provide account_id or location_id.'),
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
28
  source_url: z.string().url().describe('Public https URL of the photo or video (from upload_media / upload_media_from_url).'),
29
29
  category: z.enum(CATEGORIES).describe('Which gallery category to add the media to.'),
30
30
  media_format: z.enum(['PHOTO', 'VIDEO']).optional().describe('Defaults to PHOTO. Use VIDEO for videos (Google caps profile videos at 30s / 75MB).'),
@@ -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(
@@ -8,7 +8,7 @@ export const deleteGoogleBusinessMediaTool = {
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. Provide account_id or location_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
  media_name: z.string().min(1).describe('Full media resource name from list_google_business_media (accounts/.../locations/.../media/...).'),
13
13
  confirm: z.literal(true).describe('Must be true after explicit user confirmation.'),
14
14
  }),
@@ -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
  }),
@@ -8,7 +8,7 @@ type MetricRow = [string, string];
8
8
  export const getAccountAnalyticsTool = {
9
9
  name: 'get_account_analytics',
10
10
  description:
11
- 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
11
+ 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
12
12
  inputSchema: z.object({
13
13
  account_id: z
14
14
  .number()
@@ -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
+ };
@@ -7,7 +7,7 @@ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
7
7
  export const getPostAnalyticsTool = {
8
8
  name: 'get_post_analytics',
9
9
  description:
10
- 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
10
+ 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
11
11
  inputSchema: z.object({
12
12
  account_id: z
13
13
  .number()
@@ -8,7 +8,7 @@ export const listGoogleBusinessMediaTool = {
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(
@@ -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(),