posterly-mcp-server 0.3.1 → 0.5.0

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/dist/index.js CHANGED
@@ -11,9 +11,12 @@ import { getPostTool } from './tools/get-post.js';
11
11
  import { updatePostTool } from './tools/update-post.js';
12
12
  import { deletePostTool } from './tools/delete-post.js';
13
13
  import { whoamiTool } from './tools/whoami.js';
14
+ import { generateImageTool } from './tools/generate-image.js';
15
+ import { getAccountAnalyticsTool } from './tools/get-account-analytics.js';
16
+ import { getPostAnalyticsTool } from './tools/get-post-analytics.js';
14
17
  const server = new McpServer({
15
18
  name: 'posterly',
16
- version: '0.3.1',
19
+ version: '0.5.0',
17
20
  });
18
21
  let client;
19
22
  try {
@@ -78,6 +81,15 @@ server.tool(uploadMediaTool.name, uploadMediaTool.description, uploadMediaTool.i
78
81
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
79
82
  }
80
83
  });
84
+ server.tool(generateImageTool.name, generateImageTool.description, generateImageTool.inputSchema.shape, async (input) => {
85
+ try {
86
+ const text = await generateImageTool.execute(client, input);
87
+ return { content: [{ type: 'text', text }] };
88
+ }
89
+ catch (err) {
90
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
91
+ }
92
+ });
81
93
  server.tool(getPostTool.name, getPostTool.description, getPostTool.inputSchema.shape, async (input) => {
82
94
  try {
83
95
  const text = await getPostTool.execute(client, input);
@@ -105,6 +117,24 @@ server.tool(deletePostTool.name, deletePostTool.description, deletePostTool.inpu
105
117
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
106
118
  }
107
119
  });
120
+ server.tool(getAccountAnalyticsTool.name, getAccountAnalyticsTool.description, getAccountAnalyticsTool.inputSchema.shape, async (input) => {
121
+ try {
122
+ const text = await getAccountAnalyticsTool.execute(client, input);
123
+ return { content: [{ type: 'text', text }] };
124
+ }
125
+ catch (err) {
126
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
127
+ }
128
+ });
129
+ server.tool(getPostAnalyticsTool.name, getPostAnalyticsTool.description, getPostAnalyticsTool.inputSchema.shape, async (input) => {
130
+ try {
131
+ const text = await getPostAnalyticsTool.execute(client, input);
132
+ return { content: [{ type: 'text', text }] };
133
+ }
134
+ catch (err) {
135
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
136
+ }
137
+ });
108
138
  // Start the server
109
139
  async function main() {
110
140
  const transport = new StdioServerTransport();
@@ -43,6 +43,86 @@ export interface Whoami {
43
43
  default_workspace: Workspace;
44
44
  workspaces: Workspace[];
45
45
  }
46
+ export interface AccountAnalyticsSummary {
47
+ current_followers: number;
48
+ followers_change: number;
49
+ total_reach: number | null;
50
+ total_views: number | null;
51
+ total_accounts_engaged: number | null;
52
+ total_follows_gained: number;
53
+ total_follows_lost: number;
54
+ engagement_rate: number;
55
+ engagement_rate_by_followers: number;
56
+ total_website_clicks: number | null;
57
+ total_call_clicks: number | null;
58
+ total_direction_requests: number | null;
59
+ total_conversations: number | null;
60
+ total_bookings: number | null;
61
+ }
62
+ export interface AccountAnalyticsSnapshot {
63
+ date: string;
64
+ followers: number | null;
65
+ following: number | null;
66
+ media_count: number | null;
67
+ reach: number | null;
68
+ views: number | null;
69
+ profile_views: number | null;
70
+ accounts_engaged: number | null;
71
+ follows_gained: number | null;
72
+ follows_lost: number | null;
73
+ website_clicks: number | null;
74
+ call_clicks: number | null;
75
+ direction_requests: number | null;
76
+ conversations: number | null;
77
+ bookings: number | null;
78
+ }
79
+ export interface AccountAnalyticsResponse {
80
+ account: {
81
+ id: number;
82
+ platform: string;
83
+ username: string;
84
+ };
85
+ range: {
86
+ from: string;
87
+ to: string;
88
+ };
89
+ summary: AccountAnalyticsSummary;
90
+ snapshots: AccountAnalyticsSnapshot[];
91
+ }
92
+ export interface PostAnalyticsRow {
93
+ id: number | null;
94
+ platform_media_id: string;
95
+ platform: string;
96
+ posted_at: string | null;
97
+ likes: number;
98
+ comments: number;
99
+ impressions: number;
100
+ reach: number;
101
+ saved: number;
102
+ shares: number;
103
+ plays: number;
104
+ total_interactions: number;
105
+ media_type: string | null;
106
+ media_url: string | null;
107
+ permalink: string | null;
108
+ caption_snippet: string | null;
109
+ synced_at: string;
110
+ }
111
+ export interface PostAnalyticsResponse {
112
+ account: {
113
+ id: number;
114
+ platform: string;
115
+ username: string;
116
+ };
117
+ range: {
118
+ from: string;
119
+ to: string;
120
+ };
121
+ posts: PostAnalyticsRow[];
122
+ total: number;
123
+ limit: number;
124
+ offset: number;
125
+ }
46
126
  export declare class PosterlyClient {
47
127
  private baseUrl;
48
128
  private apiKey;
@@ -99,12 +179,49 @@ export declare class PosterlyClient {
99
179
  deleted: boolean;
100
180
  id: number;
101
181
  }>;
182
+ generateImage(data: {
183
+ prompt: string;
184
+ aspect_ratio?: string;
185
+ style?: string;
186
+ variations?: number;
187
+ model?: 'flash' | 'pro';
188
+ resolution?: '512' | '1K' | '2K' | '4K';
189
+ }): Promise<{
190
+ urls: string[];
191
+ images: Array<{
192
+ url: string;
193
+ filename: string;
194
+ path: string;
195
+ }>;
196
+ model: string;
197
+ credits_used: number;
198
+ warnings?: string[];
199
+ usage: {
200
+ used: number | null;
201
+ limit: number | null;
202
+ period: string | null;
203
+ tier: string | null;
204
+ billed_from: 'plan_quota' | 'credits';
205
+ };
206
+ }>;
102
207
  findAvailableSlots(params?: {
103
208
  account_ids?: string[];
104
209
  timezone?: string;
105
210
  count?: number;
106
211
  workspace_id?: string;
107
212
  }): Promise<Slot[]>;
213
+ getAccountAnalytics(params: {
214
+ account_id: number;
215
+ from?: string;
216
+ to?: string;
217
+ }): Promise<AccountAnalyticsResponse>;
218
+ getPostAnalytics(params: {
219
+ account_id: number;
220
+ from?: string;
221
+ to?: string;
222
+ limit?: number;
223
+ offset?: number;
224
+ }): Promise<PostAnalyticsResponse>;
108
225
  getSignedUploadUrl(filename: string, contentType: string, size: number): Promise<{
109
226
  upload_url: string;
110
227
  token: string;
@@ -68,6 +68,9 @@ export class PosterlyClient {
68
68
  async deletePost(id) {
69
69
  return this.request('DELETE', `/posts/${id}`);
70
70
  }
71
+ async generateImage(data) {
72
+ return this.request('POST', '/ai/generate-image', data);
73
+ }
71
74
  async findAvailableSlots(params) {
72
75
  const searchParams = new URLSearchParams();
73
76
  if (params?.account_ids?.length)
@@ -82,6 +85,28 @@ export class PosterlyClient {
82
85
  const data = await this.request('GET', `/slots/next${qs ? `?${qs}` : ''}`);
83
86
  return data.slots;
84
87
  }
88
+ async getAccountAnalytics(params) {
89
+ const searchParams = new URLSearchParams();
90
+ searchParams.set('account_id', String(params.account_id));
91
+ if (params.from)
92
+ searchParams.set('from', params.from);
93
+ if (params.to)
94
+ searchParams.set('to', params.to);
95
+ return this.request('GET', `/analytics/accounts?${searchParams.toString()}`);
96
+ }
97
+ async getPostAnalytics(params) {
98
+ const searchParams = new URLSearchParams();
99
+ searchParams.set('account_id', String(params.account_id));
100
+ if (params.from)
101
+ searchParams.set('from', params.from);
102
+ if (params.to)
103
+ searchParams.set('to', params.to);
104
+ if (params.limit)
105
+ searchParams.set('limit', String(params.limit));
106
+ if (params.offset)
107
+ searchParams.set('offset', String(params.offset));
108
+ return this.request('GET', `/analytics/posts?${searchParams.toString()}`);
109
+ }
85
110
  async getSignedUploadUrl(filename, contentType, size) {
86
111
  return this.request('POST', '/media/signed-upload', {
87
112
  filename,
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const generateImageTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ prompt: z.ZodString;
8
+ aspect_ratio: z.ZodOptional<z.ZodEnum<["1:1", "9:16", "4:5", "3:4", "2:3", "1:4", "1:8", "21:9", "16:9", "4:3", "3:2", "4:1", "8:1", "5:4"]>>;
9
+ style: z.ZodOptional<z.ZodEnum<["photographic", "illustration", "minimal", "vibrant", "professional", "youtube_thumbnail", "reel_cover", "review_background"]>>;
10
+ variations: z.ZodOptional<z.ZodNumber>;
11
+ model: z.ZodOptional<z.ZodEnum<["flash", "pro"]>>;
12
+ resolution: z.ZodOptional<z.ZodEnum<["512", "1K", "2K", "4K"]>>;
13
+ }, "strip", z.ZodTypeAny, {
14
+ prompt: string;
15
+ aspect_ratio?: "1:1" | "9:16" | "4:5" | "3:4" | "2:3" | "1:4" | "1:8" | "21:9" | "16:9" | "4:3" | "3:2" | "4:1" | "8:1" | "5:4" | undefined;
16
+ style?: "photographic" | "illustration" | "minimal" | "vibrant" | "professional" | "youtube_thumbnail" | "reel_cover" | "review_background" | undefined;
17
+ variations?: number | undefined;
18
+ model?: "flash" | "pro" | undefined;
19
+ resolution?: "512" | "1K" | "2K" | "4K" | undefined;
20
+ }, {
21
+ prompt: string;
22
+ aspect_ratio?: "1:1" | "9:16" | "4:5" | "3:4" | "2:3" | "1:4" | "1:8" | "21:9" | "16:9" | "4:3" | "3:2" | "4:1" | "8:1" | "5:4" | undefined;
23
+ style?: "photographic" | "illustration" | "minimal" | "vibrant" | "professional" | "youtube_thumbnail" | "reel_cover" | "review_background" | undefined;
24
+ variations?: number | undefined;
25
+ model?: "flash" | "pro" | undefined;
26
+ resolution?: "512" | "1K" | "2K" | "4K" | undefined;
27
+ }>;
28
+ execute(client: PosterlyClient, input: {
29
+ prompt: string;
30
+ aspect_ratio?: string;
31
+ style?: string;
32
+ variations?: number;
33
+ model?: "flash" | "pro";
34
+ resolution?: "512" | "1K" | "2K" | "4K";
35
+ }): Promise<string>;
36
+ };
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ export const generateImageTool = {
3
+ name: 'generate_image',
4
+ description: 'Generate an AI image via Posterly\'s Nano Banana (Gemini) integration. The image is saved to the user\'s media storage and the returned URL can be passed to `create_post` as `media_url`.\n\n' +
5
+ 'COSTS CREDITS. Every call consumes part of the user\'s monthly AI image quota (or purchased credits once the quota is exhausted). Do NOT call speculatively — always describe the image you\'re about to generate (subject, style, aspect ratio) to the user and get confirmation before calling.\n\n' +
6
+ 'If the user is over their plan limit and has no credits, this tool returns a 402 with upgrade info. Surface that message verbatim — do not retry.\n\n' +
7
+ 'Common aspect ratios by platform:\n' +
8
+ ' • Instagram feed / LinkedIn: 1:1 (square) or 4:5 (portrait)\n' +
9
+ ' • Instagram Story/Reel, TikTok, YouTube Shorts: 9:16\n' +
10
+ ' • YouTube thumbnail, Twitter cards, landscape feed: 16:9\n' +
11
+ ' • Pinterest: 2:3',
12
+ inputSchema: z.object({
13
+ prompt: z
14
+ .string()
15
+ .min(5)
16
+ .max(4000)
17
+ .describe('Detailed image description. 5–4000 characters. Describe subject, scene, lighting, mood, and style.'),
18
+ aspect_ratio: z
19
+ .enum([
20
+ '1:1', '9:16', '4:5', '3:4', '2:3', '1:4', '1:8',
21
+ '21:9', '16:9', '4:3', '3:2', '4:1', '8:1', '5:4',
22
+ ])
23
+ .optional()
24
+ .describe('Aspect ratio. Default 1:1. Pick based on target platform (see tool description).'),
25
+ style: z
26
+ .enum([
27
+ 'photographic', 'illustration', 'minimal', 'vibrant', 'professional',
28
+ 'youtube_thumbnail', 'reel_cover', 'review_background',
29
+ ])
30
+ .optional()
31
+ .describe('Preset style. Default photographic. Use youtube_thumbnail / reel_cover for platform-optimized covers; review_background for testimonial backgrounds.'),
32
+ variations: z
33
+ .number()
34
+ .int()
35
+ .min(1)
36
+ .max(4)
37
+ .optional()
38
+ .describe('How many variations to generate. Default 1. Each variation costs credits separately — prefer 1 unless the user explicitly wants options.'),
39
+ model: z
40
+ .enum(['flash', 'pro'])
41
+ .optional()
42
+ .describe('flash (default, 1 credit per 1K image) is fast and cost-effective. pro (2 credits per 1K) is higher quality for hero imagery. Start with flash unless quality is critical.'),
43
+ resolution: z
44
+ .enum(['512', '1K', '2K', '4K'])
45
+ .optional()
46
+ .describe('Output resolution. Default 1K. Higher resolutions cost more credits. 512 is flash-only.'),
47
+ }),
48
+ async execute(client, input) {
49
+ const result = await client.generateImage(input);
50
+ const lines = [];
51
+ lines.push(`Generated ${result.images.length} image${result.images.length === 1 ? '' : 's'} via ${result.model}.`);
52
+ lines.push('');
53
+ result.images.forEach((img, i) => {
54
+ lines.push(`${i + 1}. ${img.url}`);
55
+ });
56
+ lines.push('');
57
+ lines.push(`Billed from: ${result.usage.billed_from}${result.credits_used > 0 ? ` (${result.credits_used} credits)` : ''}`);
58
+ if (result.usage.limit != null) {
59
+ lines.push(`Plan usage: ${result.usage.used ?? 0}/${result.usage.limit} this ${result.usage.period || 'period'}${result.usage.tier ? ` (${result.usage.tier})` : ''}`);
60
+ }
61
+ if (result.warnings?.length) {
62
+ lines.push('');
63
+ lines.push('Warnings:');
64
+ result.warnings.forEach((w) => lines.push(`• ${w}`));
65
+ }
66
+ lines.push('');
67
+ lines.push('Pass any of these URLs to create_post as media_url (or media_urls for a carousel).');
68
+ return lines.join('\n');
69
+ },
70
+ };
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const getAccountAnalyticsTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ account_id: z.ZodNumber;
8
+ from: z.ZodOptional<z.ZodString>;
9
+ to: z.ZodOptional<z.ZodString>;
10
+ }, "strip", z.ZodTypeAny, {
11
+ account_id: number;
12
+ from?: string | undefined;
13
+ to?: string | undefined;
14
+ }, {
15
+ account_id: number;
16
+ from?: string | undefined;
17
+ to?: string | undefined;
18
+ }>;
19
+ execute(client: PosterlyClient, input: {
20
+ account_id: number;
21
+ from?: string;
22
+ to?: string;
23
+ }): Promise<string>;
24
+ };
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ export const getAccountAnalyticsTool = {
3
+ name: 'get_account_analytics',
4
+ description: 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, LinkedIn, and Google Business Profile. Returns follower growth, reach, views, engagement rate, and platform-specific metrics (e.g. website clicks, direction requests for GBP).',
5
+ inputSchema: z.object({
6
+ account_id: z
7
+ .number()
8
+ .describe('The social account ID (from list_accounts)'),
9
+ from: z
10
+ .string()
11
+ .optional()
12
+ .describe('Start date (ISO date, e.g. 2026-03-19). Defaults to 30 days ago.'),
13
+ to: z
14
+ .string()
15
+ .optional()
16
+ .describe('End date (ISO date). Defaults to today.'),
17
+ }),
18
+ async execute(client, input) {
19
+ const result = await client.getAccountAnalytics(input);
20
+ const { account, range, summary, snapshots } = result;
21
+ const lines = [
22
+ `Analytics for @${account.username} (${account.platform}, id ${account.id})`,
23
+ `Range: ${range.from} → ${range.to} (${snapshots.length} daily snapshots)`,
24
+ '',
25
+ 'Summary:',
26
+ `• Followers: ${summary.current_followers.toLocaleString()} (${formatDelta(summary.followers_change)} in range)`,
27
+ `• Follows gained / lost: +${summary.total_follows_gained} / -${summary.total_follows_lost}`,
28
+ ];
29
+ if (summary.total_reach !== null)
30
+ lines.push(`• Total reach: ${summary.total_reach.toLocaleString()}`);
31
+ if (summary.total_views !== null)
32
+ lines.push(`• Total views: ${summary.total_views.toLocaleString()}`);
33
+ if (summary.total_accounts_engaged !== null) {
34
+ lines.push(`• Total accounts engaged: ${summary.total_accounts_engaged.toLocaleString()}`);
35
+ }
36
+ lines.push(`• Engagement rate (by reach): ${summary.engagement_rate}%`, `• Engagement rate (by followers): ${summary.engagement_rate_by_followers}%`);
37
+ if (account.platform === 'google_business') {
38
+ if (summary.total_website_clicks !== null) {
39
+ lines.push(`• Website clicks: ${summary.total_website_clicks.toLocaleString()}`);
40
+ }
41
+ if (summary.total_call_clicks !== null) {
42
+ lines.push(`• Call clicks: ${summary.total_call_clicks.toLocaleString()}`);
43
+ }
44
+ if (summary.total_direction_requests !== null) {
45
+ lines.push(`• Direction requests: ${summary.total_direction_requests.toLocaleString()}`);
46
+ }
47
+ if (summary.total_conversations !== null) {
48
+ lines.push(`• Conversations: ${summary.total_conversations.toLocaleString()}`);
49
+ }
50
+ if (summary.total_bookings !== null) {
51
+ lines.push(`• Bookings: ${summary.total_bookings.toLocaleString()}`);
52
+ }
53
+ }
54
+ return lines.join('\n');
55
+ },
56
+ };
57
+ function formatDelta(n) {
58
+ return n >= 0 ? `+${n.toLocaleString()}` : n.toLocaleString();
59
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const getPostAnalyticsTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{
7
+ account_id: z.ZodNumber;
8
+ from: z.ZodOptional<z.ZodString>;
9
+ to: z.ZodOptional<z.ZodString>;
10
+ limit: z.ZodOptional<z.ZodNumber>;
11
+ offset: z.ZodOptional<z.ZodNumber>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ account_id: number;
14
+ limit?: number | undefined;
15
+ offset?: number | undefined;
16
+ from?: string | undefined;
17
+ to?: string | undefined;
18
+ }, {
19
+ account_id: number;
20
+ limit?: number | undefined;
21
+ offset?: number | undefined;
22
+ from?: string | undefined;
23
+ to?: string | undefined;
24
+ }>;
25
+ execute(client: PosterlyClient, input: {
26
+ account_id: number;
27
+ from?: string;
28
+ to?: string;
29
+ limit?: number;
30
+ offset?: number;
31
+ }): Promise<string>;
32
+ };
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod';
2
+ export const getPostAnalyticsTool = {
3
+ name: 'get_post_analytics',
4
+ description: 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays) for a connected social account. Supports Instagram, LinkedIn, and Google Business Profile. Returns the most recent posts first.',
5
+ inputSchema: z.object({
6
+ account_id: z
7
+ .number()
8
+ .describe('The social account ID (from list_accounts)'),
9
+ from: z
10
+ .string()
11
+ .optional()
12
+ .describe('Start date (ISO date, e.g. 2026-03-19). Defaults to 30 days ago.'),
13
+ to: z
14
+ .string()
15
+ .optional()
16
+ .describe('End date (ISO date). Defaults to today.'),
17
+ limit: z
18
+ .number()
19
+ .min(1)
20
+ .max(200)
21
+ .optional()
22
+ .describe('Number of posts to return (default 50, max 200)'),
23
+ offset: z.number().min(0).optional().describe('Pagination offset'),
24
+ }),
25
+ async execute(client, input) {
26
+ 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
+ lines.push(`• [${postedAt}] ${caption}`);
59
+ lines.push(` ${metrics.join(' • ')}`);
60
+ if (p.permalink)
61
+ lines.push(` ${p.permalink}`);
62
+ }
63
+ return lines.join('\n');
64
+ },
65
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for posterly — schedule social media posts from Claude Desktop",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -12,10 +12,13 @@ import { getPostTool } from './tools/get-post.js';
12
12
  import { updatePostTool } from './tools/update-post.js';
13
13
  import { deletePostTool } from './tools/delete-post.js';
14
14
  import { whoamiTool } from './tools/whoami.js';
15
+ import { generateImageTool } from './tools/generate-image.js';
16
+ import { getAccountAnalyticsTool } from './tools/get-account-analytics.js';
17
+ import { getPostAnalyticsTool } from './tools/get-post-analytics.js';
15
18
 
16
19
  const server = new McpServer({
17
20
  name: 'posterly',
18
- version: '0.3.1',
21
+ version: '0.5.0',
19
22
  });
20
23
 
21
24
  let client: PosterlyClient;
@@ -112,6 +115,20 @@ server.tool(
112
115
  }
113
116
  );
114
117
 
118
+ server.tool(
119
+ generateImageTool.name,
120
+ generateImageTool.description,
121
+ generateImageTool.inputSchema.shape,
122
+ async (input) => {
123
+ try {
124
+ const text = await generateImageTool.execute(client, input as any);
125
+ return { content: [{ type: 'text' as const, text }] };
126
+ } catch (err: any) {
127
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
128
+ }
129
+ }
130
+ );
131
+
115
132
  server.tool(
116
133
  getPostTool.name,
117
134
  getPostTool.description,
@@ -154,6 +171,34 @@ server.tool(
154
171
  }
155
172
  );
156
173
 
174
+ server.tool(
175
+ getAccountAnalyticsTool.name,
176
+ getAccountAnalyticsTool.description,
177
+ getAccountAnalyticsTool.inputSchema.shape,
178
+ async (input) => {
179
+ try {
180
+ const text = await getAccountAnalyticsTool.execute(client, input as any);
181
+ return { content: [{ type: 'text' as const, text }] };
182
+ } catch (err: any) {
183
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
184
+ }
185
+ }
186
+ );
187
+
188
+ server.tool(
189
+ getPostAnalyticsTool.name,
190
+ getPostAnalyticsTool.description,
191
+ getPostAnalyticsTool.inputSchema.shape,
192
+ async (input) => {
193
+ try {
194
+ const text = await getPostAnalyticsTool.execute(client, input as any);
195
+ return { content: [{ type: 'text' as const, text }] };
196
+ } catch (err: any) {
197
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
198
+ }
199
+ }
200
+ );
201
+
157
202
  // Start the server
158
203
  async function main() {
159
204
  const transport = new StdioServerTransport();
@@ -45,6 +45,77 @@ export interface Whoami {
45
45
  workspaces: Workspace[];
46
46
  }
47
47
 
48
+ export interface AccountAnalyticsSummary {
49
+ current_followers: number;
50
+ followers_change: number;
51
+ total_reach: number | null;
52
+ total_views: number | null;
53
+ total_accounts_engaged: number | null;
54
+ total_follows_gained: number;
55
+ total_follows_lost: number;
56
+ engagement_rate: number;
57
+ engagement_rate_by_followers: number;
58
+ total_website_clicks: number | null;
59
+ total_call_clicks: number | null;
60
+ total_direction_requests: number | null;
61
+ total_conversations: number | null;
62
+ total_bookings: number | null;
63
+ }
64
+
65
+ export interface AccountAnalyticsSnapshot {
66
+ date: string;
67
+ followers: number | null;
68
+ following: number | null;
69
+ media_count: number | null;
70
+ reach: number | null;
71
+ views: number | null;
72
+ profile_views: number | null;
73
+ accounts_engaged: number | null;
74
+ follows_gained: number | null;
75
+ follows_lost: number | null;
76
+ website_clicks: number | null;
77
+ call_clicks: number | null;
78
+ direction_requests: number | null;
79
+ conversations: number | null;
80
+ bookings: number | null;
81
+ }
82
+
83
+ export interface AccountAnalyticsResponse {
84
+ account: { id: number; platform: string; username: string };
85
+ range: { from: string; to: string };
86
+ summary: AccountAnalyticsSummary;
87
+ snapshots: AccountAnalyticsSnapshot[];
88
+ }
89
+
90
+ export interface PostAnalyticsRow {
91
+ id: number | null;
92
+ platform_media_id: string;
93
+ platform: string;
94
+ posted_at: string | null;
95
+ likes: number;
96
+ comments: number;
97
+ impressions: number;
98
+ reach: number;
99
+ saved: number;
100
+ shares: number;
101
+ plays: number;
102
+ total_interactions: number;
103
+ media_type: string | null;
104
+ media_url: string | null;
105
+ permalink: string | null;
106
+ caption_snippet: string | null;
107
+ synced_at: string;
108
+ }
109
+
110
+ export interface PostAnalyticsResponse {
111
+ account: { id: number; platform: string; username: string };
112
+ range: { from: string; to: string };
113
+ posts: PostAnalyticsRow[];
114
+ total: number;
115
+ limit: number;
116
+ offset: number;
117
+ }
118
+
48
119
  export class PosterlyClient {
49
120
  private baseUrl: string;
50
121
  private apiKey: string;
@@ -157,6 +228,30 @@ export class PosterlyClient {
157
228
  return this.request('DELETE', `/posts/${id}`);
158
229
  }
159
230
 
231
+ async generateImage(data: {
232
+ prompt: string;
233
+ aspect_ratio?: string;
234
+ style?: string;
235
+ variations?: number;
236
+ model?: 'flash' | 'pro';
237
+ resolution?: '512' | '1K' | '2K' | '4K';
238
+ }): Promise<{
239
+ urls: string[];
240
+ images: Array<{ url: string; filename: string; path: string }>;
241
+ model: string;
242
+ credits_used: number;
243
+ warnings?: string[];
244
+ usage: {
245
+ used: number | null;
246
+ limit: number | null;
247
+ period: string | null;
248
+ tier: string | null;
249
+ billed_from: 'plan_quota' | 'credits';
250
+ };
251
+ }> {
252
+ return this.request('POST', '/ai/generate-image', data);
253
+ }
254
+
160
255
  async findAvailableSlots(params?: {
161
256
  account_ids?: string[];
162
257
  timezone?: string;
@@ -174,6 +269,34 @@ export class PosterlyClient {
174
269
  return data.slots;
175
270
  }
176
271
 
272
+ async getAccountAnalytics(params: {
273
+ account_id: number;
274
+ from?: string;
275
+ to?: string;
276
+ }): Promise<AccountAnalyticsResponse> {
277
+ const searchParams = new URLSearchParams();
278
+ searchParams.set('account_id', String(params.account_id));
279
+ if (params.from) searchParams.set('from', params.from);
280
+ if (params.to) searchParams.set('to', params.to);
281
+ return this.request('GET', `/analytics/accounts?${searchParams.toString()}`);
282
+ }
283
+
284
+ async getPostAnalytics(params: {
285
+ account_id: number;
286
+ from?: string;
287
+ to?: string;
288
+ limit?: number;
289
+ offset?: number;
290
+ }): Promise<PostAnalyticsResponse> {
291
+ const searchParams = new URLSearchParams();
292
+ searchParams.set('account_id', String(params.account_id));
293
+ if (params.from) searchParams.set('from', params.from);
294
+ if (params.to) searchParams.set('to', params.to);
295
+ if (params.limit) searchParams.set('limit', String(params.limit));
296
+ if (params.offset) searchParams.set('offset', String(params.offset));
297
+ return this.request('GET', `/analytics/posts?${searchParams.toString()}`);
298
+ }
299
+
177
300
  async getSignedUploadUrl(
178
301
  filename: string,
179
302
  contentType: string,
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const generateImageTool = {
5
+ name: 'generate_image',
6
+ description:
7
+ 'Generate an AI image via Posterly\'s Nano Banana (Gemini) integration. The image is saved to the user\'s media storage and the returned URL can be passed to `create_post` as `media_url`.\n\n' +
8
+ 'COSTS CREDITS. Every call consumes part of the user\'s monthly AI image quota (or purchased credits once the quota is exhausted). Do NOT call speculatively — always describe the image you\'re about to generate (subject, style, aspect ratio) to the user and get confirmation before calling.\n\n' +
9
+ 'If the user is over their plan limit and has no credits, this tool returns a 402 with upgrade info. Surface that message verbatim — do not retry.\n\n' +
10
+ 'Common aspect ratios by platform:\n' +
11
+ ' • Instagram feed / LinkedIn: 1:1 (square) or 4:5 (portrait)\n' +
12
+ ' • Instagram Story/Reel, TikTok, YouTube Shorts: 9:16\n' +
13
+ ' • YouTube thumbnail, Twitter cards, landscape feed: 16:9\n' +
14
+ ' • Pinterest: 2:3',
15
+ inputSchema: z.object({
16
+ prompt: z
17
+ .string()
18
+ .min(5)
19
+ .max(4000)
20
+ .describe('Detailed image description. 5–4000 characters. Describe subject, scene, lighting, mood, and style.'),
21
+ aspect_ratio: z
22
+ .enum([
23
+ '1:1', '9:16', '4:5', '3:4', '2:3', '1:4', '1:8',
24
+ '21:9', '16:9', '4:3', '3:2', '4:1', '8:1', '5:4',
25
+ ])
26
+ .optional()
27
+ .describe('Aspect ratio. Default 1:1. Pick based on target platform (see tool description).'),
28
+ style: z
29
+ .enum([
30
+ 'photographic', 'illustration', 'minimal', 'vibrant', 'professional',
31
+ 'youtube_thumbnail', 'reel_cover', 'review_background',
32
+ ])
33
+ .optional()
34
+ .describe('Preset style. Default photographic. Use youtube_thumbnail / reel_cover for platform-optimized covers; review_background for testimonial backgrounds.'),
35
+ variations: z
36
+ .number()
37
+ .int()
38
+ .min(1)
39
+ .max(4)
40
+ .optional()
41
+ .describe('How many variations to generate. Default 1. Each variation costs credits separately — prefer 1 unless the user explicitly wants options.'),
42
+ model: z
43
+ .enum(['flash', 'pro'])
44
+ .optional()
45
+ .describe('flash (default, 1 credit per 1K image) is fast and cost-effective. pro (2 credits per 1K) is higher quality for hero imagery. Start with flash unless quality is critical.'),
46
+ resolution: z
47
+ .enum(['512', '1K', '2K', '4K'])
48
+ .optional()
49
+ .describe('Output resolution. Default 1K. Higher resolutions cost more credits. 512 is flash-only.'),
50
+ }),
51
+
52
+ async execute(
53
+ client: PosterlyClient,
54
+ input: {
55
+ prompt: string;
56
+ aspect_ratio?: string;
57
+ style?: string;
58
+ variations?: number;
59
+ model?: 'flash' | 'pro';
60
+ resolution?: '512' | '1K' | '2K' | '4K';
61
+ }
62
+ ) {
63
+ const result = await client.generateImage(input);
64
+
65
+ const lines: string[] = [];
66
+ lines.push(`Generated ${result.images.length} image${result.images.length === 1 ? '' : 's'} via ${result.model}.`);
67
+ lines.push('');
68
+ result.images.forEach((img, i) => {
69
+ lines.push(`${i + 1}. ${img.url}`);
70
+ });
71
+ lines.push('');
72
+ lines.push(`Billed from: ${result.usage.billed_from}${result.credits_used > 0 ? ` (${result.credits_used} credits)` : ''}`);
73
+ if (result.usage.limit != null) {
74
+ lines.push(`Plan usage: ${result.usage.used ?? 0}/${result.usage.limit} this ${result.usage.period || 'period'}${result.usage.tier ? ` (${result.usage.tier})` : ''}`);
75
+ }
76
+ if (result.warnings?.length) {
77
+ lines.push('');
78
+ lines.push('Warnings:');
79
+ result.warnings.forEach((w) => lines.push(`• ${w}`));
80
+ }
81
+ lines.push('');
82
+ lines.push('Pass any of these URLs to create_post as media_url (or media_urls for a carousel).');
83
+
84
+ return lines.join('\n');
85
+ },
86
+ };
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const getAccountAnalyticsTool = {
5
+ name: 'get_account_analytics',
6
+ description:
7
+ 'Get daily analytics snapshots and a period summary for a connected social account. Supports Instagram, LinkedIn, and Google Business Profile. Returns follower growth, reach, views, engagement rate, and platform-specific metrics (e.g. website clicks, direction requests for GBP).',
8
+ inputSchema: z.object({
9
+ account_id: z
10
+ .number()
11
+ .describe('The social account ID (from list_accounts)'),
12
+ from: z
13
+ .string()
14
+ .optional()
15
+ .describe('Start date (ISO date, e.g. 2026-03-19). Defaults to 30 days ago.'),
16
+ to: z
17
+ .string()
18
+ .optional()
19
+ .describe('End date (ISO date). Defaults to today.'),
20
+ }),
21
+
22
+ async execute(
23
+ client: PosterlyClient,
24
+ input: { account_id: number; from?: string; to?: string }
25
+ ) {
26
+ const result = await client.getAccountAnalytics(input);
27
+ const { account, range, summary, snapshots } = result;
28
+
29
+ const lines: string[] = [
30
+ `Analytics for @${account.username} (${account.platform}, id ${account.id})`,
31
+ `Range: ${range.from} → ${range.to} (${snapshots.length} daily snapshots)`,
32
+ '',
33
+ 'Summary:',
34
+ `• Followers: ${summary.current_followers.toLocaleString()} (${formatDelta(summary.followers_change)} in range)`,
35
+ `• Follows gained / lost: +${summary.total_follows_gained} / -${summary.total_follows_lost}`,
36
+ ];
37
+
38
+ if (summary.total_reach !== null) lines.push(`• Total reach: ${summary.total_reach.toLocaleString()}`);
39
+ if (summary.total_views !== null) lines.push(`• Total views: ${summary.total_views.toLocaleString()}`);
40
+ if (summary.total_accounts_engaged !== null) {
41
+ lines.push(`• Total accounts engaged: ${summary.total_accounts_engaged.toLocaleString()}`);
42
+ }
43
+ lines.push(
44
+ `• Engagement rate (by reach): ${summary.engagement_rate}%`,
45
+ `• Engagement rate (by followers): ${summary.engagement_rate_by_followers}%`
46
+ );
47
+
48
+ if (account.platform === 'google_business') {
49
+ if (summary.total_website_clicks !== null) {
50
+ lines.push(`• Website clicks: ${summary.total_website_clicks.toLocaleString()}`);
51
+ }
52
+ if (summary.total_call_clicks !== null) {
53
+ lines.push(`• Call clicks: ${summary.total_call_clicks.toLocaleString()}`);
54
+ }
55
+ if (summary.total_direction_requests !== null) {
56
+ lines.push(`• Direction requests: ${summary.total_direction_requests.toLocaleString()}`);
57
+ }
58
+ if (summary.total_conversations !== null) {
59
+ lines.push(`• Conversations: ${summary.total_conversations.toLocaleString()}`);
60
+ }
61
+ if (summary.total_bookings !== null) {
62
+ lines.push(`• Bookings: ${summary.total_bookings.toLocaleString()}`);
63
+ }
64
+ }
65
+
66
+ return lines.join('\n');
67
+ },
68
+ };
69
+
70
+ function formatDelta(n: number): string {
71
+ return n >= 0 ? `+${n.toLocaleString()}` : n.toLocaleString();
72
+ }
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+
4
+ export const getPostAnalyticsTool = {
5
+ name: 'get_post_analytics',
6
+ description:
7
+ 'Get per-post engagement metrics (likes, comments, reach, impressions, saves, shares, plays) for a connected social account. Supports Instagram, LinkedIn, and Google Business Profile. Returns the most recent posts first.',
8
+ inputSchema: z.object({
9
+ account_id: z
10
+ .number()
11
+ .describe('The social account ID (from list_accounts)'),
12
+ from: z
13
+ .string()
14
+ .optional()
15
+ .describe('Start date (ISO date, e.g. 2026-03-19). Defaults to 30 days ago.'),
16
+ to: z
17
+ .string()
18
+ .optional()
19
+ .describe('End date (ISO date). Defaults to today.'),
20
+ limit: z
21
+ .number()
22
+ .min(1)
23
+ .max(200)
24
+ .optional()
25
+ .describe('Number of posts to return (default 50, max 200)'),
26
+ offset: z.number().min(0).optional().describe('Pagination offset'),
27
+ }),
28
+
29
+ async execute(
30
+ client: PosterlyClient,
31
+ input: {
32
+ account_id: number;
33
+ from?: string;
34
+ to?: string;
35
+ limit?: number;
36
+ offset?: number;
37
+ }
38
+ ) {
39
+ const result = await client.getPostAnalytics(input);
40
+ const { account, range, posts, total } = result;
41
+
42
+ if (posts.length === 0) {
43
+ return `No analytics found for @${account.username} (${account.platform}) between ${range.from} and ${range.to}.`;
44
+ }
45
+
46
+ const lines: string[] = [
47
+ `Post analytics for @${account.username} (${account.platform})`,
48
+ `Range: ${range.from} → ${range.to} • Showing ${posts.length} of ${total}`,
49
+ '',
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
+
71
+ lines.push(`• [${postedAt}] ${caption}`);
72
+ lines.push(` ${metrics.join(' • ')}`);
73
+ if (p.permalink) lines.push(` ${p.permalink}`);
74
+ }
75
+
76
+ return lines.join('\n');
77
+ },
78
+ };