posterly-mcp-server 0.19.3 → 0.19.5

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 CHANGED
@@ -93,7 +93,7 @@ Add the same server definition to your Cursor MCP settings:
93
93
 
94
94
  ## Available tools
95
95
 
96
- `posterly-mcp-server@0.19.0` exposes 49 tools:
96
+ `posterly-mcp-server@0.19.5` exposes 49 tools:
97
97
 
98
98
  - `whoami`
99
99
  - `list_accounts`
@@ -164,6 +164,12 @@ export interface AccountAnalyticsSummary {
164
164
  total_watch_minutes?: number | null;
165
165
  total_likes?: number | null;
166
166
  platform_metrics?: Record<string, number | null | undefined>;
167
+ display_metrics?: Array<{
168
+ key?: string;
169
+ label: string;
170
+ value: number | null | undefined;
171
+ unit?: string;
172
+ }>;
167
173
  }
168
174
  export interface AccountAnalyticsSnapshot {
169
175
  date: string;
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
+ declare const presentationSchema: z.ZodOptional<z.ZodEnum<["compact", "table", "json"]>>;
4
+ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
3
5
  export declare const getAccountAnalyticsTool: {
4
6
  name: string;
5
7
  description: string;
@@ -7,18 +9,23 @@ export declare const getAccountAnalyticsTool: {
7
9
  account_id: z.ZodNumber;
8
10
  from: z.ZodOptional<z.ZodString>;
9
11
  to: z.ZodOptional<z.ZodString>;
12
+ presentation: z.ZodOptional<z.ZodEnum<["compact", "table", "json"]>>;
10
13
  }, "strip", z.ZodTypeAny, {
11
14
  account_id: number;
12
15
  from?: string | undefined;
13
16
  to?: string | undefined;
17
+ presentation?: "compact" | "table" | "json" | undefined;
14
18
  }, {
15
19
  account_id: number;
16
20
  from?: string | undefined;
17
21
  to?: string | undefined;
22
+ presentation?: "compact" | "table" | "json" | undefined;
18
23
  }>;
19
24
  execute(client: PosterlyClient, input: {
20
25
  account_id: number;
21
26
  from?: string;
22
27
  to?: string;
28
+ presentation?: AnalyticsPresentation;
23
29
  }): Promise<string>;
24
30
  };
31
+ export {};
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
+ const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
2
3
  export const getAccountAnalyticsTool = {
3
4
  name: 'get_account_analytics',
4
- description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. For Google Business Profile, returns Profile Views, Search Views, Maps Views, Customer Actions, and Posts.',
5
+ description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
5
6
  inputSchema: z.object({
6
7
  account_id: z
7
8
  .number()
@@ -14,55 +15,138 @@ export const getAccountAnalyticsTool = {
14
15
  .string()
15
16
  .optional()
16
17
  .describe('End date (ISO date). Defaults to today.'),
18
+ presentation: presentationSchema.describe('Output style: compact bullets, Markdown table, or raw JSON for client-side chart/card rendering.'),
17
19
  }),
18
20
  async execute(client, input) {
19
21
  const result = await client.getAccountAnalytics(input);
20
- const { account, range, summary, snapshots } = result;
21
- const accountLabel = account.platform === 'google_business'
22
- ? `${account.username} (Google Business, id ${account.id})`
23
- : `@${account.username} (${account.platform}, id ${account.id})`;
24
- const lines = [
25
- `Analytics for ${accountLabel}`,
26
- `Range: ${range.from} → ${range.to} (${snapshots.length} daily snapshots)`,
27
- '',
28
- 'Summary:',
29
- ];
30
- if (account.platform === 'google_business') {
31
- const metrics = summary.platform_metrics || {};
32
- pushMetric(lines, 'Profile Views', metrics.profile_views ?? summary.total_views);
33
- pushMetric(lines, 'Search Views', metrics.search_views ?? summary.total_reach);
34
- pushMetric(lines, 'Maps Views', metrics.maps_views ?? summary.total_profile_views);
35
- pushMetric(lines, 'Customer Actions', metrics.customer_actions ?? summary.total_accounts_engaged);
36
- pushMetric(lines, 'Posts', metrics.posts ?? summary.current_media_count);
37
- }
38
- else if (account.platform === 'pinterest') {
39
- pushGenericSummary(lines, summary);
40
- pushMetric(lines, 'Outbound clicks', summary.total_website_clicks);
41
- }
42
- else if (account.platform === 'youtube') {
43
- pushGenericSummary(lines, summary);
44
- pushMetric(lines, 'Watch minutes', summary.total_watch_minutes);
45
- pushMetric(lines, 'Likes', summary.total_likes);
46
- }
47
- else {
48
- pushGenericSummary(lines, summary);
49
- }
50
- return lines.join('\n');
22
+ return formatAccountAnalytics(result, input.presentation || 'compact');
51
23
  },
52
24
  };
25
+ function formatAccountAnalytics(result, presentation) {
26
+ const { account, range, summary, snapshots } = result;
27
+ const rows = accountMetricRows(summary, account.platform);
28
+ const insights = analyticsInsights(summary, account.platform);
29
+ if (presentation === 'json') {
30
+ return JSON.stringify({
31
+ type: 'account_analytics',
32
+ account,
33
+ range,
34
+ snapshot_count: snapshots.length,
35
+ metrics: rows.map(([label, value]) => ({ label, value })),
36
+ insights,
37
+ summary,
38
+ snapshots,
39
+ }, null, 2);
40
+ }
41
+ if (presentation === 'table') {
42
+ return [
43
+ `**📊 Analytics for ${accountLabel(account.username)} (${account.id})**`,
44
+ `_${account.platform} · ${range.from} → ${range.to}_`,
45
+ '',
46
+ '**Account**',
47
+ markdownTable(['Field', 'Value'], [
48
+ ['Account ID', String(account.id)],
49
+ ['Daily snapshots', formatNumber(snapshots.length)],
50
+ ]),
51
+ '',
52
+ '**Metrics**',
53
+ markdownTable(['Metric', 'Value'], rows),
54
+ '',
55
+ '**Quick read**',
56
+ insights.map((insight) => `- ${insight}`).join('\n'),
57
+ ].join('\n');
58
+ }
59
+ return [
60
+ `**📊 Analytics for ${accountLabel(account.username)} (${account.id})**`,
61
+ `_${account.platform} · ${range.from} → ${range.to}_`,
62
+ '',
63
+ '**Account**',
64
+ `- **Account ID:** ${account.id}`,
65
+ `- **Daily snapshots:** ${formatNumber(snapshots.length)}`,
66
+ '',
67
+ '**Key metrics**',
68
+ rows.map(([label, value]) => `- **${label}:** ${value}`).join('\n'),
69
+ '',
70
+ '**Quick read**',
71
+ insights.map((insight) => `- ${insight}`).join('\n'),
72
+ ].join('\n');
73
+ }
74
+ function accountMetricRows(summary, platform) {
75
+ const nativeRows = nativeMetricRows(summary);
76
+ if (nativeRows.length > 0)
77
+ return nativeRows;
78
+ if (platform === 'google_business') {
79
+ const metrics = summary.platform_metrics || {};
80
+ return [
81
+ ['Profile Views', formatNumber(metrics.profile_views ?? summary.total_views)],
82
+ ['Search Views', formatNumber(metrics.search_views ?? summary.total_reach)],
83
+ ['Maps Views', formatNumber(metrics.maps_views ?? summary.total_profile_views)],
84
+ ['Customer Actions', formatNumber(metrics.customer_actions ?? summary.total_accounts_engaged)],
85
+ ['Posts', formatNumber(metrics.posts ?? summary.current_media_count)],
86
+ ];
87
+ }
88
+ const rows = genericMetricRows(summary);
89
+ if (platform === 'pinterest')
90
+ rows.push(['Outbound clicks', formatNumber(summary.total_website_clicks)]);
91
+ if (platform === 'youtube') {
92
+ rows.push(['Watch minutes', formatNumber(summary.total_watch_minutes)]);
93
+ rows.push(['Likes', formatNumber(summary.total_likes)]);
94
+ }
95
+ return rows;
96
+ }
97
+ function nativeMetricRows(summary) {
98
+ return (summary.display_metrics || [])
99
+ .filter((metric) => metric?.label)
100
+ .map((metric) => [metric.label, formatNumber(metric.value)]);
101
+ }
102
+ function genericMetricRows(summary) {
103
+ return [
104
+ ['Followers', `${formatNumber(summary.current_followers)} (${formatDelta(summary.followers_change)} in range)`],
105
+ ['Follows gained / lost', `+${formatNumber(summary.total_follows_gained)} / -${formatNumber(summary.total_follows_lost)}`],
106
+ ['Total reach', formatNumber(summary.total_reach)],
107
+ ['Total views', formatNumber(summary.total_views)],
108
+ ['Profile views', formatNumber(summary.total_profile_views)],
109
+ ['Accounts engaged', formatNumber(summary.total_accounts_engaged)],
110
+ ['Engagement rate by reach', formatPercent(summary.engagement_rate)],
111
+ ['Engagement rate by followers', formatPercent(summary.engagement_rate_by_followers)],
112
+ ];
113
+ }
114
+ function analyticsInsights(summary, platform) {
115
+ const insights = [];
116
+ if (platform === 'google_business' && (summary.display_metrics?.length || 0) > 0) {
117
+ insights.push('Google Business metrics are shown with dashboard-native labels.');
118
+ }
119
+ if ((summary.followers_change || 0) > 0)
120
+ insights.push(`Audience grew by ${formatDelta(summary.followers_change)} followers in this range.`);
121
+ if ((summary.followers_change || 0) < 0)
122
+ insights.push(`Audience dipped by ${formatDelta(summary.followers_change)} followers in this range.`);
123
+ if (summary.engagement_rate != null && platform !== 'google_business')
124
+ insights.push(`Engagement by reach is ${formatPercent(summary.engagement_rate)}.`);
125
+ if (platform === 'youtube' && summary.total_watch_minutes != null)
126
+ insights.push(`${formatNumber(summary.total_watch_minutes)} total watch minutes were recorded.`);
127
+ return insights.length ? insights : ['No standout movement detected in the returned period.'];
128
+ }
53
129
  function formatDelta(n) {
54
130
  return n >= 0 ? `+${n.toLocaleString()}` : n.toLocaleString();
55
131
  }
56
- function pushGenericSummary(lines, summary) {
57
- lines.push(`• Followers: ${summary.current_followers.toLocaleString()} (${formatDelta(summary.followers_change)} in range)`, `• Follows gained / lost: +${summary.total_follows_gained} / -${summary.total_follows_lost}`);
58
- pushMetric(lines, 'Total reach', summary.total_reach);
59
- pushMetric(lines, 'Total views', summary.total_views);
60
- pushMetric(lines, 'Profile views', summary.total_profile_views);
61
- pushMetric(lines, 'Total accounts engaged', summary.total_accounts_engaged);
62
- lines.push(`• Engagement rate (by reach): ${summary.engagement_rate}%`, `• Engagement rate (by followers): ${summary.engagement_rate_by_followers}%`);
132
+ function formatNumber(value) {
133
+ return value == null ? 'n/a' : value.toLocaleString();
63
134
  }
64
- function pushMetric(lines, label, value) {
65
- if (value != null) {
66
- lines.push(`• ${label}: ${value.toLocaleString()}`);
67
- }
135
+ function formatPercent(value) {
136
+ return value == null ? 'n/a' : `${formatNumber(value)}%`;
137
+ }
138
+ function accountLabel(username) {
139
+ if (!username)
140
+ return 'account';
141
+ return username.startsWith('@') || /\s/.test(username) ? username : `@${username}`;
142
+ }
143
+ function markdownTable(headers, rows) {
144
+ return [
145
+ `| ${headers.map(escapeCell).join(' | ')} |`,
146
+ `| ${headers.map(() => '---').join(' | ')} |`,
147
+ ...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
148
+ ].join('\n');
149
+ }
150
+ function escapeCell(value) {
151
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
68
152
  }
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
+ declare const presentationSchema: z.ZodOptional<z.ZodEnum<["compact", "table", "json"]>>;
4
+ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
3
5
  export declare const getPostAnalyticsTool: {
4
6
  name: string;
5
7
  description: string;
@@ -9,18 +11,21 @@ export declare const getPostAnalyticsTool: {
9
11
  to: z.ZodOptional<z.ZodString>;
10
12
  limit: z.ZodOptional<z.ZodNumber>;
11
13
  offset: z.ZodOptional<z.ZodNumber>;
14
+ presentation: z.ZodOptional<z.ZodEnum<["compact", "table", "json"]>>;
12
15
  }, "strip", z.ZodTypeAny, {
13
16
  account_id: number;
14
17
  limit?: number | undefined;
15
18
  offset?: number | undefined;
16
19
  from?: string | undefined;
17
20
  to?: string | undefined;
21
+ presentation?: "compact" | "table" | "json" | undefined;
18
22
  }, {
19
23
  account_id: number;
20
24
  limit?: number | undefined;
21
25
  offset?: number | undefined;
22
26
  from?: string | undefined;
23
27
  to?: string | undefined;
28
+ presentation?: "compact" | "table" | "json" | undefined;
24
29
  }>;
25
30
  execute(client: PosterlyClient, input: {
26
31
  account_id: number;
@@ -28,5 +33,7 @@ export declare const getPostAnalyticsTool: {
28
33
  to?: string;
29
34
  limit?: number;
30
35
  offset?: number;
36
+ presentation?: AnalyticsPresentation;
31
37
  }): Promise<string>;
32
38
  };
39
+ export {};
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
+ const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
2
3
  export const getPostAnalyticsTool = {
3
4
  name: 'get_post_analytics',
4
- description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first.',
5
+ description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
5
6
  inputSchema: z.object({
6
7
  account_id: z
7
8
  .number()
@@ -21,61 +22,139 @@ export const getPostAnalyticsTool = {
21
22
  .optional()
22
23
  .describe('Number of posts to return (default 50, max 200)'),
23
24
  offset: z.number().min(0).optional().describe('Pagination offset'),
25
+ presentation: presentationSchema.describe('Output style: compact bullets, Markdown table, or raw JSON for client-side chart/card rendering.'),
24
26
  }),
25
27
  async execute(client, input) {
26
28
  const result = await client.getPostAnalytics(input);
27
- const { account, range, posts, total } = result;
28
- if (posts.length === 0) {
29
- return `No analytics found for @${account.username} (${account.platform}) between ${range.from} and ${range.to}.`;
30
- }
31
- const lines = [
32
- `Post analytics for @${account.username} (${account.platform})`,
33
- `Range: ${range.from} → ${range.to} • Showing ${posts.length} of ${total}`,
34
- '',
35
- ];
36
- for (const p of posts) {
37
- const postedAt = p.posted_at
38
- ? new Date(p.posted_at).toLocaleString()
39
- : 'unknown date';
40
- const caption = p.caption_snippet
41
- ? p.caption_snippet.length > 60
42
- ? `${p.caption_snippet.slice(0, 60)}…`
43
- : p.caption_snippet
44
- : '(no caption)';
45
- const metrics = [
46
- `${p.likes} likes`,
47
- `${p.comments} comments`,
48
- `${p.reach.toLocaleString()} reach`,
49
- ];
50
- if (p.impressions)
51
- metrics.push(`${p.impressions.toLocaleString()} impressions`);
52
- if (p.saved)
53
- metrics.push(`${p.saved} saved`);
54
- if (p.shares)
55
- metrics.push(`${p.shares} shares`);
56
- if (p.plays)
57
- metrics.push(`${p.plays.toLocaleString()} plays`);
58
- if (account.platform === 'youtube' && p.total_watch_time_ms) {
59
- metrics.push(`${Math.round(p.total_watch_time_ms / 60000).toLocaleString()} watch minutes`);
60
- }
61
- if (account.platform === 'youtube' && p.avg_watch_time_ms) {
62
- metrics.push(`${Math.round(p.avg_watch_time_ms / 1000)}s avg view`);
63
- }
64
- if (p.url_link_clicks) {
65
- metrics.push(account.platform === 'pinterest'
66
- ? `${p.url_link_clicks.toLocaleString()} outbound clicks`
67
- : `${p.url_link_clicks.toLocaleString()} link clicks`);
68
- }
69
- if (p.user_profile_clicks) {
70
- metrics.push(account.platform === 'pinterest'
71
- ? `${p.user_profile_clicks.toLocaleString()} pin clicks`
72
- : `${p.user_profile_clicks.toLocaleString()} profile clicks`);
73
- }
74
- lines.push(`• [${postedAt}] ${caption}`);
75
- lines.push(` ${metrics.join(' • ')}`);
76
- if (p.permalink)
77
- lines.push(` ${p.permalink}`);
78
- }
79
- return lines.join('\n');
29
+ return formatPostAnalytics(result, input.presentation || 'compact');
80
30
  },
81
31
  };
32
+ function formatPostAnalytics(result, presentation) {
33
+ const { account, range, posts, total } = result;
34
+ if (posts.length === 0) {
35
+ return `No analytics found for ${accountLabel(account.username)} (${account.platform}) between ${range.from} and ${range.to}.`;
36
+ }
37
+ const totals = posts.reduce((acc, post) => {
38
+ acc.likes += post.likes || 0;
39
+ acc.comments += post.comments || 0;
40
+ acc.reach += post.reach || 0;
41
+ acc.impressions += post.impressions || 0;
42
+ acc.shares += post.shares || 0;
43
+ acc.saves += post.saved || 0;
44
+ return acc;
45
+ }, { likes: 0, comments: 0, reach: 0, impressions: 0, shares: 0, saves: 0 });
46
+ if (presentation === 'json') {
47
+ return JSON.stringify({
48
+ type: 'post_analytics',
49
+ account,
50
+ range,
51
+ returned: {
52
+ count: posts.length,
53
+ total,
54
+ },
55
+ totals,
56
+ posts,
57
+ }, null, 2);
58
+ }
59
+ if (presentation === 'table') {
60
+ return [
61
+ `**📈 Post analytics for ${accountLabel(account.username)} (${account.id})**`,
62
+ `_${account.platform} · ${range.from} → ${range.to} · showing ${posts.length} of ${total}_`,
63
+ '',
64
+ '**Returned totals**',
65
+ markdownTable(['Likes', 'Comments', 'Reach', 'Impressions', 'Shares', 'Saves'], [[
66
+ formatNumber(totals.likes),
67
+ formatNumber(totals.comments),
68
+ formatNumber(totals.reach),
69
+ formatNumber(totals.impressions),
70
+ formatNumber(totals.shares),
71
+ formatNumber(totals.saves),
72
+ ]]),
73
+ '',
74
+ '**Posts**',
75
+ markdownTable(['Posted', 'Post', 'Caption', 'Reach', 'Likes', 'Comments', 'Extras'], posts.map((post) => [
76
+ dateTime(post.posted_at),
77
+ post.id ? String(post.id) : post.platform_media_id,
78
+ compact(post.caption_snippet, 70) || '(no caption)',
79
+ formatNumber(post.reach),
80
+ formatNumber(post.likes),
81
+ formatNumber(post.comments),
82
+ postExtras(account.platform, post),
83
+ ])),
84
+ ].join('\n');
85
+ }
86
+ const lines = [
87
+ `**📈 Post analytics for ${accountLabel(account.username)} (${account.id})**`,
88
+ `_${account.platform} · ${range.from} → ${range.to} · showing ${posts.length} of ${total}_`,
89
+ '',
90
+ '**Returned totals**',
91
+ `- **Likes:** ${formatNumber(totals.likes)}`,
92
+ `- **Comments:** ${formatNumber(totals.comments)}`,
93
+ `- **Reach:** ${formatNumber(totals.reach)}`,
94
+ `- **Impressions:** ${formatNumber(totals.impressions)}`,
95
+ `- **Shares:** ${formatNumber(totals.shares)}`,
96
+ `- **Saves:** ${formatNumber(totals.saves)}`,
97
+ '',
98
+ '**Posts**',
99
+ ];
100
+ posts.forEach((post, index) => {
101
+ lines.push(`${index + 1}. **${compact(post.caption_snippet, 70) || '(no caption)'}**`);
102
+ lines.push(` Posted: ${dateTime(post.posted_at)}`);
103
+ lines.push(` Post ID: ${post.id ? String(post.id) : post.platform_media_id}`);
104
+ lines.push(` Reach: ${formatNumber(post.reach)}`);
105
+ lines.push(` Likes: ${formatNumber(post.likes)}`);
106
+ lines.push(` Comments: ${formatNumber(post.comments)}`);
107
+ lines.push(` Extras: ${postExtras(account.platform, post)}`);
108
+ });
109
+ return lines.join('\n');
110
+ }
111
+ function postExtras(platform, post) {
112
+ const metrics = [];
113
+ if (post.impressions)
114
+ metrics.push(`${formatNumber(post.impressions)} impressions`);
115
+ if (post.saved)
116
+ metrics.push(`${formatNumber(post.saved)} saved`);
117
+ if (post.shares)
118
+ metrics.push(`${formatNumber(post.shares)} shares`);
119
+ if (post.plays)
120
+ metrics.push(`${formatNumber(post.plays)} plays`);
121
+ if (platform === 'youtube' && post.total_watch_time_ms)
122
+ metrics.push(`${formatNumber(Math.round(post.total_watch_time_ms / 60000))} watch minutes`);
123
+ if (platform === 'youtube' && post.avg_watch_time_ms)
124
+ metrics.push(`${formatNumber(Math.round(post.avg_watch_time_ms / 1000))}s avg view`);
125
+ if (post.url_link_clicks)
126
+ metrics.push(platform === 'pinterest' ? `${formatNumber(post.url_link_clicks)} outbound clicks` : `${formatNumber(post.url_link_clicks)} link clicks`);
127
+ if (post.user_profile_clicks)
128
+ metrics.push(platform === 'pinterest' ? `${formatNumber(post.user_profile_clicks)} pin clicks` : `${formatNumber(post.user_profile_clicks)} profile clicks`);
129
+ if (post.permalink)
130
+ metrics.push(post.permalink);
131
+ return metrics.join(' · ') || 'n/a';
132
+ }
133
+ function accountLabel(username) {
134
+ if (!username)
135
+ return 'account';
136
+ return username.startsWith('@') || /\s/.test(username) ? username : `@${username}`;
137
+ }
138
+ function dateTime(value) {
139
+ if (!value)
140
+ return 'n/a';
141
+ const date = new Date(value);
142
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
143
+ }
144
+ function compact(value, maxLength) {
145
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
146
+ return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text;
147
+ }
148
+ function formatNumber(value) {
149
+ return value == null ? 'n/a' : value.toLocaleString();
150
+ }
151
+ function markdownTable(headers, rows) {
152
+ return [
153
+ `| ${headers.map(escapeCell).join(' | ')} |`,
154
+ `| ${headers.map(() => '---').join(' | ')} |`,
155
+ ...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
156
+ ].join('\n');
157
+ }
158
+ function escapeCell(value) {
159
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.19.3",
3
+ "version": "0.19.5",
4
4
  "description": "MCP server for posterly — schedule social media posts from Claude Desktop",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.poster.ly/mcp",
@@ -152,6 +152,7 @@ export interface AccountAnalyticsSummary {
152
152
  total_watch_minutes?: number | null;
153
153
  total_likes?: number | null;
154
154
  platform_metrics?: Record<string, number | null | undefined>;
155
+ display_metrics?: Array<{ key?: string; label: string; value: number | null | undefined; unit?: string }>;
155
156
  }
156
157
 
157
158
  export interface AccountAnalyticsSnapshot {
@@ -1,10 +1,14 @@
1
1
  import { z } from 'zod';
2
- import type { AccountAnalyticsSummary, PosterlyClient } from '../lib/api-client.js';
2
+ import type { AccountAnalyticsResponse, AccountAnalyticsSummary, PosterlyClient } from '../lib/api-client.js';
3
+
4
+ const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
5
+ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
6
+ type MetricRow = [string, string];
3
7
 
4
8
  export const getAccountAnalyticsTool = {
5
9
  name: 'get_account_analytics',
6
10
  description:
7
- 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. For Google Business Profile, returns Profile Views, Search Views, Maps Views, Customer Actions, and Posts.',
11
+ 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Uses API-provided display_metrics for platform-native dashboard labels and supports presentation: compact, table, or json.',
8
12
  inputSchema: z.object({
9
13
  account_id: z
10
14
  .number()
@@ -17,69 +21,151 @@ export const getAccountAnalyticsTool = {
17
21
  .string()
18
22
  .optional()
19
23
  .describe('End date (ISO date). Defaults to today.'),
24
+ presentation: presentationSchema.describe('Output style: compact bullets, Markdown table, or raw JSON for client-side chart/card rendering.'),
20
25
  }),
21
26
 
22
27
  async execute(
23
28
  client: PosterlyClient,
24
- input: { account_id: number; from?: string; to?: string }
29
+ input: { account_id: number; from?: string; to?: string; presentation?: AnalyticsPresentation }
25
30
  ) {
26
31
  const result = await client.getAccountAnalytics(input);
27
- const { account, range, summary, snapshots } = result;
32
+ return formatAccountAnalytics(result, input.presentation || 'compact');
33
+ },
34
+ };
35
+
36
+ function formatAccountAnalytics(result: AccountAnalyticsResponse, presentation: Exclude<AnalyticsPresentation, undefined>): string {
37
+ const { account, range, summary, snapshots } = result;
38
+ const rows = accountMetricRows(summary, account.platform);
39
+ const insights = analyticsInsights(summary, account.platform);
28
40
 
29
- const accountLabel = account.platform === 'google_business'
30
- ? `${account.username} (Google Business, id ${account.id})`
31
- : `@${account.username} (${account.platform}, id ${account.id})`;
41
+ if (presentation === 'json') {
42
+ return JSON.stringify({
43
+ type: 'account_analytics',
44
+ account,
45
+ range,
46
+ snapshot_count: snapshots.length,
47
+ metrics: rows.map(([label, value]) => ({ label, value })),
48
+ insights,
49
+ summary,
50
+ snapshots,
51
+ }, null, 2);
52
+ }
32
53
 
33
- const lines: string[] = [
34
- `Analytics for ${accountLabel}`,
35
- `Range: ${range.from} ${range.to} (${snapshots.length} daily snapshots)`,
54
+ if (presentation === 'table') {
55
+ return [
56
+ `**📊 Analytics for ${accountLabel(account.username)} (${account.id})**`,
57
+ `_${account.platform} · ${range.from} → ${range.to}_`,
58
+ '',
59
+ '**Account**',
60
+ markdownTable(['Field', 'Value'], [
61
+ ['Account ID', String(account.id)],
62
+ ['Daily snapshots', formatNumber(snapshots.length)],
63
+ ]),
36
64
  '',
37
- 'Summary:',
65
+ '**Metrics**',
66
+ markdownTable(['Metric', 'Value'], rows),
67
+ '',
68
+ '**Quick read**',
69
+ insights.map((insight) => `- ${insight}`).join('\n'),
70
+ ].join('\n');
71
+ }
72
+
73
+ return [
74
+ `**📊 Analytics for ${accountLabel(account.username)} (${account.id})**`,
75
+ `_${account.platform} · ${range.from} → ${range.to}_`,
76
+ '',
77
+ '**Account**',
78
+ `- **Account ID:** ${account.id}`,
79
+ `- **Daily snapshots:** ${formatNumber(snapshots.length)}`,
80
+ '',
81
+ '**Key metrics**',
82
+ rows.map(([label, value]) => `- **${label}:** ${value}`).join('\n'),
83
+ '',
84
+ '**Quick read**',
85
+ insights.map((insight) => `- ${insight}`).join('\n'),
86
+ ].join('\n');
87
+ }
88
+
89
+ function accountMetricRows(summary: AccountAnalyticsSummary, platform: string): MetricRow[] {
90
+ const nativeRows = nativeMetricRows(summary);
91
+ if (nativeRows.length > 0) return nativeRows;
92
+
93
+ if (platform === 'google_business') {
94
+ const metrics = summary.platform_metrics || {};
95
+ return [
96
+ ['Profile Views', formatNumber(metrics.profile_views ?? summary.total_views)],
97
+ ['Search Views', formatNumber(metrics.search_views ?? summary.total_reach)],
98
+ ['Maps Views', formatNumber(metrics.maps_views ?? summary.total_profile_views)],
99
+ ['Customer Actions', formatNumber(metrics.customer_actions ?? summary.total_accounts_engaged)],
100
+ ['Posts', formatNumber(metrics.posts ?? summary.current_media_count)],
38
101
  ];
102
+ }
39
103
 
40
- if (account.platform === 'google_business') {
41
- const metrics = summary.platform_metrics || {};
42
- pushMetric(lines, 'Profile Views', metrics.profile_views ?? summary.total_views);
43
- pushMetric(lines, 'Search Views', metrics.search_views ?? summary.total_reach);
44
- pushMetric(lines, 'Maps Views', metrics.maps_views ?? summary.total_profile_views);
45
- pushMetric(lines, 'Customer Actions', metrics.customer_actions ?? summary.total_accounts_engaged);
46
- pushMetric(lines, 'Posts', metrics.posts ?? summary.current_media_count);
47
- } else if (account.platform === 'pinterest') {
48
- pushGenericSummary(lines, summary);
49
- pushMetric(lines, 'Outbound clicks', summary.total_website_clicks);
50
- } else if (account.platform === 'youtube') {
51
- pushGenericSummary(lines, summary);
52
- pushMetric(lines, 'Watch minutes', summary.total_watch_minutes);
53
- pushMetric(lines, 'Likes', summary.total_likes);
54
- } else {
55
- pushGenericSummary(lines, summary);
56
- }
57
-
58
- return lines.join('\n');
59
- },
60
- };
104
+ const rows = genericMetricRows(summary);
105
+ if (platform === 'pinterest') rows.push(['Outbound clicks', formatNumber(summary.total_website_clicks)]);
106
+ if (platform === 'youtube') {
107
+ rows.push(['Watch minutes', formatNumber(summary.total_watch_minutes)]);
108
+ rows.push(['Likes', formatNumber(summary.total_likes)]);
109
+ }
110
+ return rows;
111
+ }
112
+
113
+ function nativeMetricRows(summary: AccountAnalyticsSummary): MetricRow[] {
114
+ return (summary.display_metrics || [])
115
+ .filter((metric) => metric?.label)
116
+ .map((metric) => [metric.label, formatNumber(metric.value)]);
117
+ }
118
+
119
+ function genericMetricRows(summary: AccountAnalyticsSummary): MetricRow[] {
120
+ return [
121
+ ['Followers', `${formatNumber(summary.current_followers)} (${formatDelta(summary.followers_change)} in range)`],
122
+ ['Follows gained / lost', `+${formatNumber(summary.total_follows_gained)} / -${formatNumber(summary.total_follows_lost)}`],
123
+ ['Total reach', formatNumber(summary.total_reach)],
124
+ ['Total views', formatNumber(summary.total_views)],
125
+ ['Profile views', formatNumber(summary.total_profile_views)],
126
+ ['Accounts engaged', formatNumber(summary.total_accounts_engaged)],
127
+ ['Engagement rate by reach', formatPercent(summary.engagement_rate)],
128
+ ['Engagement rate by followers', formatPercent(summary.engagement_rate_by_followers)],
129
+ ];
130
+ }
131
+
132
+ function analyticsInsights(summary: AccountAnalyticsSummary, platform: string): string[] {
133
+ const insights: string[] = [];
134
+ if (platform === 'google_business' && (summary.display_metrics?.length || 0) > 0) {
135
+ insights.push('Google Business metrics are shown with dashboard-native labels.');
136
+ }
137
+ if ((summary.followers_change || 0) > 0) insights.push(`Audience grew by ${formatDelta(summary.followers_change)} followers in this range.`);
138
+ if ((summary.followers_change || 0) < 0) insights.push(`Audience dipped by ${formatDelta(summary.followers_change)} followers in this range.`);
139
+ if (summary.engagement_rate != null && platform !== 'google_business') insights.push(`Engagement by reach is ${formatPercent(summary.engagement_rate)}.`);
140
+ if (platform === 'youtube' && summary.total_watch_minutes != null) insights.push(`${formatNumber(summary.total_watch_minutes)} total watch minutes were recorded.`);
141
+ return insights.length ? insights : ['No standout movement detected in the returned period.'];
142
+ }
61
143
 
62
144
  function formatDelta(n: number): string {
63
145
  return n >= 0 ? `+${n.toLocaleString()}` : n.toLocaleString();
64
146
  }
65
147
 
66
- function pushGenericSummary(lines: string[], summary: AccountAnalyticsSummary) {
67
- lines.push(
68
- `• Followers: ${summary.current_followers.toLocaleString()} (${formatDelta(summary.followers_change)} in range)`,
69
- `• Follows gained / lost: +${summary.total_follows_gained} / -${summary.total_follows_lost}`
70
- );
71
- pushMetric(lines, 'Total reach', summary.total_reach);
72
- pushMetric(lines, 'Total views', summary.total_views);
73
- pushMetric(lines, 'Profile views', summary.total_profile_views);
74
- pushMetric(lines, 'Total accounts engaged', summary.total_accounts_engaged);
75
- lines.push(
76
- `• Engagement rate (by reach): ${summary.engagement_rate}%`,
77
- `• Engagement rate (by followers): ${summary.engagement_rate_by_followers}%`
78
- );
148
+ function formatNumber(value: number | null | undefined): string {
149
+ return value == null ? 'n/a' : value.toLocaleString();
79
150
  }
80
151
 
81
- function pushMetric(lines: string[], label: string, value: number | null | undefined) {
82
- if (value != null) {
83
- lines.push(`• ${label}: ${value.toLocaleString()}`);
84
- }
152
+ function formatPercent(value: number | null | undefined): string {
153
+ return value == null ? 'n/a' : `${formatNumber(value)}%`;
154
+ }
155
+
156
+ function accountLabel(username: string): string {
157
+ if (!username) return 'account';
158
+ return username.startsWith('@') || /\s/.test(username) ? username : `@${username}`;
159
+ }
160
+
161
+ function markdownTable(headers: string[], rows: MetricRow[]): string {
162
+ return [
163
+ `| ${headers.map(escapeCell).join(' | ')} |`,
164
+ `| ${headers.map(() => '---').join(' | ')} |`,
165
+ ...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
166
+ ].join('\n');
167
+ }
168
+
169
+ function escapeCell(value: string): string {
170
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
85
171
  }
@@ -1,10 +1,13 @@
1
1
  import { z } from 'zod';
2
- import type { PosterlyClient } from '../lib/api-client.js';
2
+ import type { PostAnalyticsResponse, PostAnalyticsRow, PosterlyClient } from '../lib/api-client.js';
3
+
4
+ const presentationSchema = z.enum(['compact', 'table', 'json']).optional();
5
+ type AnalyticsPresentation = z.infer<typeof presentationSchema>;
3
6
 
4
7
  export const getPostAnalyticsTool = {
5
8
  name: 'get_post_analytics',
6
9
  description:
7
- 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first.',
10
+ 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays, clicks, watch time) for a connected social account. Supports Instagram, Facebook, LinkedIn, Google Business Profile, Pinterest, and YouTube. Returns the most recent posts first. presentation controls output: compact for Telegram/mobile, table for Markdown clients, json for custom chart/card renderers.',
8
11
  inputSchema: z.object({
9
12
  account_id: z
10
13
  .number()
@@ -24,6 +27,7 @@ export const getPostAnalyticsTool = {
24
27
  .optional()
25
28
  .describe('Number of posts to return (default 50, max 200)'),
26
29
  offset: z.number().min(0).optional().describe('Pagination offset'),
30
+ presentation: presentationSchema.describe('Output style: compact bullets, Markdown table, or raw JSON for client-side chart/card rendering.'),
27
31
  }),
28
32
 
29
33
  async execute(
@@ -34,65 +38,143 @@ export const getPostAnalyticsTool = {
34
38
  to?: string;
35
39
  limit?: number;
36
40
  offset?: number;
41
+ presentation?: AnalyticsPresentation;
37
42
  }
38
43
  ) {
39
44
  const result = await client.getPostAnalytics(input);
40
- const { account, range, posts, total } = result;
45
+ return formatPostAnalytics(result, input.presentation || 'compact');
46
+ },
47
+ };
41
48
 
42
- if (posts.length === 0) {
43
- return `No analytics found for @${account.username} (${account.platform}) between ${range.from} and ${range.to}.`;
44
- }
49
+ function formatPostAnalytics(result: PostAnalyticsResponse, presentation: Exclude<AnalyticsPresentation, undefined>): string {
50
+ const { account, range, posts, total } = result;
51
+
52
+ if (posts.length === 0) {
53
+ return `No analytics found for ${accountLabel(account.username)} (${account.platform}) between ${range.from} and ${range.to}.`;
54
+ }
45
55
 
46
- const lines: string[] = [
47
- `Post analytics for @${account.username} (${account.platform})`,
48
- `Range: ${range.from} ${range.to} • Showing ${posts.length} of ${total}`,
56
+ const totals = posts.reduce((acc, post) => {
57
+ acc.likes += post.likes || 0;
58
+ acc.comments += post.comments || 0;
59
+ acc.reach += post.reach || 0;
60
+ acc.impressions += post.impressions || 0;
61
+ acc.shares += post.shares || 0;
62
+ acc.saves += post.saved || 0;
63
+ return acc;
64
+ }, { likes: 0, comments: 0, reach: 0, impressions: 0, shares: 0, saves: 0 });
65
+
66
+ if (presentation === 'json') {
67
+ return JSON.stringify({
68
+ type: 'post_analytics',
69
+ account,
70
+ range,
71
+ returned: {
72
+ count: posts.length,
73
+ total,
74
+ },
75
+ totals,
76
+ posts,
77
+ }, null, 2);
78
+ }
79
+
80
+ if (presentation === 'table') {
81
+ return [
82
+ `**📈 Post analytics for ${accountLabel(account.username)} (${account.id})**`,
83
+ `_${account.platform} · ${range.from} → ${range.to} · showing ${posts.length} of ${total}_`,
49
84
  '',
50
- ];
51
-
52
- for (const p of posts) {
53
- const postedAt = p.posted_at
54
- ? new Date(p.posted_at).toLocaleString()
55
- : 'unknown date';
56
- const caption = p.caption_snippet
57
- ? p.caption_snippet.length > 60
58
- ? `${p.caption_snippet.slice(0, 60)}…`
59
- : p.caption_snippet
60
- : '(no caption)';
61
- const metrics = [
62
- `${p.likes} likes`,
63
- `${p.comments} comments`,
64
- `${p.reach.toLocaleString()} reach`,
65
- ];
66
- if (p.impressions) metrics.push(`${p.impressions.toLocaleString()} impressions`);
67
- if (p.saved) metrics.push(`${p.saved} saved`);
68
- if (p.shares) metrics.push(`${p.shares} shares`);
69
- if (p.plays) metrics.push(`${p.plays.toLocaleString()} plays`);
70
- if (account.platform === 'youtube' && p.total_watch_time_ms) {
71
- metrics.push(`${Math.round(p.total_watch_time_ms / 60000).toLocaleString()} watch minutes`);
72
- }
73
- if (account.platform === 'youtube' && p.avg_watch_time_ms) {
74
- metrics.push(`${Math.round(p.avg_watch_time_ms / 1000)}s avg view`);
75
- }
76
- if (p.url_link_clicks) {
77
- metrics.push(
78
- account.platform === 'pinterest'
79
- ? `${p.url_link_clicks.toLocaleString()} outbound clicks`
80
- : `${p.url_link_clicks.toLocaleString()} link clicks`
81
- );
82
- }
83
- if (p.user_profile_clicks) {
84
- metrics.push(
85
- account.platform === 'pinterest'
86
- ? `${p.user_profile_clicks.toLocaleString()} pin clicks`
87
- : `${p.user_profile_clicks.toLocaleString()} profile clicks`
88
- );
89
- }
90
-
91
- lines.push(`• [${postedAt}] ${caption}`);
92
- lines.push(` ${metrics.join(' • ')}`);
93
- if (p.permalink) lines.push(` ${p.permalink}`);
94
- }
85
+ '**Returned totals**',
86
+ markdownTable(['Likes', 'Comments', 'Reach', 'Impressions', 'Shares', 'Saves'], [[
87
+ formatNumber(totals.likes),
88
+ formatNumber(totals.comments),
89
+ formatNumber(totals.reach),
90
+ formatNumber(totals.impressions),
91
+ formatNumber(totals.shares),
92
+ formatNumber(totals.saves),
93
+ ]]),
94
+ '',
95
+ '**Posts**',
96
+ markdownTable(['Posted', 'Post', 'Caption', 'Reach', 'Likes', 'Comments', 'Extras'], posts.map((post) => [
97
+ dateTime(post.posted_at),
98
+ post.id ? String(post.id) : post.platform_media_id,
99
+ compact(post.caption_snippet, 70) || '(no caption)',
100
+ formatNumber(post.reach),
101
+ formatNumber(post.likes),
102
+ formatNumber(post.comments),
103
+ postExtras(account.platform, post),
104
+ ])),
105
+ ].join('\n');
106
+ }
95
107
 
96
- return lines.join('\n');
97
- },
98
- };
108
+ const lines: string[] = [
109
+ `**📈 Post analytics for ${accountLabel(account.username)} (${account.id})**`,
110
+ `_${account.platform} · ${range.from} → ${range.to} · showing ${posts.length} of ${total}_`,
111
+ '',
112
+ '**Returned totals**',
113
+ `- **Likes:** ${formatNumber(totals.likes)}`,
114
+ `- **Comments:** ${formatNumber(totals.comments)}`,
115
+ `- **Reach:** ${formatNumber(totals.reach)}`,
116
+ `- **Impressions:** ${formatNumber(totals.impressions)}`,
117
+ `- **Shares:** ${formatNumber(totals.shares)}`,
118
+ `- **Saves:** ${formatNumber(totals.saves)}`,
119
+ '',
120
+ '**Posts**',
121
+ ];
122
+
123
+ posts.forEach((post, index) => {
124
+ lines.push(`${index + 1}. **${compact(post.caption_snippet, 70) || '(no caption)'}**`);
125
+ lines.push(` Posted: ${dateTime(post.posted_at)}`);
126
+ lines.push(` Post ID: ${post.id ? String(post.id) : post.platform_media_id}`);
127
+ lines.push(` Reach: ${formatNumber(post.reach)}`);
128
+ lines.push(` Likes: ${formatNumber(post.likes)}`);
129
+ lines.push(` Comments: ${formatNumber(post.comments)}`);
130
+ lines.push(` Extras: ${postExtras(account.platform, post)}`);
131
+ });
132
+
133
+ return lines.join('\n');
134
+ }
135
+
136
+ function postExtras(platform: string, post: PostAnalyticsRow): string {
137
+ const metrics: string[] = [];
138
+ if (post.impressions) metrics.push(`${formatNumber(post.impressions)} impressions`);
139
+ if (post.saved) metrics.push(`${formatNumber(post.saved)} saved`);
140
+ if (post.shares) metrics.push(`${formatNumber(post.shares)} shares`);
141
+ if (post.plays) metrics.push(`${formatNumber(post.plays)} plays`);
142
+ if (platform === 'youtube' && post.total_watch_time_ms) metrics.push(`${formatNumber(Math.round(post.total_watch_time_ms / 60000))} watch minutes`);
143
+ if (platform === 'youtube' && post.avg_watch_time_ms) metrics.push(`${formatNumber(Math.round(post.avg_watch_time_ms / 1000))}s avg view`);
144
+ if (post.url_link_clicks) metrics.push(platform === 'pinterest' ? `${formatNumber(post.url_link_clicks)} outbound clicks` : `${formatNumber(post.url_link_clicks)} link clicks`);
145
+ if (post.user_profile_clicks) metrics.push(platform === 'pinterest' ? `${formatNumber(post.user_profile_clicks)} pin clicks` : `${formatNumber(post.user_profile_clicks)} profile clicks`);
146
+ if (post.permalink) metrics.push(post.permalink);
147
+ return metrics.join(' · ') || 'n/a';
148
+ }
149
+
150
+ function accountLabel(username: string): string {
151
+ if (!username) return 'account';
152
+ return username.startsWith('@') || /\s/.test(username) ? username : `@${username}`;
153
+ }
154
+
155
+ function dateTime(value: string | null): string {
156
+ if (!value) return 'n/a';
157
+ const date = new Date(value);
158
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
159
+ }
160
+
161
+ function compact(value: string | null, maxLength: number): string {
162
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
163
+ return text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text;
164
+ }
165
+
166
+ function formatNumber(value: number | null | undefined): string {
167
+ return value == null ? 'n/a' : value.toLocaleString();
168
+ }
169
+
170
+ function markdownTable(headers: string[], rows: string[][]): string {
171
+ return [
172
+ `| ${headers.map(escapeCell).join(' | ')} |`,
173
+ `| ${headers.map(() => '---').join(' | ')} |`,
174
+ ...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
175
+ ].join('\n');
176
+ }
177
+
178
+ function escapeCell(value: string): string {
179
+ return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
180
+ }