posterly-mcp-server 0.23.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.
- package/README.md +5 -1
- package/dist/index.js +10 -0
- package/dist/lib/api-client.d.ts +17 -0
- package/dist/lib/api-client.js +3 -0
- package/dist/lib/format.d.ts +6 -1
- package/dist/lib/format.js +44 -0
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +8 -1
- package/dist/tools/add-google-business-media.js +1 -1
- package/dist/tools/audit-google-business-profile.js +1 -1
- package/dist/tools/delete-google-business-media.js +1 -1
- package/dist/tools/delete-google-business-review-reply.js +1 -1
- package/dist/tools/get-brand-profile.js +8 -2
- package/dist/tools/get-google-business-review-link.js +2 -2
- package/dist/tools/get-learned-voice.d.ts +16 -0
- package/dist/tools/get-learned-voice.js +22 -0
- package/dist/tools/list-google-business-media.js +1 -1
- package/dist/tools/list-google-business-reviews.js +1 -1
- package/dist/tools/reply-google-business-review.js +1 -1
- package/dist/tools/suggest-google-business-review-reply.js +1 -1
- package/package.json +1 -1
- package/src/index.ts +15 -0
- package/src/lib/api-client.ts +19 -0
- package/src/lib/format.ts +46 -1
- package/src/lib/version.ts +8 -1
- package/src/tools/add-google-business-media.ts +1 -1
- package/src/tools/audit-google-business-profile.ts +1 -1
- package/src/tools/delete-google-business-media.ts +1 -1
- package/src/tools/delete-google-business-review-reply.ts +1 -1
- package/src/tools/get-brand-profile.ts +13 -2
- package/src/tools/get-google-business-review-link.ts +2 -2
- package/src/tools/get-learned-voice.ts +31 -0
- package/src/tools/list-google-business-media.ts +1 -1
- package/src/tools/list-google-business-reviews.ts +1 -1
- package/src/tools/reply-google-business-review.ts +1 -1
- 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
|
|
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)
|
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);
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -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)
|
package/dist/lib/format.d.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
|
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 {};
|
package/dist/lib/format.js
CHANGED
|
@@ -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))
|
package/dist/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const POSTERLY_MCP_VERSION = "0.23.
|
|
1
|
+
export declare const POSTERLY_MCP_VERSION = "0.23.1";
|
package/dist/lib/version.js
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
|
|
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.
|
|
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.
|
|
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
|
}),
|
|
@@ -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
|
-
|
|
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
|
+
};
|
|
@@ -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.
|
|
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';
|
|
@@ -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,
|
package/src/lib/api-client.ts
CHANGED
|
@@ -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 {
|
|
@@ -867,6 +882,10 @@ export class PosterlyClient {
|
|
|
867
882
|
return this.request('GET', `/brands/${encodeURIComponent(id)}/profile`);
|
|
868
883
|
}
|
|
869
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
|
+
|
|
870
889
|
async listPosts(params?: {
|
|
871
890
|
status?: string;
|
|
872
891
|
platform?: string;
|
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}`;
|
package/src/lib/version.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
|
|
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.
|
|
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.
|
|
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
|
}),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { PosterlyClient } from '../lib/api-client.js';
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
+
};
|
|
@@ -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(),
|