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.
- package/README.md +12 -2
- package/dist/index.js +10 -0
- package/dist/lib/api-client.d.ts +30 -0
- package/dist/lib/api-client.js +73 -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-account-analytics.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/get-post-analytics.js +1 -1
- 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 +117 -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-account-analytics.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/get-post-analytics.ts +1 -1
- 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)
|
|
@@ -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
|
|
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);
|
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;
|
|
@@ -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 {};
|
package/dist/lib/api-client.js
CHANGED
|
@@ -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) : {};
|
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
|
}),
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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,
|
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 {
|
|
@@ -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}`;
|
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
|
}),
|
|
@@ -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
|
|
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 {
|
|
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
|
+
};
|
|
@@ -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
|
|
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(),
|